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,18 @@
|
||||
import * as React from 'react';
|
||||
import { goBack } from './goBack';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
import styles from './ReportPage.module.scss';
|
||||
|
||||
/** «← Вернуться назад» — назад по истории браузера, как в старой форме. */
|
||||
export const BackLink: React.FC = () => (
|
||||
<a
|
||||
href="#"
|
||||
className={styles.backLink}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
goBack();
|
||||
}}
|
||||
>
|
||||
{REPORT_TEXTS.back}
|
||||
</a>
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ReportPage.module.scss';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
readOnly: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
/** Поле-кнопка: показывает выбранное значение и открывает пикер. */
|
||||
export const PickerField: React.FC<Props> = ({ id, value, placeholder, readOnly, onOpen }) => (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
className={value ? styles.pickerValue : `${styles.pickerValue} ${styles.pickerValueEmpty}`}
|
||||
disabled={readOnly}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{value || placeholder}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@tnsu/ui-kit';
|
||||
import { BackLink } from './BackLink';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
import styles from './ReportPage.module.scss';
|
||||
|
||||
const TEXTS = REPORT_TEXTS.actions;
|
||||
|
||||
/** Какие кнопки показать — решают правила (reportRules), компонент только рисует. */
|
||||
export interface IReportActionsView {
|
||||
showSave: boolean;
|
||||
showBackAfterSave: boolean;
|
||||
showApprove: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
view: IReportActionsView;
|
||||
isSaving: boolean;
|
||||
onSave: () => void;
|
||||
onApprove: () => void;
|
||||
}
|
||||
|
||||
/** Низ формы: «Сохранить отчет» → после сохранения «← Вернуться назад»; в режиме утверждения — «Утвердить отчет». */
|
||||
export const ReportActions: React.FC<Props> = ({ view, isSaving, onSave, onApprove }) => (
|
||||
<div className={styles.actions}>
|
||||
{view.showSave && (
|
||||
<Button type="button" variant="primary" onClick={onSave} disabled={isSaving}>
|
||||
{isSaving ? TEXTS.saving : TEXTS.save}
|
||||
</Button>
|
||||
)}
|
||||
{view.showBackAfterSave && <BackLink />}
|
||||
{view.showApprove && (
|
||||
<Button type="button" variant="accent" onClick={onApprove} disabled={isSaving}>
|
||||
{isSaving ? TEXTS.approving : TEXTS.approve}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import * as React from 'react';
|
||||
import { Spinner } from '@tnsu/ui-kit';
|
||||
import { useAppServices } from '../../app/AppServicesContext';
|
||||
import { EmptyState } from '../../shared/components/EmptyState';
|
||||
import { IReportRouteParams } from './reportTypes';
|
||||
import { createReportServices } from './reportServices';
|
||||
import { canShowBackAfterSave, canShowSaveButton, getStatusBadge } from './reportRules';
|
||||
import { useReportForm } from './useReportForm';
|
||||
import { usePickerModal } from './usePickerModal';
|
||||
import { createSectionHandlers } from './reportSectionHandlers';
|
||||
import { BackLink } from './BackLink';
|
||||
import { ReportHeader } from './ReportHeader';
|
||||
import { ReportMessages } from './ReportMessages';
|
||||
import { ReportMainFields } from './ReportMainFields';
|
||||
import { ReportActions } from './ReportActions';
|
||||
import { WorkSection } from './sections/WorkSection';
|
||||
import { ResourceSection } from './sections/ResourceSection';
|
||||
import { CommentSection } from './sections/CommentSection';
|
||||
import { PhotoSection } from './sections/PhotoSection';
|
||||
import { PickerSheet } from './picker/PickerSheet';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
import styles from './ReportPage.module.scss';
|
||||
|
||||
interface Props {
|
||||
params: IReportRouteParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Страница SharePoint — это форма: Enter в поле ввода отправил бы её. Глушим Enter везде,
|
||||
* кроме многострочных полей, и само событие submit — как в старом web part.
|
||||
*/
|
||||
const preventEnterSubmit = (event: React.KeyboardEvent): void => {
|
||||
if (event.key === 'Enter' && (event.target as HTMLElement).tagName !== 'TEXTAREA') {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
const preventSubmit = (event: React.FormEvent): void => event.preventDefault();
|
||||
|
||||
/** Форма отчёта: собирает секции; состояние — в useReportForm, пикер — в usePickerModal. */
|
||||
export const ReportForm: React.FC<Props> = ({ params }) => {
|
||||
const appServices = useAppServices();
|
||||
const services = React.useMemo(() => createReportServices(appServices), [appServices]);
|
||||
const report = useReportForm(services, params);
|
||||
const picker = usePickerModal({ services, report, projectId: params.projectId });
|
||||
const handlers = createSectionHandlers(report, picker);
|
||||
const { form, mode, isReadOnly } = report;
|
||||
|
||||
if (report.isLoading) {
|
||||
return <Spinner label={REPORT_TEXTS.loading} block />;
|
||||
}
|
||||
if (mode === 'noaccess') {
|
||||
return <EmptyState icon="warning" title={REPORT_TEXTS.noAccess.title} text={REPORT_TEXTS.noAccess.text} />;
|
||||
}
|
||||
|
||||
const isSaved = !!report.successMessage;
|
||||
const runAsync = (action: () => Promise<void>) => (): void => {
|
||||
action().catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page} onKeyDown={preventEnterSubmit} onSubmitCapture={preventSubmit}>
|
||||
<BackLink />
|
||||
<ReportHeader
|
||||
reportNumber={form.reportNumber}
|
||||
projectName={report.projectName}
|
||||
status={getStatusBadge(mode, report.isApproved, report.access)}
|
||||
/>
|
||||
<ReportMessages
|
||||
errorMessage={report.errorMessage}
|
||||
successMessage={report.successMessage}
|
||||
validationErrors={report.validationErrors}
|
||||
/>
|
||||
|
||||
<ReportMainFields
|
||||
values={form}
|
||||
shifts={report.lookups.shifts}
|
||||
readOnly={isReadOnly}
|
||||
onDateChange={handlers.changeDate}
|
||||
onShiftChange={handlers.changeShift}
|
||||
onOpenObject={handlers.openObjectPicker}
|
||||
onOpenCompany={handlers.openCompanyPicker}
|
||||
/>
|
||||
<WorkSection works={form.works} readOnly={isReadOnly} handlers={handlers} />
|
||||
<ResourceSection
|
||||
kind="profession"
|
||||
items={form.professions}
|
||||
readOnly={isReadOnly}
|
||||
onAdd={() => handlers.addResource({ kind: 'profession' })}
|
||||
onChange={(itemId, patch) => handlers.changeResource({ kind: 'profession' }, itemId, patch)}
|
||||
onRemove={(itemId) => handlers.removeResource({ kind: 'profession' }, itemId)}
|
||||
/>
|
||||
<ResourceSection
|
||||
kind="technic"
|
||||
items={form.technics}
|
||||
readOnly={isReadOnly}
|
||||
onAdd={() => handlers.addResource({ kind: 'technic' })}
|
||||
onChange={(itemId, patch) => handlers.changeResource({ kind: 'technic' }, itemId, patch)}
|
||||
onRemove={(itemId) => handlers.removeResource({ kind: 'technic' }, itemId)}
|
||||
/>
|
||||
<CommentSection value={form.comment} readOnly={isReadOnly} onChange={handlers.changeComment} />
|
||||
<PhotoSection
|
||||
photos={form.photos}
|
||||
photoFolderUrl={form.photoFolderUrl}
|
||||
readOnly={isReadOnly}
|
||||
onAddPhotos={handlers.addPhotos}
|
||||
onRemovePhoto={handlers.removePhoto}
|
||||
/>
|
||||
|
||||
{/* Внизу формы ошибки валидации и успех повторяются — как в старой форме (ошибка запроса — только вверху). */}
|
||||
<ReportMessages successMessage={report.successMessage} validationErrors={report.validationErrors} />
|
||||
<ReportActions
|
||||
view={{
|
||||
showSave: canShowSaveButton(mode, isSaved, report.access),
|
||||
showBackAfterSave: canShowBackAfterSave(mode, isSaved),
|
||||
showApprove: mode === 'approve'
|
||||
}}
|
||||
isSaving={report.isSaving}
|
||||
onSave={runAsync(report.save)}
|
||||
onApprove={runAsync(report.approve)}
|
||||
/>
|
||||
|
||||
{picker.isOpen && picker.options && (
|
||||
<PickerSheet
|
||||
options={picker.options}
|
||||
items={picker.items}
|
||||
isLoading={picker.isLoading}
|
||||
onSelect={picker.select}
|
||||
onDismiss={picker.dismiss}
|
||||
onCreateNew={picker.createNew}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { Badge } from '@tnsu/ui-kit';
|
||||
import { ReportStatusBadge } from './reportRules';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
import styles from './ReportPage.module.scss';
|
||||
|
||||
const TEXTS = REPORT_TEXTS.header;
|
||||
|
||||
interface Props {
|
||||
reportNumber: number;
|
||||
projectName: string;
|
||||
status: ReportStatusBadge | undefined;
|
||||
}
|
||||
|
||||
/** «Отчет №N» / «Новый отчет», «Проект: …» и бейдж статуса. */
|
||||
export const ReportHeader: React.FC<Props> = ({ reportNumber, projectName, status }) => (
|
||||
<div className={styles.header}>
|
||||
<h2>{reportNumber > 0 ? TEXTS.reportNumber(reportNumber) : TEXTS.newReport}</h2>
|
||||
<div className={styles.headerSub}>
|
||||
<span>
|
||||
{TEXTS.project} {projectName}
|
||||
</span>
|
||||
{status === 'approved' && <Badge variant="success">{TEXTS.approved}</Badge>}
|
||||
{status === 'pending' && <Badge variant="warning">{TEXTS.pending}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { FormField, Select, TextInput } from '@tnsu/ui-kit';
|
||||
import { IShift } from './reportTypes';
|
||||
import { PickerField } from './PickerField';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
import styles from './ReportPage.module.scss';
|
||||
|
||||
const TEXTS = REPORT_TEXTS.fields;
|
||||
|
||||
const FIELD_IDS = {
|
||||
reportDate: 'report-date',
|
||||
shift: 'report-shift',
|
||||
object: 'report-object',
|
||||
company: 'report-company'
|
||||
} as const;
|
||||
|
||||
/** Значения шапки отчёта — срез состояния формы. */
|
||||
export interface IMainFieldValues {
|
||||
reportDate: string;
|
||||
shiftId: number | undefined;
|
||||
objectPath: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
values: IMainFieldValues;
|
||||
shifts: IShift[];
|
||||
readOnly: boolean;
|
||||
onDateChange: (value: string) => void;
|
||||
onShiftChange: (value: string) => void;
|
||||
onOpenObject: () => void;
|
||||
onOpenCompany: () => void;
|
||||
}
|
||||
|
||||
/** Дата отчета, Смена, Объект, Компания. */
|
||||
export const ReportMainFields: React.FC<Props> = (props) => {
|
||||
const { values, shifts, readOnly } = props;
|
||||
const shiftOptions = shifts.map((shift) => ({ value: String(shift.Id), label: shift.Title }));
|
||||
|
||||
return (
|
||||
<div className={styles.fields}>
|
||||
<FormField label={TEXTS.reportDate} htmlFor={FIELD_IDS.reportDate} required>
|
||||
<TextInput
|
||||
id={FIELD_IDS.reportDate}
|
||||
type="date"
|
||||
value={values.reportDate}
|
||||
onChange={props.onDateChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={TEXTS.shift} htmlFor={FIELD_IDS.shift} required>
|
||||
<Select
|
||||
id={FIELD_IDS.shift}
|
||||
value={values.shiftId === undefined ? '' : String(values.shiftId)}
|
||||
onChange={props.onShiftChange}
|
||||
options={shiftOptions}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={TEXTS.object} htmlFor={FIELD_IDS.object} required>
|
||||
<PickerField
|
||||
id={FIELD_IDS.object}
|
||||
value={values.objectPath}
|
||||
placeholder={TEXTS.objectPlaceholder}
|
||||
readOnly={readOnly}
|
||||
onOpen={props.onOpenObject}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={TEXTS.company} htmlFor={FIELD_IDS.company} required>
|
||||
<PickerField
|
||||
id={FIELD_IDS.company}
|
||||
value={values.companyName}
|
||||
placeholder={TEXTS.companyPlaceholder}
|
||||
readOnly={readOnly}
|
||||
onOpen={props.onOpenCompany}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import { Alert } from '@tnsu/ui-kit';
|
||||
import styles from './ReportPage.module.scss';
|
||||
|
||||
interface Props {
|
||||
errorMessage?: string;
|
||||
successMessage?: string;
|
||||
validationErrors: string[];
|
||||
}
|
||||
|
||||
/** Ошибка, успех и ошибки валидации (каждая строкой). Пустые сообщения не выводятся. */
|
||||
export const ReportMessages: React.FC<Props> = ({ errorMessage, successMessage, validationErrors }) => {
|
||||
if (!errorMessage && !successMessage && validationErrors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.messages}>
|
||||
{errorMessage && <Alert variant="err">{errorMessage}</Alert>}
|
||||
{successMessage && <Alert variant="ok">{successMessage}</Alert>}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert variant="err">
|
||||
{validationErrors.map((message, index) => (
|
||||
<div key={index}>{message}</div>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,301 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Форма заполняется в основном с телефона на объекте — узкая колонка, как в старом web part.
|
||||
$form-max-width: 720px;
|
||||
$comment-min-height: 60px;
|
||||
$photo-thumb-min: 80px;
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-4;
|
||||
width: 100%;
|
||||
max-width: $form-max-width;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
align-self: flex-start;
|
||||
color: $navy-600;
|
||||
font-size: $fs-sm;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $navy-900;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-1;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-form-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
}
|
||||
|
||||
.headerSub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-1;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-3;
|
||||
}
|
||||
|
||||
.pickerValue {
|
||||
width: 100%;
|
||||
min-height: 38px; // высота полей ui-kit
|
||||
padding: $sp-2 $sp-3;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
color: $gray-900;
|
||||
font: inherit;
|
||||
font-size: $fs-body;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-500;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $gray-100;
|
||||
color: $gray-500;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pickerValueEmpty {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-3;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin: $sp-2 0 0;
|
||||
padding-bottom: $sp-2;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-section-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-3;
|
||||
padding: $sp-4;
|
||||
background: $gray-50;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: $fs-body;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.twoColumns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $sp-3;
|
||||
}
|
||||
|
||||
.valueInput {
|
||||
width: 100%;
|
||||
padding: $sp-2 $sp-3;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
font: inherit;
|
||||
font-size: $fs-body;
|
||||
color: $gray-900;
|
||||
|
||||
&:focus {
|
||||
@include input-focus-gold;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $gray-100;
|
||||
color: $gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
.planFact {
|
||||
font-size: $fs-body;
|
||||
color: $gray-900;
|
||||
|
||||
strong {
|
||||
margin-right: $sp-3;
|
||||
}
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: $green-600;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.nestedGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-2;
|
||||
padding-left: $sp-3;
|
||||
border-left: 2px solid $gold-300;
|
||||
}
|
||||
|
||||
.nestedTitle {
|
||||
font-size: $fs-caption;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.nestedCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-2;
|
||||
padding: $sp-3;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.comment {
|
||||
min-height: $comment-min-height;
|
||||
padding: $sp-2 $sp-3;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
font-size: $fs-body;
|
||||
color: $gray-900;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
|
||||
&:focus {
|
||||
@include input-focus-gold;
|
||||
}
|
||||
|
||||
&:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: $gray-400;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.commentDisabled {
|
||||
background: $gray-100;
|
||||
color: $gray-500;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.photoLink a {
|
||||
color: $navy-600;
|
||||
font-size: $fs-sm;
|
||||
}
|
||||
|
||||
.photoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax($photo-thumb-min, 1fr));
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.photoThumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
box-shadow: $sh-1;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.photoRemove {
|
||||
position: absolute;
|
||||
top: $sp-1;
|
||||
right: $sp-1;
|
||||
background: $white;
|
||||
border-radius: $r-full;
|
||||
}
|
||||
|
||||
.photoName {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
padding: 2px $sp-1;
|
||||
background: rgba(15, 36, 64, 0.6); // $navy-900 с прозрачностью — подпись поверх фото
|
||||
color: $white;
|
||||
font-size: $fs-caption;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hiddenInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $sp-3;
|
||||
padding-top: $sp-2;
|
||||
}
|
||||
|
||||
@include below($bp-xs) {
|
||||
.twoColumns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { useRouter } from '../../app/routing/RouterContext';
|
||||
import { parseReportRoute } from './reportRules';
|
||||
import { ReportForm } from './ReportForm';
|
||||
|
||||
/**
|
||||
* Форма отчёта (бывший stroy-bot-report, SendReport.aspx): ?page=report&projectId=…[&reportId=…|©FromId=…].
|
||||
* Старая страница читала адрес один раз при загрузке; здесь при смене параметров форма монтируется заново (key).
|
||||
*/
|
||||
export const ReportPage: React.FC = () => {
|
||||
const { route } = useRouter();
|
||||
const formKey = `${route.projectId ?? ''}|${route.reportId ?? ''}|${route.copyFromId ?? ''}`;
|
||||
return <ReportForm key={formKey} params={parseReportRoute(route)} />;
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import { HttpMethod, IHttpClient, IHttpResponse } from '../../services/http/IHttpClient';
|
||||
import { dataverseUrl } from '../../shared/dataverse/dataverseRest';
|
||||
import { DV_TABLES, DV_WORKS_LOGICAL_NAME } from '../../config/dataverse';
|
||||
import {
|
||||
IJobRow,
|
||||
IJobWithPath,
|
||||
IReportDataFields,
|
||||
IReportDataNode,
|
||||
IReportDataRow,
|
||||
IReportPayload,
|
||||
IReportRow
|
||||
} from './reportTypes';
|
||||
import { buildJobPaths } from './reportRules';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
|
||||
// Запросы формы отчёта к Dataverse — 1:1 со stroy-bot-report (services/DataverseService.ts).
|
||||
// Заголовки у старого сервиса свои (не общие DATAVERSE_HEADERS), поэтому запросы собираются здесь.
|
||||
|
||||
const ODATA_VERSION_HEADERS: Record<string, string> = {
|
||||
'OData-MaxVersion': '4.0',
|
||||
'OData-Version': '4.0'
|
||||
};
|
||||
|
||||
const GET_HEADERS: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...ODATA_VERSION_HEADERS,
|
||||
Prefer: 'odata.include-annotations="*"'
|
||||
};
|
||||
|
||||
const WRITE_HEADERS: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json;odata.metadata=minimal',
|
||||
...ODATA_VERSION_HEADERS
|
||||
};
|
||||
|
||||
const POST_HEADERS: Record<string, string> = { ...WRITE_HEADERS, Prefer: 'return=representation' };
|
||||
|
||||
/** Навигационное свойство родителя у работ, если его не удалось узнать из метаданных. */
|
||||
const DEFAULT_PARENT_NAV_PROPERTY = 'cree9_parentid';
|
||||
|
||||
const JOB_SELECT = 'cree9_tableid,cree9_title,cree9_unit,cree9_objectid,cree9_volume,_cree9_parentid_value';
|
||||
|
||||
// ---------- Адреса (путь после /api/data/v9.2/) ----------
|
||||
|
||||
export const DV_PATHS = {
|
||||
jobsByObject: (objectId: number): string =>
|
||||
`${DV_TABLES.works}?$filter=cree9_objectid eq '${objectId}'&$select=${JOB_SELECT}`,
|
||||
parentNavProperty: (): string =>
|
||||
`EntityDefinitions(LogicalName='${DV_WORKS_LOGICAL_NAME}')/ManyToOneRelationships` +
|
||||
`?$select=ReferencingAttribute,ReferencingEntityNavigationPropertyName` +
|
||||
`&$filter=ReferencingAttribute eq 'cree9_parentid'`,
|
||||
report: (reportId: string): string => `${DV_TABLES.reports}(${reportId})`,
|
||||
reportCount: (projectId: number): string =>
|
||||
`${DV_TABLES.reports}?$filter=cree9_projectid eq ${projectId} and cree9_deleted ne true` +
|
||||
`&$count=true&$top=1&$select=cree9_number`,
|
||||
jobTotalFact: (jobId: string): string =>
|
||||
`${DV_TABLES.reportData}?$filter=_cree9_jobid_value eq ${jobId}&$select=cree9_value`,
|
||||
reportData: (reportId: string): string => `${DV_TABLES.reportData}?$filter=_cree9_column1_value eq ${reportId}`,
|
||||
reportDataRow: (rowId: string): string => `${DV_TABLES.reportData}(${rowId})`
|
||||
} as const;
|
||||
|
||||
// ---------- Тела запросов ----------
|
||||
|
||||
/**
|
||||
* Тело строки отчёта (как buildReportDataPayload): lookup на работу — через @odata.bind,
|
||||
* пустые поля пропускаются, строка всегда привязана к отчёту (cree9_Column1),
|
||||
* а персонал / техника на работе — к строке-работе (cree9_parentId).
|
||||
*/
|
||||
export function buildReportDataPayload(
|
||||
fields: IReportDataFields,
|
||||
reportId: string,
|
||||
parentRowId?: string
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
(Object.keys(fields) as Array<keyof IReportDataFields>).forEach((key) => {
|
||||
const value = fields[key];
|
||||
if (value === undefined || value === null) return;
|
||||
if (key === '_cree9_jobid_value') {
|
||||
payload['cree9_jobId@odata.bind'] = `/${DV_TABLES.works}(${value})`;
|
||||
} else {
|
||||
payload[key] = value;
|
||||
}
|
||||
});
|
||||
payload['cree9_Column1@odata.bind'] = `/${DV_TABLES.reports}(${reportId})`;
|
||||
if (parentRowId) {
|
||||
payload['cree9_parentId@odata.bind'] = `/${DV_TABLES.reportData}(${parentRowId})`;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export interface INewJob {
|
||||
title: string;
|
||||
unit: string;
|
||||
objectId: number;
|
||||
volume?: number;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
function buildJobBody(job: INewJob, parentNavProperty: string | undefined): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
cree9_title: job.title,
|
||||
cree9_unit: job.unit,
|
||||
cree9_objectid: String(job.objectId)
|
||||
};
|
||||
if (job.volume !== undefined && job.volume > 0) {
|
||||
body.cree9_volume = job.volume;
|
||||
}
|
||||
if (job.parentId && parentNavProperty) {
|
||||
body[`${parentNavProperty}@odata.bind`] = `/${DV_TABLES.works}(${job.parentId})`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Порядок удаления старых строк: сначала дочерние (с родителем), затем остальные — иначе self-lookup мешает. */
|
||||
export function orderRowsForDeletion(rows: IReportDataRow[]): IReportDataRow[] {
|
||||
const rank = (row: IReportDataRow): number => (row._cree9_parentid_value ? 0 : 1);
|
||||
return [...rows].sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
// ---------- Репозиторий ----------
|
||||
|
||||
export interface IDataverseReportRepository {
|
||||
getJobsByObjectId(objectId: number): Promise<IJobWithPath[]>;
|
||||
createJob(job: INewJob): Promise<IJobWithPath>;
|
||||
getReportById(reportId: string): Promise<IReportRow>;
|
||||
getNextReportNumber(projectId: number): Promise<number>;
|
||||
approveReport(reportId: string): Promise<void>;
|
||||
getJobTotalFact(jobId: string): Promise<number>;
|
||||
getReportData(reportId: string): Promise<IReportDataRow[]>;
|
||||
saveReportWithData(report: IReportPayload, nodes: IReportDataNode[], existingReportId?: string): Promise<string>;
|
||||
}
|
||||
|
||||
interface ICreatedRow {
|
||||
cree9_reportid?: string;
|
||||
cree9_report_dataid?: string;
|
||||
cree9_tableid?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
async function readErrorBody(response: IHttpResponse): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createDataverseReportRepository(client: IHttpClient, baseUrl: string): IDataverseReportRepository {
|
||||
// Как в старом сервисе: навигационное свойство кэшируется на время жизни экземпляра (страницы).
|
||||
const navPropertyCache = new Map<string, string>();
|
||||
|
||||
const send = async (
|
||||
method: HttpMethod,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
body?: unknown
|
||||
): Promise<IHttpResponse> => {
|
||||
const response = await client.fetch(dataverseUrl(baseUrl, path), {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorBody = await readErrorBody(response);
|
||||
throw new Error(REPORT_TEXTS.errors.dataverse(method, response.status, errorBody));
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const get = async <T>(path: string): Promise<T> => (await (await send('GET', path, GET_HEADERS)).json()) as T;
|
||||
|
||||
const post = async (path: string, body: unknown): Promise<ICreatedRow> => {
|
||||
const response = await send('POST', path, POST_HEADERS, body);
|
||||
// 204 без тела: старый код брал id из заголовка OData-EntityId, а IHttpClient заголовков не отдаёт.
|
||||
// С Prefer: return=representation Dataverse отвечает 201 с телом, так что ветка аварийная.
|
||||
if (response.status === 204) {
|
||||
return { id: '' };
|
||||
}
|
||||
return (await response.json()) as ICreatedRow;
|
||||
};
|
||||
|
||||
const patch = async (path: string, body: unknown): Promise<void> => {
|
||||
await send('PATCH', path, WRITE_HEADERS, body);
|
||||
};
|
||||
|
||||
const getReportData = async (reportId: string): Promise<IReportDataRow[]> => {
|
||||
const result = await get<{ value?: IReportDataRow[] }>(DV_PATHS.reportData(reportId));
|
||||
return result?.value || [];
|
||||
};
|
||||
|
||||
const getParentNavProperty = async (): Promise<string> => {
|
||||
const cached = navPropertyCache.get(DEFAULT_PARENT_NAV_PROPERTY);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const result = await get<{ value?: Array<{ ReferencingEntityNavigationPropertyName?: string }> }>(
|
||||
DV_PATHS.parentNavProperty()
|
||||
);
|
||||
const name = result?.value?.[0]?.ReferencingEntityNavigationPropertyName;
|
||||
if (name) {
|
||||
navPropertyCache.set(DEFAULT_PARENT_NAV_PROPERTY, name);
|
||||
return name;
|
||||
}
|
||||
} catch {
|
||||
// Метаданные недоступны — имя по умолчанию.
|
||||
}
|
||||
return DEFAULT_PARENT_NAV_PROPERTY;
|
||||
};
|
||||
|
||||
const createReportData = async (payload: Record<string, unknown>): Promise<string> => {
|
||||
const result = await post(DV_TABLES.reportData, payload);
|
||||
return result.cree9_report_dataid || result.id || '';
|
||||
};
|
||||
|
||||
const replaceOldRows = async (reportId: string): Promise<void> => {
|
||||
const oldRows = orderRowsForDeletion(await getReportData(reportId));
|
||||
for (const row of oldRows) {
|
||||
if (row.cree9_report_dataid) {
|
||||
await send('DELETE', DV_PATHS.reportDataRow(row.cree9_report_dataid), ODATA_VERSION_HEADERS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createNodes = async (reportId: string, nodes: IReportDataNode[]): Promise<void> => {
|
||||
for (const node of nodes) {
|
||||
const parentRowId = await createReportData(buildReportDataPayload(node.data, reportId));
|
||||
for (const child of node.children || []) {
|
||||
await createReportData(buildReportDataPayload(child, reportId, parentRowId || undefined));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getJobsByObjectId: async (objectId) => {
|
||||
const result = await get<{ value?: IJobRow[] }>(DV_PATHS.jobsByObject(objectId));
|
||||
return buildJobPaths(result?.value || []);
|
||||
},
|
||||
|
||||
createJob: async (job) => {
|
||||
const navProperty = job.parentId ? await getParentNavProperty() : undefined;
|
||||
const result = await post(DV_TABLES.works, buildJobBody(job, navProperty));
|
||||
return {
|
||||
cree9_tableid: result.cree9_tableid || result.id || '',
|
||||
cree9_title: job.title,
|
||||
cree9_unit: job.unit,
|
||||
cree9_objectid: String(job.objectId),
|
||||
cree9_volume: job.volume,
|
||||
_cree9_parentid_value: job.parentId,
|
||||
fullPath: job.title
|
||||
};
|
||||
},
|
||||
|
||||
getReportById: (reportId) => get<IReportRow>(DV_PATHS.report(reportId)),
|
||||
|
||||
getNextReportNumber: async (projectId) => {
|
||||
// 🐞 Номер = число неудалённых отчётов + 1: после мягкого удаления номер может повториться.
|
||||
const result = await get<{ '@odata.count'?: number }>(DV_PATHS.reportCount(projectId));
|
||||
return (result['@odata.count'] || 0) + 1;
|
||||
},
|
||||
|
||||
approveReport: (reportId) => patch(DV_PATHS.report(reportId), { cree9_aprooved: true }),
|
||||
|
||||
getJobTotalFact: async (jobId) => {
|
||||
const result = await get<{ value?: Array<{ cree9_value?: number }> }>(DV_PATHS.jobTotalFact(jobId));
|
||||
return (result?.value || []).reduce((sum, row) => sum + (row.cree9_value || 0), 0);
|
||||
},
|
||||
|
||||
getReportData,
|
||||
|
||||
saveReportWithData: async (report, nodes, existingReportId) => {
|
||||
let reportId: string;
|
||||
if (existingReportId) {
|
||||
await patch(DV_PATHS.report(existingReportId), report);
|
||||
reportId = existingReportId;
|
||||
await replaceOldRows(reportId);
|
||||
} else {
|
||||
const created = await post(DV_TABLES.reports, report);
|
||||
reportId = created.cree9_reportid || created.id || '';
|
||||
}
|
||||
if (!reportId) {
|
||||
throw new Error(REPORT_TEXTS.errors.emptyReportId);
|
||||
}
|
||||
await createNodes(reportId, nodes);
|
||||
return reportId;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* «← Вернуться назад» — как в старом коде: если страница открыта во фрейме, назад идёт верхнее окно,
|
||||
* при ошибке доступа к нему (другой домен) — текущее.
|
||||
*/
|
||||
export function goBack(): void {
|
||||
try {
|
||||
if (window.top && window.top !== window) {
|
||||
window.top.history.back();
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
} catch {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IPhotoItem } from './reportTypes';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
|
||||
function readFile<T extends ArrayBuffer | string>(file: File, read: (reader: FileReader) => void): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as T);
|
||||
reader.onerror = () => reject(new Error(REPORT_TEXTS.photos.readError(file.name)));
|
||||
read(reader);
|
||||
});
|
||||
}
|
||||
|
||||
/** Выбранные фото: содержимое для загрузки и data: URL для превью. Файлы читаются по очереди. */
|
||||
export async function readPhotoFiles(files: File[]): Promise<IPhotoItem[]> {
|
||||
const photos: IPhotoItem[] = [];
|
||||
for (const file of files) {
|
||||
const buffer = await readFile<ArrayBuffer>(file, (reader) => reader.readAsArrayBuffer(file));
|
||||
const preview = await readFile<string>(file, (reader) => reader.readAsDataURL(file));
|
||||
photos.push({ name: file.name, preview, buffer });
|
||||
}
|
||||
return photos;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Нижняя шторка выбора — как модалка старого stroy-bot-report (выезжает снизу, удобно на телефоне).
|
||||
$sheet-max-width: 680px;
|
||||
$sheet-max-height: 80vh;
|
||||
// Поверх шапки и панелей SharePoint (у них z-index до ~1000).
|
||||
$sheet-z-index: 10000;
|
||||
$overlay-color: rgba(10, 27, 48, 0.5);
|
||||
$slide-duration: 0.2s;
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: $sheet-z-index;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
background: $overlay-color;
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: $sheet-max-width;
|
||||
max-height: $sheet-max-height;
|
||||
overflow: hidden;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-lg $r-lg 0 0;
|
||||
box-shadow: $sh-modal;
|
||||
animation: slideUp $slide-duration ease-out;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $sp-2;
|
||||
padding: $sp-4 $sp-5 $sp-2;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-section-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 0 $sp-5 $sp-3;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $sp-1;
|
||||
padding: 0 $sp-5 $sp-2;
|
||||
font-size: $fs-sm;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: $navy-600;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.crumbCurrent {
|
||||
color: $navy-900;
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.crumbSeparator {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.filterRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $sp-2;
|
||||
padding: 0 $sp-5 $sp-2;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $sp-2;
|
||||
padding: 0 $sp-5 $sp-3;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-1;
|
||||
padding: $sp-1 $sp-3;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-full;
|
||||
background: $white;
|
||||
color: $gray-700;
|
||||
font: inherit;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: $gold-500;
|
||||
}
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
border-color: $navy-800;
|
||||
background: $navy-800;
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
border-color: $navy-800;
|
||||
}
|
||||
}
|
||||
|
||||
.filterBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
padding: 0 $sp-1;
|
||||
border-radius: $r-full;
|
||||
background: $gold-500;
|
||||
color: $navy-900;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $sp-2;
|
||||
width: 100%;
|
||||
padding: $sp-3 $sp-5;
|
||||
border: 0;
|
||||
border-bottom: 1px solid $gray-100;
|
||||
background: $white;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $gold-50;
|
||||
}
|
||||
}
|
||||
|
||||
.itemSelectCurrent {
|
||||
background: $navy-50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.itemText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
font-size: $fs-body;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.itemSubtitle {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.itemMark {
|
||||
flex-shrink: 0;
|
||||
color: $gold-600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: $sp-5;
|
||||
text-align: center;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.createSection {
|
||||
padding: $sp-3 $sp-5 $sp-4;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.createForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.createHint {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.createError {
|
||||
font-size: $fs-sm;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.createActions {
|
||||
display: flex;
|
||||
gap: $sp-2;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import { IDrillLevel } from './pickerTypes';
|
||||
import { REPORT_TEXTS } from '../texts';
|
||||
import styles from './Picker.module.scss';
|
||||
|
||||
interface Props {
|
||||
path: IDrillLevel[];
|
||||
/** Перейти на уровень: 0 — «Все работы», n — n-й элемент пути. */
|
||||
onNavigate: (levelIndex: number) => void;
|
||||
}
|
||||
|
||||
/** Хлебные крошки drill-down: «Все работы › Родитель › …». */
|
||||
export const PickerBreadcrumbs: React.FC<Props> = ({ path, onNavigate }) => {
|
||||
const crumbClass = (isCurrent: boolean): string => (isCurrent ? `${styles.crumb} ${styles.crumbCurrent}` : styles.crumb);
|
||||
|
||||
return (
|
||||
<div className={styles.breadcrumbs}>
|
||||
<button type="button" className={crumbClass(path.length === 0)} onClick={() => onNavigate(0)}>
|
||||
{REPORT_TEXTS.picker.allWorks}
|
||||
</button>
|
||||
{path.map((level, index) => (
|
||||
<React.Fragment key={level.id}>
|
||||
<span className={styles.crumbSeparator}>›</span>
|
||||
<button
|
||||
type="button"
|
||||
className={crumbClass(index === path.length - 1)}
|
||||
onClick={() => onNavigate(index + 1)}
|
||||
>
|
||||
{level.title}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Select, TextInput } from '@tnsu/ui-kit';
|
||||
import { IPickerCreateDraft, IPickerItem } from './pickerTypes';
|
||||
import { IPickerCreateState } from './usePickerCreate';
|
||||
import { REPORT_TEXTS } from '../texts';
|
||||
import styles from './Picker.module.scss';
|
||||
|
||||
const TEXTS = REPORT_TEXTS.picker;
|
||||
|
||||
interface Props {
|
||||
create: IPickerCreateState;
|
||||
/** Поля работы: ед. изм., план, родитель. */
|
||||
requiresUnit: boolean;
|
||||
/** Название текущего уровня drill-down: работа будет вложена в него (вместо списка родителей). */
|
||||
nestedInTitle: string | undefined;
|
||||
/** Кандидаты в родители (все работы объекта). */
|
||||
parentCandidates: IPickerItem[];
|
||||
}
|
||||
|
||||
const stopPropagation = (event: React.KeyboardEvent): void => event.stopPropagation();
|
||||
|
||||
/** Форма создания новой позиции (техники или работы). */
|
||||
export const PickerCreateForm: React.FC<Props> = ({ create, requiresUnit, nestedInTitle, parentCandidates }) => {
|
||||
const { draft, isCreating, errorMessage } = create;
|
||||
const change =
|
||||
(field: keyof IPickerCreateDraft) =>
|
||||
(value: string): void =>
|
||||
create.changeDraft({ [field]: value });
|
||||
|
||||
const parentOptions = parentCandidates.map((item) => ({ value: String(item.id), label: item.fullPath || item.title }));
|
||||
|
||||
return (
|
||||
<div className={styles.createForm}>
|
||||
<TextInput
|
||||
value={draft.title}
|
||||
onChange={change('title')}
|
||||
placeholder={TEXTS.newTitle}
|
||||
onKeyDown={stopPropagation}
|
||||
autoFocus
|
||||
/>
|
||||
{requiresUnit && (
|
||||
<>
|
||||
<TextInput value={draft.unit} onChange={change('unit')} placeholder={TEXTS.newUnit} onKeyDown={stopPropagation} />
|
||||
<TextInput
|
||||
type="number"
|
||||
value={draft.plan}
|
||||
onChange={change('plan')}
|
||||
placeholder={TEXTS.newPlan}
|
||||
onKeyDown={stopPropagation}
|
||||
/>
|
||||
{nestedInTitle !== undefined ? (
|
||||
<div className={styles.createHint}>{TEXTS.nestedIn(nestedInTitle)}</div>
|
||||
) : (
|
||||
parentCandidates.length > 0 && (
|
||||
<Select
|
||||
value={draft.parentId}
|
||||
onChange={change('parentId')}
|
||||
options={parentOptions}
|
||||
placeholder={TEXTS.parentPlaceholder}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{errorMessage && <div className={styles.createError}>{errorMessage}</div>}
|
||||
<div className={styles.createActions}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => create.submit().catch(() => undefined)}
|
||||
disabled={isCreating || !draft.title.trim()}
|
||||
>
|
||||
{isCreating ? TEXTS.creating : TEXTS.create}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={create.cancel}>
|
||||
{TEXTS.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@tnsu/ui-kit';
|
||||
import { IDrillLevel, IPickerCreateData, IPickerItem } from './pickerTypes';
|
||||
import { createButtonLabel, currentParentId } from './pickerRules';
|
||||
import { usePickerCreate } from './usePickerCreate';
|
||||
import { PickerCreateForm } from './PickerCreateForm';
|
||||
import styles from './Picker.module.scss';
|
||||
|
||||
interface Props {
|
||||
items: IPickerItem[];
|
||||
search: string;
|
||||
drillDown: boolean;
|
||||
drillPath: IDrillLevel[];
|
||||
requiresUnit: boolean;
|
||||
onCreateNew: (data: IPickerCreateData) => Promise<IPickerItem>;
|
||||
onSelect: (item: IPickerItem) => void;
|
||||
}
|
||||
|
||||
/** Низ пикера: кнопка «+ Создать новую позицию» / «+ Добавить в «…»» или форма создания. */
|
||||
export const PickerCreateSection: React.FC<Props> = (props) => {
|
||||
const { items, search, drillDown, drillPath, requiresUnit } = props;
|
||||
const create = usePickerCreate(props.onCreateNew, props.onSelect);
|
||||
const parentId = drillDown ? currentParentId(drillPath) : undefined;
|
||||
const currentLevel = drillPath[drillPath.length - 1];
|
||||
|
||||
return (
|
||||
<div className={styles.createSection}>
|
||||
{create.isFormOpen ? (
|
||||
<PickerCreateForm
|
||||
create={create}
|
||||
requiresUnit={requiresUnit}
|
||||
nestedInTitle={parentId ? currentLevel?.title : undefined}
|
||||
parentCandidates={items}
|
||||
/>
|
||||
) : (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => create.open(search, parentId)}>
|
||||
{createButtonLabel(drillDown, drillPath)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import { IDrillLevel, IPickerItem } from './pickerTypes';
|
||||
import { currentParentId, drillLevelItems, hasChildren } from './pickerRules';
|
||||
import { PickerListItem } from './PickerListItem';
|
||||
import { REPORT_TEXTS } from '../texts';
|
||||
import styles from './Picker.module.scss';
|
||||
|
||||
interface Props {
|
||||
items: IPickerItem[];
|
||||
drillPath: IDrillLevel[];
|
||||
onSelect: (item: IPickerItem) => void;
|
||||
onDrillInto: (item: IPickerItem) => void;
|
||||
}
|
||||
|
||||
/** Работы по уровням: работа с вложенными открывает уровень, без вложенных — выбирается. */
|
||||
export const PickerDrillList: React.FC<Props> = ({ items, drillPath, onSelect, onDrillInto }) => {
|
||||
const parentId = currentParentId(drillPath);
|
||||
const levelItems = drillLevelItems(items, parentId);
|
||||
const currentLevel = drillPath[drillPath.length - 1];
|
||||
|
||||
const selectCurrentLevel = (): void => {
|
||||
const currentItem = items.filter((item) => String(item.id) === currentLevel.id)[0];
|
||||
if (currentItem) onSelect(currentItem);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentLevel && (
|
||||
<PickerListItem
|
||||
title={REPORT_TEXTS.picker.selectCurrent(currentLevel.title)}
|
||||
mark="✓"
|
||||
isSelectCurrent
|
||||
onClick={selectCurrentLevel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{levelItems.length === 0 ? (
|
||||
<div className={styles.message}>{REPORT_TEXTS.picker.emptyLevel}</div>
|
||||
) : (
|
||||
levelItems.map((item) => {
|
||||
const isParent = hasChildren(items, item.id);
|
||||
return (
|
||||
<PickerListItem
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
subtitle={item.subtitle}
|
||||
mark={isParent ? '›' : undefined}
|
||||
onClick={() => (isParent ? onDrillInto(item) : onSelect(item))}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { REPORT_TEXTS } from '../texts';
|
||||
import styles from './Picker.module.scss';
|
||||
|
||||
interface Props {
|
||||
groups: string[];
|
||||
selectedGroup: string | undefined;
|
||||
onSelectGroup: (group: string | undefined) => void;
|
||||
}
|
||||
|
||||
/** Фильтр по группам: кнопка «Фильтры ▼», сброс выбранной группы «X ✕», чипы «Все» и групп. */
|
||||
export const PickerGroupFilter: React.FC<Props> = ({ groups, selectedGroup, onSelectGroup }) => {
|
||||
const [isOpen, setIsOpen] = React.useState<boolean>(false);
|
||||
const chipClass = (isActive: boolean): string => (isActive ? `${styles.chip} ${styles.chipActive}` : styles.chip);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.filterRow}>
|
||||
<button type="button" className={chipClass(!!selectedGroup)} onClick={() => setIsOpen((prev) => !prev)}>
|
||||
{REPORT_TEXTS.picker.filters} {selectedGroup && <span className={styles.filterBadge}>1</span>}{' '}
|
||||
{isOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
{selectedGroup && (
|
||||
<button type="button" className={styles.chip} onClick={() => onSelectGroup(undefined)}>
|
||||
{selectedGroup} ✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className={styles.chips}>
|
||||
<button type="button" className={chipClass(!selectedGroup)} onClick={() => onSelectGroup(undefined)}>
|
||||
{REPORT_TEXTS.picker.allGroups}
|
||||
</button>
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group}
|
||||
type="button"
|
||||
className={chipClass(selectedGroup === group)}
|
||||
onClick={() => onSelectGroup(group === selectedGroup ? undefined : group)}
|
||||
>
|
||||
{group}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Picker.module.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Знак справа: «›» — есть вложенные, «✓» — выбрать текущий уровень. */
|
||||
mark?: string;
|
||||
isSelectCurrent?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** Строка списка пикера. */
|
||||
export const PickerListItem: React.FC<Props> = ({ title, subtitle, mark, isSelectCurrent, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={isSelectCurrent ? `${styles.item} ${styles.itemSelectCurrent}` : styles.item}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={styles.itemText}>
|
||||
<span className={styles.itemTitle}>{title}</span>
|
||||
{subtitle && <span className={styles.itemSubtitle}>{subtitle}</span>}
|
||||
</span>
|
||||
{mark && <span className={styles.itemMark}>{mark}</span>}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { IconButton, TextInput } from '@tnsu/ui-kit';
|
||||
import { IDrillLevel, IPickerCreateData, IPickerItem, IPickerOptions } from './pickerTypes';
|
||||
import { collectGroups, filterPickerItems } from './pickerRules';
|
||||
import { PickerBreadcrumbs } from './PickerBreadcrumbs';
|
||||
import { PickerGroupFilter } from './PickerGroupFilter';
|
||||
import { PickerDrillList } from './PickerDrillList';
|
||||
import { PickerListItem } from './PickerListItem';
|
||||
import { PickerCreateSection } from './PickerCreateSection';
|
||||
import { REPORT_TEXTS } from '../texts';
|
||||
import styles from './Picker.module.scss';
|
||||
|
||||
const TEXTS = REPORT_TEXTS.picker;
|
||||
|
||||
interface Props {
|
||||
options: IPickerOptions;
|
||||
items: IPickerItem[];
|
||||
isLoading: boolean;
|
||||
onSelect: (item: IPickerItem) => void;
|
||||
onDismiss: () => void;
|
||||
onCreateNew: (data: IPickerCreateData) => Promise<IPickerItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нижняя шторка выбора из справочника (бывший PickerModal): поиск, чипы групп, drill-down по работам,
|
||||
* создание новой позиции. Монтируется при каждом открытии — состояние поиска и фильтров сбрасывается.
|
||||
* Рендерится в body, чтобы position: fixed не зависел от контейнеров страницы SharePoint.
|
||||
*/
|
||||
export const PickerSheet: React.FC<Props> = ({ options, items, isLoading, onSelect, onDismiss, onCreateNew }) => {
|
||||
const [search, setSearch] = React.useState<string>('');
|
||||
const [selectedGroup, setSelectedGroup] = React.useState<string | undefined>(undefined);
|
||||
const [drillPath, setDrillPath] = React.useState<IDrillLevel[]>([]);
|
||||
|
||||
const isDrillMode = options.drillDown && !search;
|
||||
const groups = options.groupFilter ? collectGroups(items) : [];
|
||||
|
||||
const drillInto = (item: IPickerItem): void => {
|
||||
setDrillPath((prev) => [...prev, { id: String(item.id), title: item.title }]);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const renderList = (): React.ReactNode => {
|
||||
if (isLoading) return <div className={styles.message}>{TEXTS.loading}</div>;
|
||||
if (isDrillMode) {
|
||||
return <PickerDrillList items={items} drillPath={drillPath} onSelect={onSelect} onDrillInto={drillInto} />;
|
||||
}
|
||||
const filtered = filterPickerItems(items, search, selectedGroup, options.drillDown);
|
||||
if (filtered.length === 0) return <div className={styles.message}>{TEXTS.notFound}</div>;
|
||||
return filtered.map((item) => (
|
||||
<PickerListItem
|
||||
key={item.id}
|
||||
title={item.fullPath || item.title}
|
||||
subtitle={item.subtitle}
|
||||
onClick={() => onSelect(item)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const sheet = (
|
||||
<div className={styles.overlay} onClick={onDismiss}>
|
||||
<div
|
||||
className={styles.sheet}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={options.title}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<h3>{options.title}</h3>
|
||||
<IconButton icon="close" title={TEXTS.close} onClick={onDismiss} />
|
||||
</div>
|
||||
|
||||
<div className={styles.search}>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={TEXTS.searchPlaceholder}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isDrillMode && <PickerBreadcrumbs path={drillPath} onNavigate={(level) => setDrillPath((prev) => prev.slice(0, level))} />}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<PickerGroupFilter groups={groups} selectedGroup={selectedGroup} onSelectGroup={setSelectedGroup} />
|
||||
)}
|
||||
|
||||
<div className={styles.list}>{renderList()}</div>
|
||||
|
||||
{options.allowCreate && !isLoading && (
|
||||
<PickerCreateSection
|
||||
items={items}
|
||||
search={search}
|
||||
drillDown={options.drillDown}
|
||||
drillPath={drillPath}
|
||||
requiresUnit={options.requiresUnit}
|
||||
onCreateNew={onCreateNew}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return ReactDOM.createPortal(sheet, document.body);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { IDrillLevel, IPickerCreateData, IPickerCreateDraft, IPickerItem } from './pickerTypes';
|
||||
import { REPORT_TEXTS } from '../texts';
|
||||
|
||||
// Правила пикера — 1:1 с PickerModal.tsx.
|
||||
|
||||
function containsIgnoreCase(text: string, query: string): boolean {
|
||||
return text.toLowerCase().indexOf(query.toLowerCase()) !== -1;
|
||||
}
|
||||
|
||||
/** Группы для чипов: непустые subtitle в порядке появления, без повторов. */
|
||||
export function collectGroups(items: IPickerItem[]): string[] {
|
||||
const groups: string[] = [];
|
||||
items.forEach((item) => {
|
||||
if (item.subtitle && groups.indexOf(item.subtitle) === -1) groups.push(item.subtitle);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Плоский список / результаты поиска.
|
||||
* В drill-down с поиском ищем по полному пути и subtitle (без фильтра групп);
|
||||
* иначе — по названию и subtitle с фильтром по выбранной группе.
|
||||
*/
|
||||
export function filterPickerItems(
|
||||
items: IPickerItem[],
|
||||
search: string,
|
||||
selectedGroup: string | undefined,
|
||||
drillDown: boolean
|
||||
): IPickerItem[] {
|
||||
if (drillDown && search) {
|
||||
return items.filter(
|
||||
(item) => containsIgnoreCase(item.fullPath || item.title, search) || containsIgnoreCase(item.subtitle || '', search)
|
||||
);
|
||||
}
|
||||
return items.filter((item) => {
|
||||
const matchesSearch =
|
||||
containsIgnoreCase(item.title || '', search) || (!!item.subtitle && containsIgnoreCase(item.subtitle, search));
|
||||
const matchesGroup = !selectedGroup || item.subtitle === selectedGroup;
|
||||
return matchesSearch && matchesGroup;
|
||||
});
|
||||
}
|
||||
|
||||
/** Id родителя текущего уровня drill-down (undefined — корень). */
|
||||
export function currentParentId(drillPath: IDrillLevel[]): string | undefined {
|
||||
return drillPath.length > 0 ? drillPath[drillPath.length - 1].id : undefined;
|
||||
}
|
||||
|
||||
/** Элементы текущего уровня: у кого родитель = текущий уровень (в корне — без родителя). */
|
||||
export function drillLevelItems(items: IPickerItem[], parentId: string | undefined): IPickerItem[] {
|
||||
return items.filter((item) => item.parentId === parentId);
|
||||
}
|
||||
|
||||
export function hasChildren(items: IPickerItem[], itemId: number | string): boolean {
|
||||
return items.some((item) => item.parentId === String(itemId));
|
||||
}
|
||||
|
||||
export function createButtonLabel(drillDown: boolean, drillPath: IDrillLevel[]): string {
|
||||
return drillDown && drillPath.length > 0
|
||||
? REPORT_TEXTS.picker.addInto(drillPath[drillPath.length - 1].title)
|
||||
: REPORT_TEXTS.picker.createNew;
|
||||
}
|
||||
|
||||
export const EMPTY_CREATE_DRAFT: IPickerCreateDraft = { title: '', unit: '', plan: '0', parentId: '' };
|
||||
|
||||
/** Черновик → данные создания: пустые значения не передаются, план — parseFloat. */
|
||||
export function toCreateData(draft: IPickerCreateDraft): IPickerCreateData {
|
||||
return {
|
||||
title: draft.title.trim(),
|
||||
unit: draft.unit.trim() || undefined,
|
||||
plan: draft.plan ? parseFloat(draft.plan) : undefined,
|
||||
parentId: draft.parentId || undefined
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Типы пикера (нижняя шторка выбора) — как в старом modals/PickerModal.tsx.
|
||||
|
||||
export interface IPickerItem {
|
||||
id: number | string;
|
||||
title: string;
|
||||
/** Подпись под названием: ед. изм. у работ, группа у техники. По ней же работают чипы-фильтры. */
|
||||
subtitle?: string;
|
||||
/** Родитель (только для работ, drill-down). */
|
||||
parentId?: string;
|
||||
/** Полный путь (только для работ) — показывается в поиске. */
|
||||
fullPath?: string;
|
||||
}
|
||||
|
||||
/** Данные формы «создать новую позицию». */
|
||||
export interface IPickerCreateData {
|
||||
title: string;
|
||||
unit?: string;
|
||||
plan?: number;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
/** Что умеет пикер для конкретного справочника. */
|
||||
export interface IPickerOptions {
|
||||
title: string;
|
||||
/** Чипы-фильтры по subtitle (техника). */
|
||||
groupFilter: boolean;
|
||||
/** Навигация по уровням с хлебными крошками (работы). */
|
||||
drillDown: boolean;
|
||||
/** Кнопка «+ Создать новую позицию». */
|
||||
allowCreate: boolean;
|
||||
/** В форме создания есть ед. изм., план и родитель (работы). */
|
||||
requiresUnit: boolean;
|
||||
}
|
||||
|
||||
/** Уровень drill-down: выбранная родительская работа. */
|
||||
export interface IDrillLevel {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Черновик формы создания. План хранится строкой, как ввёл пользователь. */
|
||||
export interface IPickerCreateDraft {
|
||||
title: string;
|
||||
unit: string;
|
||||
plan: string;
|
||||
parentId: string;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { IPickerCreateData, IPickerCreateDraft, IPickerItem } from './pickerTypes';
|
||||
import { EMPTY_CREATE_DRAFT, toCreateData } from './pickerRules';
|
||||
|
||||
export interface IPickerCreateState {
|
||||
isFormOpen: boolean;
|
||||
draft: IPickerCreateDraft;
|
||||
isCreating: boolean;
|
||||
errorMessage: string;
|
||||
open: (search: string, parentId: string | undefined) => void;
|
||||
cancel: () => void;
|
||||
changeDraft: (patch: Partial<IPickerCreateDraft>) => void;
|
||||
submit: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Форма «создать новую позицию» в пикере. Как в старом коде: при открытии название берётся из поиска,
|
||||
* а родитель — из текущего уровня drill-down; остальные поля черновика сохраняются между открытиями.
|
||||
* Созданная позиция сразу выбирается.
|
||||
*/
|
||||
export function usePickerCreate(
|
||||
onCreateNew: (data: IPickerCreateData) => Promise<IPickerItem>,
|
||||
onSelect: (item: IPickerItem) => void
|
||||
): IPickerCreateState {
|
||||
const [isFormOpen, setIsFormOpen] = React.useState<boolean>(false);
|
||||
const [draft, setDraft] = React.useState<IPickerCreateDraft>(EMPTY_CREATE_DRAFT);
|
||||
const [isCreating, setIsCreating] = React.useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string>('');
|
||||
|
||||
// Выбор созданной позиции закрывает пикер — после этого состояние уже не обновляем.
|
||||
const isMountedRef = React.useRef(true);
|
||||
React.useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const open = (search: string, parentId: string | undefined): void => {
|
||||
setIsFormOpen(true);
|
||||
setDraft((prev) => ({ ...prev, title: search, parentId: parentId ?? prev.parentId }));
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
const cancel = (): void => {
|
||||
setIsFormOpen(false);
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!draft.title.trim()) return;
|
||||
setIsCreating(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const item = await onCreateNew(toCreateData(draft));
|
||||
onSelect(item);
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) setErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (isMountedRef.current) setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isFormOpen,
|
||||
draft,
|
||||
isCreating,
|
||||
errorMessage,
|
||||
open,
|
||||
cancel,
|
||||
changeDraft: (patch) => setDraft((prev) => ({ ...prev, ...patch })),
|
||||
submit
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { IJobWithPath, IReportDataRow, IResourceItem, IWorkItem } from './reportTypes';
|
||||
import { HOURS_TYPES } from './reportConstants';
|
||||
import { createLocalId } from './reportFormUpdates';
|
||||
import { IReportServices } from './reportServices';
|
||||
|
||||
// Разбор строк сохранённого отчёта в работы / персонал / технику формы — как buildFormCollections.
|
||||
|
||||
export interface IFormCollections {
|
||||
works: IWorkItem[];
|
||||
professions: IResourceItem[];
|
||||
technics: IResourceItem[];
|
||||
}
|
||||
|
||||
function isWorkRow(row: IReportDataRow): boolean {
|
||||
return !!row._cree9_jobid_value && !row._cree9_parentid_value;
|
||||
}
|
||||
|
||||
function workFromRow(row: IReportDataRow, jobs: IJobWithPath[]): IWorkItem {
|
||||
const job = jobs.filter((candidate) => candidate.cree9_tableid === row._cree9_jobid_value)[0];
|
||||
return {
|
||||
id: createLocalId(),
|
||||
jobId: row._cree9_jobid_value || '',
|
||||
jobTitle: job?.cree9_title || '',
|
||||
jobUnit: job?.cree9_unit || '',
|
||||
value: row.cree9_value || 0,
|
||||
plan: job?.cree9_volume || 0,
|
||||
totalFact: 0,
|
||||
comment: row.cree9_comment || '',
|
||||
professions: [],
|
||||
technics: []
|
||||
};
|
||||
}
|
||||
|
||||
/** Строка персонала / техники. Название — отдельным запросом; ошибка не мешает (название пустое). */
|
||||
async function resourceFromRow(
|
||||
row: IReportDataRow,
|
||||
rawId: number | string | undefined,
|
||||
loadTitle: (id: number) => Promise<{ Title?: string }>
|
||||
): Promise<IResourceItem> {
|
||||
const resourceId = Number(rawId);
|
||||
let title = '';
|
||||
try {
|
||||
title = (await loadTitle(resourceId))?.Title || '';
|
||||
} catch {
|
||||
// Как в старом коде: элемент справочника удалён или нет доступа — без названия.
|
||||
}
|
||||
// Старый бот писал название в комментарий — такой комментарий не показываем.
|
||||
const comment = row.cree9_comment && row.cree9_comment !== title ? row.cree9_comment : '';
|
||||
return {
|
||||
id: createLocalId(),
|
||||
resourceId,
|
||||
title,
|
||||
count: row.cree9_count || 0,
|
||||
hours: row.cree9_hours || 0,
|
||||
hoursType: row.cree9_hourstype || HOURS_TYPES.work,
|
||||
comment
|
||||
};
|
||||
}
|
||||
|
||||
async function loadJobsSafe(services: IReportServices, objectId: number): Promise<IJobWithPath[]> {
|
||||
try {
|
||||
return await services.dv.getJobsByObjectId(objectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fillTotalFacts(services: IReportServices, works: IWorkItem[]): Promise<IWorkItem[]> {
|
||||
return Promise.all(
|
||||
works.map(async (work) => {
|
||||
try {
|
||||
return { ...work, totalFact: await services.dv.getJobTotalFact(work.jobId) };
|
||||
} catch {
|
||||
return work;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Строки-работы (есть работа, нет родителя) становятся работами; персонал / техника с родителем
|
||||
* прикрепляются к своей работе, без родителя — попадают в общие секции. Названия грузятся по одному,
|
||||
* в порядке строк; затем факт по каждой работе — параллельно.
|
||||
*/
|
||||
export async function buildFormCollections(
|
||||
services: IReportServices,
|
||||
rows: IReportDataRow[],
|
||||
objectId: number
|
||||
): Promise<IFormCollections> {
|
||||
const jobs = await loadJobsSafe(services, objectId);
|
||||
const works: IWorkItem[] = [];
|
||||
const workByRowId = new Map<string, IWorkItem>();
|
||||
rows.filter(isWorkRow).forEach((row) => {
|
||||
const work = workFromRow(row, jobs);
|
||||
works.push(work);
|
||||
if (row.cree9_report_dataid) workByRowId.set(row.cree9_report_dataid, work);
|
||||
});
|
||||
|
||||
const professions: IResourceItem[] = [];
|
||||
const technics: IResourceItem[] = [];
|
||||
for (const row of rows.filter((candidate) => !isWorkRow(candidate))) {
|
||||
const parent = row._cree9_parentid_value ? workByRowId.get(row._cree9_parentid_value) : undefined;
|
||||
if (row.cree9_professionid) {
|
||||
const item = await resourceFromRow(row, row.cree9_professionid, services.sp.getProfessionById);
|
||||
(parent ? parent.professions : professions).push(item);
|
||||
} else if (row.cree9_technicid) {
|
||||
const item = await resourceFromRow(row, row.cree9_technicid, services.sp.getTechnicById);
|
||||
(parent ? parent.technics : technics).push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { works: await fillTotalFacts(services, works), professions, technics };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Значения, которые хранятся в данных (Dataverse) и участвуют в логике формы отчёта.
|
||||
|
||||
/** Тип часов персонала / техники — значение поля cree9_hourstype. */
|
||||
export const HOURS_TYPES = {
|
||||
work: 'Часы работы',
|
||||
idle: 'Часы простоя',
|
||||
repair: 'Часы ремонта'
|
||||
} as const;
|
||||
|
||||
/** Варианты «Тип часов» у общего персонала. */
|
||||
export const PROFESSION_HOURS_TYPES: string[] = [HOURS_TYPES.work, HOURS_TYPES.idle];
|
||||
|
||||
/** Варианты «Тип часов» у общей техники. */
|
||||
export const TECHNIC_HOURS_TYPES: string[] = [HOURS_TYPES.work, HOURS_TYPES.idle, HOURS_TYPES.repair];
|
||||
|
||||
/** Сколько дней после даты отчёта его можно править, если в «Проекты» не задан editDays. */
|
||||
export const DEFAULT_EDIT_DAYS = 5;
|
||||
|
||||
export const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
/** Значение reportId в адресе, которое старый код считал «нет отчёта». */
|
||||
export const EMPTY_REPORT_ID = 'null';
|
||||
@@ -0,0 +1,92 @@
|
||||
import { IReportFormState, IResourceItem, IWorkItem, ResourceKind } from './reportTypes';
|
||||
import { HOURS_TYPES } from './reportConstants';
|
||||
|
||||
// Иммутабельные изменения состояния формы. Хук useReportForm применяет их через setForm(prev => …).
|
||||
|
||||
/** Пустая форма. Дата — сегодня (UTC, как в старом коде). */
|
||||
export function createEmptyForm(today: string): IReportFormState {
|
||||
return {
|
||||
objectId: undefined,
|
||||
objectPath: '',
|
||||
companyId: undefined,
|
||||
companyName: '',
|
||||
shiftId: undefined,
|
||||
shiftName: '',
|
||||
reportDate: today,
|
||||
reportNumber: 0,
|
||||
comment: '',
|
||||
works: [],
|
||||
professions: [],
|
||||
technics: [],
|
||||
photos: [],
|
||||
photoFolderUrl: undefined
|
||||
};
|
||||
}
|
||||
|
||||
/** Локальный id строки формы (только для key и поиска строки, в данные не попадает). */
|
||||
export function createLocalId(): string {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
|
||||
export function createResourceItem(resourceId: number, title: string): IResourceItem {
|
||||
return { id: createLocalId(), resourceId, title, count: 0, hours: 0, hoursType: HOURS_TYPES.work, comment: '' };
|
||||
}
|
||||
|
||||
const LIST_BY_KIND: Record<ResourceKind, 'professions' | 'technics'> = {
|
||||
profession: 'professions',
|
||||
technic: 'technics'
|
||||
};
|
||||
|
||||
export function resourceListKey(kind: ResourceKind): 'professions' | 'technics' {
|
||||
return LIST_BY_KIND[kind];
|
||||
}
|
||||
|
||||
export function patchWork(form: IReportFormState, workId: string, patch: Partial<IWorkItem>): IReportFormState {
|
||||
return { ...form, works: form.works.map((work) => (work.id === workId ? { ...work, ...patch } : work)) };
|
||||
}
|
||||
|
||||
export function removeWork(form: IReportFormState, workId: string): IReportFormState {
|
||||
return { ...form, works: form.works.filter((work) => work.id !== workId) };
|
||||
}
|
||||
|
||||
export function addWork(form: IReportFormState, work: IWorkItem): IReportFormState {
|
||||
return { ...form, works: [...form.works, work] };
|
||||
}
|
||||
|
||||
/** Где лежит строка персонала / техники: в общей секции (workId не задан) или на работе. */
|
||||
export interface IResourceTarget {
|
||||
kind: ResourceKind;
|
||||
workId?: string;
|
||||
}
|
||||
|
||||
function mapResources(
|
||||
form: IReportFormState,
|
||||
target: IResourceTarget,
|
||||
update: (items: IResourceItem[]) => IResourceItem[]
|
||||
): IReportFormState {
|
||||
const key = resourceListKey(target.kind);
|
||||
if (!target.workId) {
|
||||
return { ...form, [key]: update(form[key]) };
|
||||
}
|
||||
return {
|
||||
...form,
|
||||
works: form.works.map((work) => (work.id === target.workId ? { ...work, [key]: update(work[key]) } : work))
|
||||
};
|
||||
}
|
||||
|
||||
export function addResource(form: IReportFormState, target: IResourceTarget, item: IResourceItem): IReportFormState {
|
||||
return mapResources(form, target, (items) => [...items, item]);
|
||||
}
|
||||
|
||||
export function patchResource(
|
||||
form: IReportFormState,
|
||||
target: IResourceTarget,
|
||||
itemId: string,
|
||||
patch: Partial<IResourceItem>
|
||||
): IReportFormState {
|
||||
return mapResources(form, target, (items) => items.map((item) => (item.id === itemId ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
export function removeResource(form: IReportFormState, target: IResourceTarget, itemId: string): IReportFormState {
|
||||
return mapResources(form, target, (items) => items.filter((item) => item.id !== itemId));
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
ICompany,
|
||||
IObjectWithPath,
|
||||
IReportAccess,
|
||||
IReportFormState,
|
||||
IReportLookups,
|
||||
IReportRouteParams,
|
||||
IReportRow,
|
||||
IShift,
|
||||
ReportFormMode
|
||||
} from './reportTypes';
|
||||
import { DEFAULT_EDIT_DAYS } from './reportConstants';
|
||||
import { daysSinceReport, decideExistingReportMode, hasAnyAccess, todayIsoDate } from './reportRules';
|
||||
import { createEmptyForm } from './reportFormUpdates';
|
||||
import { buildFormCollections, IFormCollections } from './reportCollections';
|
||||
import { photoFolderLink } from './sharePointReportRepository';
|
||||
import { IReportServices } from './reportServices';
|
||||
|
||||
// Загрузка формы — как initializeForm / initNewReport / copyFromReport / loadExistingReport.
|
||||
// Результаты отдаются по шагам через sink в том же порядке, что setState в старом компоненте:
|
||||
// при ошибке посередине форма остаётся в том же частично загруженном состоянии, что и раньше.
|
||||
|
||||
export interface IReportLoadSink {
|
||||
setProjectName(name: string): void;
|
||||
setMode(mode: ReportFormMode): void;
|
||||
setAccess(access: IReportAccess | undefined): void;
|
||||
setApproved(isApproved: boolean): void;
|
||||
setLookups(lookups: Partial<IReportLookups>): void;
|
||||
setForm(form: IReportFormState): void;
|
||||
}
|
||||
|
||||
export type ReportLoadOutcome = 'loaded' | 'missingProjectId';
|
||||
|
||||
interface ILoadContext {
|
||||
services: IReportServices;
|
||||
projectId: number;
|
||||
projectName: string;
|
||||
now: Date;
|
||||
sink: IReportLoadSink;
|
||||
}
|
||||
|
||||
function formFromReport(
|
||||
report: IReportRow,
|
||||
lookups: { objects: IObjectWithPath[]; companies: ICompany[]; shifts: IShift[] },
|
||||
collections: IFormCollections
|
||||
): IReportFormState {
|
||||
const object = lookups.objects.filter((item) => item.Id === report.cree9_objectid)[0];
|
||||
const company = lookups.companies.filter((item) => item.Id === report.cree9_companyid)[0];
|
||||
const shift = lookups.shifts.filter((item) => item.Id === report.cree9_shiftid)[0];
|
||||
return {
|
||||
objectId: report.cree9_objectid ?? undefined,
|
||||
objectPath: object?.fullPath || '',
|
||||
companyId: report.cree9_companyid ?? undefined,
|
||||
companyName: company?.Title || '',
|
||||
shiftId: report.cree9_shiftid ?? undefined,
|
||||
shiftName: shift?.Title || '',
|
||||
reportDate: report.cree9_report_date ? report.cree9_report_date.split('T')[0] : '',
|
||||
reportNumber: report.cree9_number,
|
||||
comment: report.cree9_comment || '',
|
||||
...collections,
|
||||
photos: [],
|
||||
photoFolderUrl: undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function initNewReport({ services, projectId, now, sink }: ILoadContext): Promise<void> {
|
||||
sink.setMode('create');
|
||||
const [shifts, access] = await Promise.all([services.sp.getShifts(), services.sp.checkAccess(projectId)]);
|
||||
// 🐞 Как в старом коде: для нового отчёта отсутствие прав не проверяется.
|
||||
sink.setAccess(access);
|
||||
sink.setLookups({ shifts });
|
||||
const defaultShift = shifts[0];
|
||||
sink.setForm({
|
||||
...createEmptyForm(todayIsoDate(now)),
|
||||
shiftId: defaultShift?.Id ?? undefined,
|
||||
shiftName: defaultShift?.Title ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
async function copyFromReport(context: ILoadContext, copyFromId: string): Promise<void> {
|
||||
const { services, projectId, projectName, now, sink } = context;
|
||||
const [access, shifts] = await Promise.all([services.sp.checkAccess(projectId), services.sp.getShifts()]);
|
||||
if (!hasAnyAccess(access)) {
|
||||
sink.setMode('noaccess');
|
||||
return;
|
||||
}
|
||||
sink.setAccess(access);
|
||||
sink.setMode('create');
|
||||
sink.setLookups({ shifts });
|
||||
|
||||
const report = await services.dv.getReportById(copyFromId);
|
||||
const rows = await services.dv.getReportData(copyFromId);
|
||||
const objects = await services.sp.getObjectsByProject(projectName);
|
||||
sink.setLookups({ objects });
|
||||
const companies = await services.sp.getCompaniesWithFallback(projectId);
|
||||
sink.setLookups({ companies });
|
||||
const collections = await buildFormCollections(services, rows, report.cree9_objectid);
|
||||
|
||||
// Копия — новый отчёт: дата сегодня, номер не присвоен, фото не копируются.
|
||||
sink.setForm({
|
||||
...formFromReport(report, { objects, companies, shifts }, collections),
|
||||
reportDate: todayIsoDate(now),
|
||||
reportNumber: 0,
|
||||
photoFolderUrl: ''
|
||||
});
|
||||
}
|
||||
|
||||
async function loadExistingReport(context: ILoadContext, reportId: string, editDays: number): Promise<void> {
|
||||
const { services, projectId, projectName, now, sink } = context;
|
||||
const access = await services.sp.checkAccess(projectId);
|
||||
if (!hasAnyAccess(access)) {
|
||||
sink.setMode('noaccess');
|
||||
return;
|
||||
}
|
||||
sink.setAccess(access);
|
||||
|
||||
const report = await services.dv.getReportById(reportId);
|
||||
const rows = await services.dv.getReportData(reportId);
|
||||
const isApproved = !!report.cree9_aprooved;
|
||||
sink.setApproved(isApproved);
|
||||
const daysSince = daysSinceReport(report.cree9_report_date, now.getTime());
|
||||
sink.setMode(decideExistingReportMode(access, isApproved, daysSince, editDays));
|
||||
|
||||
const objects = await services.sp.getObjectsByProject(projectName);
|
||||
sink.setLookups({ objects });
|
||||
const companies = await services.sp.getCompaniesWithFallback(projectId);
|
||||
sink.setLookups({ companies });
|
||||
const shifts = await services.sp.getShifts();
|
||||
sink.setLookups({ shifts });
|
||||
const collections = await buildFormCollections(services, rows, report.cree9_objectid);
|
||||
|
||||
sink.setForm({
|
||||
...formFromReport(report, { objects, companies, shifts }, collections),
|
||||
photoFolderUrl: photoFolderLink(services.siteUrl, projectId, report.cree9_number)
|
||||
});
|
||||
}
|
||||
|
||||
/** Загружает форму по параметрам адреса. Ошибки запросов пробрасываются — их показывает хук. */
|
||||
export async function loadReportForm(
|
||||
services: IReportServices,
|
||||
params: IReportRouteParams,
|
||||
now: Date,
|
||||
sink: IReportLoadSink
|
||||
): Promise<ReportLoadOutcome> {
|
||||
if (!params.projectId) {
|
||||
return 'missingProjectId';
|
||||
}
|
||||
const project = await services.sp.getProjectById(params.projectId);
|
||||
sink.setProjectName(project.Title);
|
||||
const context: ILoadContext = { services, projectId: params.projectId, projectName: project.Title, now, sink };
|
||||
|
||||
if (params.reportId) {
|
||||
await loadExistingReport(context, params.reportId, project.editDays ?? DEFAULT_EDIT_DAYS);
|
||||
} else if (params.copyFromId) {
|
||||
await copyFromReport(context, params.copyFromId);
|
||||
} else {
|
||||
await initNewReport(context);
|
||||
}
|
||||
return 'loaded';
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { IReportDataFields, IReportDataNode, IReportFormState, IReportPayload, IResourceItem, ResourceKind } from './reportTypes';
|
||||
import { HOURS_TYPES } from './reportConstants';
|
||||
|
||||
// Сборка тел сохранения из состояния формы — как handleSave в StroyBotReport.tsx.
|
||||
|
||||
export function buildReportPayload(form: IReportFormState, reportNumber: number, projectId: number): IReportPayload {
|
||||
return {
|
||||
cree9_objectid: form.objectId,
|
||||
cree9_companyid: form.companyId,
|
||||
cree9_shiftid: form.shiftId,
|
||||
cree9_report_date: form.reportDate,
|
||||
cree9_number: reportNumber,
|
||||
cree9_projectid: projectId,
|
||||
cree9_comment: form.comment || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function resourceFields(kind: ResourceKind, item: IResourceItem, hoursType: string): IReportDataFields {
|
||||
const idField: IReportDataFields =
|
||||
kind === 'profession' ? { cree9_professionid: item.resourceId } : { cree9_technicid: item.resourceId };
|
||||
return {
|
||||
...idField,
|
||||
cree9_count: item.count,
|
||||
cree9_hours: item.hours,
|
||||
cree9_comment: item.comment || undefined,
|
||||
cree9_hourstype: hoursType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Узлы сохранения в порядке старого кода: работы (с персоналом, затем техникой на работе —
|
||||
* у них тип часов всегда «Часы работы»), затем общий персонал, затем общая техника.
|
||||
*/
|
||||
export function buildReportNodes(form: IReportFormState): IReportDataNode[] {
|
||||
const nodes: IReportDataNode[] = form.works.map((work) => {
|
||||
const children = [
|
||||
...work.professions.map((item) => resourceFields('profession', item, HOURS_TYPES.work)),
|
||||
...work.technics.map((item) => resourceFields('technic', item, HOURS_TYPES.work))
|
||||
];
|
||||
return {
|
||||
data: { _cree9_jobid_value: work.jobId, cree9_value: work.value, cree9_comment: work.comment || undefined },
|
||||
children: children.length > 0 ? children : undefined
|
||||
};
|
||||
});
|
||||
|
||||
form.professions.forEach((item) => {
|
||||
nodes.push({ data: resourceFields('profession', item, item.hoursType || HOURS_TYPES.work) });
|
||||
});
|
||||
form.technics.forEach((item) => {
|
||||
nodes.push({ data: resourceFields('technic', item, item.hoursType || HOURS_TYPES.work) });
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IReportLookups } from './reportTypes';
|
||||
import { IPickerItem, IPickerOptions } from './picker/pickerTypes';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
|
||||
// Какие справочники открываются в пикере формы отчёта и как они выглядят в нём.
|
||||
// (В старом коде был ещё тип «смена», но смена выбирается обычным списком — пикер для неё не открывался.)
|
||||
|
||||
export type PickerKind = 'object' | 'company' | 'work' | 'profession' | 'technic';
|
||||
|
||||
const TITLES = REPORT_TEXTS.picker.titles;
|
||||
|
||||
const NO_EXTRAS = { groupFilter: false, drillDown: false, allowCreate: false, requiresUnit: false };
|
||||
|
||||
/** Возможности пикера по справочнику — как пропсы PickerModal в старом компоненте. */
|
||||
export const PICKER_OPTIONS: Record<PickerKind, IPickerOptions> = {
|
||||
object: { ...NO_EXTRAS, title: TITLES.object },
|
||||
company: { ...NO_EXTRAS, title: TITLES.company },
|
||||
profession: { ...NO_EXTRAS, title: TITLES.profession },
|
||||
technic: { ...NO_EXTRAS, title: TITLES.technic, groupFilter: true, allowCreate: true },
|
||||
work: { ...NO_EXTRAS, title: TITLES.work, drillDown: true, allowCreate: true, requiresUnit: true }
|
||||
};
|
||||
|
||||
/** Элементы пикера из справочников (getModalItems). */
|
||||
export function toPickerItems(kind: PickerKind, lookups: IReportLookups): IPickerItem[] {
|
||||
switch (kind) {
|
||||
case 'object':
|
||||
return lookups.objects.map((item) => ({ id: item.Id, title: item.fullPath }));
|
||||
case 'company':
|
||||
return lookups.companies.map((item) => ({ id: item.Id, title: item.Title }));
|
||||
case 'profession':
|
||||
return lookups.professions.map((item) => ({ id: item.Id, title: item.Title }));
|
||||
case 'technic':
|
||||
return lookups.technics.map((item) => ({ id: item.Id, title: item.Title, subtitle: item.group || '' }));
|
||||
case 'work':
|
||||
return lookups.jobs.map((job) => ({
|
||||
id: job.cree9_tableid,
|
||||
title: job.cree9_title || '',
|
||||
subtitle: job.cree9_unit || '',
|
||||
// Dataverse отдаёт null у корневых работ — для drill-down корень = «без родителя».
|
||||
parentId: job._cree9_parentid_value ?? undefined,
|
||||
fullPath: job.fullPath || job.cree9_title || ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { IRoute } from '../../app/routing/routes';
|
||||
import {
|
||||
IJobRow,
|
||||
IJobWithPath,
|
||||
IObjectRow,
|
||||
IObjectWithPath,
|
||||
IReportAccess,
|
||||
IReportRouteParams,
|
||||
ITechnic,
|
||||
ReportFormMode
|
||||
} from './reportTypes';
|
||||
import { EMPTY_REPORT_ID, MS_PER_DAY } from './reportConstants';
|
||||
|
||||
// Чистые правила формы отчёта, вынесенные из StroyBotReport.tsx и сервисов stroy-bot-report.
|
||||
|
||||
const PATH_SEPARATOR = ' / ';
|
||||
const RU_LOCALE = 'ru';
|
||||
|
||||
/** Параметры адреса. projectId — parseInt (как в старом коде: «12abc» → 12, мусор → NaN). */
|
||||
export function parseReportRoute(route: IRoute): IReportRouteParams {
|
||||
const reportId = route.reportId;
|
||||
return {
|
||||
projectId: parseInt(route.projectId || '0', 10),
|
||||
reportId: reportId && reportId !== EMPTY_REPORT_ID ? reportId : undefined,
|
||||
copyFromId: route.copyFromId || ''
|
||||
};
|
||||
}
|
||||
|
||||
/** Сегодня в формате YYYY-MM-DD. 🐞 По UTC (toISOString), как в старом коде. */
|
||||
export function todayIsoDate(now: Date): string {
|
||||
return now.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/** Сколько полных суток прошло с даты отчёта. */
|
||||
export function daysSinceReport(reportDate: string, nowMs: number): number {
|
||||
return Math.floor((nowMs - new Date(reportDate).getTime()) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/** Есть ли у пользователя хоть какое-то право на проект (read / edit0 / aproove). */
|
||||
export function hasAnyAccess(access: IReportAccess | undefined): access is IReportAccess {
|
||||
return !!access && (access.read || access.edit || access.aproove);
|
||||
}
|
||||
|
||||
/**
|
||||
* Режим открытого отчёта: утверждающий видит неутверждённый отчёт в режиме утверждения;
|
||||
* редактор может править неутверждённый отчёт, пока не прошло editDays дней; иначе — просмотр.
|
||||
*/
|
||||
export function decideExistingReportMode(
|
||||
access: IReportAccess,
|
||||
isApproved: boolean,
|
||||
daysSince: number,
|
||||
editDays: number
|
||||
): ReportFormMode {
|
||||
if (access.aproove && !isApproved) return 'approve';
|
||||
if (access.edit && !isApproved && daysSince < editDays) return 'edit';
|
||||
return 'readonly';
|
||||
}
|
||||
|
||||
export function isFormReadOnly(mode: ReportFormMode): boolean {
|
||||
return mode === 'readonly' || mode === 'approve';
|
||||
}
|
||||
|
||||
export type ReportStatusBadge = 'approved' | 'pending';
|
||||
|
||||
/** Бейдж статуса в шапке. Для нового отчёта бейджа нет. */
|
||||
export function getStatusBadge(
|
||||
mode: ReportFormMode,
|
||||
isApproved: boolean,
|
||||
access: IReportAccess | undefined
|
||||
): ReportStatusBadge | undefined {
|
||||
if (mode === 'create') return undefined;
|
||||
if (isApproved) return 'approved';
|
||||
if (access?.aproove) return 'pending';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isEditableMode(mode: ReportFormMode): boolean {
|
||||
return mode === 'create' || mode === 'edit';
|
||||
}
|
||||
|
||||
/** Кнопка «Сохранить отчет». Без загруженных прав (новый отчёт без строки «Доступы») — показывается. */
|
||||
export function canShowSaveButton(mode: ReportFormMode, isSaved: boolean, access: IReportAccess | undefined): boolean {
|
||||
return isEditableMode(mode) && !isSaved && access?.edit !== false;
|
||||
}
|
||||
|
||||
/** После успешного сохранения кнопка сохранения меняется на «← Вернуться назад». */
|
||||
export function canShowBackAfterSave(mode: ReportFormMode, isSaved: boolean): boolean {
|
||||
return isEditableMode(mode) && isSaved;
|
||||
}
|
||||
|
||||
/** Пути объектов «Объект / Раздел / Вид работ», сортировка по пути (ru). */
|
||||
export function buildObjectPaths(rows: IObjectRow[]): IObjectWithPath[] {
|
||||
const byId = new Map<number, IObjectRow>();
|
||||
rows.forEach((row) => byId.set(row.Id, { Id: row.Id, Title: row.Title, parentId: row.parentId || undefined }));
|
||||
|
||||
// Без защиты от циклов в parentId — как в старом SharePointService.buildObjectPath.
|
||||
const pathOf = (objectId: number): string => {
|
||||
const parts: string[] = [];
|
||||
let currentId: number | undefined = objectId;
|
||||
while (currentId !== undefined) {
|
||||
const current = byId.get(currentId);
|
||||
if (!current) break;
|
||||
parts.unshift(current.Title);
|
||||
currentId = current.parentId;
|
||||
}
|
||||
return parts.join(PATH_SEPARATOR);
|
||||
};
|
||||
|
||||
return rows
|
||||
.map((row) => ({ Id: row.Id, Title: row.Title, parentId: row.parentId || undefined, fullPath: pathOf(row.Id) }))
|
||||
.sort((a, b) => a.fullPath.localeCompare(b.fullPath, RU_LOCALE));
|
||||
}
|
||||
|
||||
/** Пути работ «Родитель / Работа», сортировка по пути (ru). */
|
||||
export function buildJobPaths(rows: IJobRow[]): IJobWithPath[] {
|
||||
const byId = new Map<string, IJobRow>();
|
||||
rows.forEach((row) => byId.set(row.cree9_tableid, row));
|
||||
|
||||
const pathOf = (jobId: string): string => {
|
||||
const parts: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
let currentId: string | undefined = jobId;
|
||||
while (currentId !== undefined && !visited.has(currentId)) {
|
||||
visited.add(currentId);
|
||||
const job = byId.get(currentId);
|
||||
if (!job) break;
|
||||
parts.unshift(job.cree9_title);
|
||||
currentId = job._cree9_parentid_value ?? undefined;
|
||||
}
|
||||
return parts.join(PATH_SEPARATOR);
|
||||
};
|
||||
|
||||
return rows
|
||||
.map((row) => ({ ...row, fullPath: pathOf(row.cree9_tableid) }))
|
||||
.sort((a, b) => a.fullPath.localeCompare(b.fullPath, RU_LOCALE));
|
||||
}
|
||||
|
||||
/** Путь новой работы: «путь родителя / название», если родитель есть в загруженном списке. */
|
||||
export function newJobPath(title: string, parentId: string | undefined, jobs: IJobWithPath[]): string {
|
||||
if (!parentId) return title;
|
||||
const parent = jobs.filter((job) => job.cree9_tableid === parentId)[0];
|
||||
return parent ? `${parent.fullPath}${PATH_SEPARATOR}${title}` : title;
|
||||
}
|
||||
|
||||
/** Техника по группе, затем по названию (ru). */
|
||||
export function sortTechnics(technics: ITechnic[]): ITechnic[] {
|
||||
return [...technics].sort((a, b) => {
|
||||
const groupA = a.group || '';
|
||||
const groupB = b.group || '';
|
||||
return groupA.localeCompare(groupB, RU_LOCALE) || a.Title.localeCompare(b.Title, RU_LOCALE);
|
||||
});
|
||||
}
|
||||
|
||||
/** «+/-» план-факта. */
|
||||
export function planFactDifference(totalFact: number, plan: number): number {
|
||||
return totalFact - plan;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IReportFormState } from './reportTypes';
|
||||
import { buildReportNodes, buildReportPayload } from './reportPayload';
|
||||
import { IReportServices } from './reportServices';
|
||||
|
||||
// Сохранение отчёта — как handleSave в StroyBotReport.tsx.
|
||||
|
||||
export interface IReportSaveResult {
|
||||
reportId: string;
|
||||
reportNumber: number;
|
||||
/** Ссылка на папку с фото — только если фото загружались. */
|
||||
photoFolderUrl: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Номер: у существующего отчёта — прежний, у нового — число неудалённых отчётов проекта + 1.
|
||||
* 2. Отчёт и его строки (при правке старые строки удаляются и создаются заново).
|
||||
* 3. Фото — в DocLib/{projectId}_{номер}. Ошибка загрузки фото = ошибка сохранения (отчёт уже записан).
|
||||
*/
|
||||
export async function saveReport(
|
||||
services: IReportServices,
|
||||
projectId: number,
|
||||
form: IReportFormState,
|
||||
existingReportId: string | undefined
|
||||
): Promise<IReportSaveResult> {
|
||||
const reportNumber = existingReportId ? form.reportNumber : await services.dv.getNextReportNumber(projectId);
|
||||
const reportId = await services.dv.saveReportWithData(
|
||||
buildReportPayload(form, reportNumber, projectId),
|
||||
buildReportNodes(form),
|
||||
existingReportId
|
||||
);
|
||||
const photoFolderUrl =
|
||||
form.photos.length > 0 ? await services.sp.uploadPhotos(projectId, reportNumber, form.photos) : undefined;
|
||||
return { reportId, reportNumber, photoFolderUrl };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { IResourceItem, IWorkItem } from './reportTypes';
|
||||
import { IResourceTarget, patchResource, patchWork, removeResource, removeWork } from './reportFormUpdates';
|
||||
import { readPhotoFiles } from './photoFiles';
|
||||
import { IReportFormApi } from './useReportForm';
|
||||
import { IPickerModalApi } from './usePickerModal';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
|
||||
export interface IReportSectionHandlers {
|
||||
changeDate: (value: string) => void;
|
||||
changeShift: (value: string) => void;
|
||||
openObjectPicker: () => void;
|
||||
openCompanyPicker: () => void;
|
||||
addWork: () => void;
|
||||
changeWork: (workId: string, patch: Partial<IWorkItem>) => void;
|
||||
removeWork: (workId: string) => void;
|
||||
addResource: (target: IResourceTarget) => void;
|
||||
changeResource: (target: IResourceTarget, itemId: string, patch: Partial<IResourceItem>) => void;
|
||||
removeResource: (target: IResourceTarget, itemId: string) => void;
|
||||
changeComment: (value: string) => void;
|
||||
addPhotos: (files: File[]) => Promise<void>;
|
||||
removePhoto: (index: number) => void;
|
||||
}
|
||||
|
||||
/** Колбэки секций формы. Состояние меняется только через report.updateForm (иммутабельно). */
|
||||
export function createSectionHandlers(report: IReportFormApi, picker: IPickerModalApi): IReportSectionHandlers {
|
||||
const { updateForm } = report;
|
||||
|
||||
return {
|
||||
changeDate: (value) => {
|
||||
report.clearValidation();
|
||||
updateForm((prev) => ({ ...prev, reportDate: value }));
|
||||
},
|
||||
changeShift: (value) => {
|
||||
report.clearValidation();
|
||||
const shiftId = parseInt(value, 10);
|
||||
const shift = report.lookups.shifts.filter((item) => item.Id === shiftId)[0];
|
||||
updateForm((prev) => ({ ...prev, shiftId: shiftId || undefined, shiftName: shift?.Title || '' }));
|
||||
},
|
||||
openObjectPicker: () => picker.open('object'),
|
||||
openCompanyPicker: () => picker.open('company'),
|
||||
addWork: () => {
|
||||
if (!report.form.objectId) {
|
||||
report.setErrorMessage(REPORT_TEXTS.messages.selectObjectFirst);
|
||||
return;
|
||||
}
|
||||
picker.open('work');
|
||||
},
|
||||
changeWork: (workId, patch) => updateForm((prev) => patchWork(prev, workId, patch)),
|
||||
removeWork: (workId) => updateForm((prev) => removeWork(prev, workId)),
|
||||
addResource: (target) => picker.open(target.kind, target.workId),
|
||||
changeResource: (target, itemId, patch) => updateForm((prev) => patchResource(prev, target, itemId, patch)),
|
||||
removeResource: (target, itemId) => updateForm((prev) => removeResource(prev, target, itemId)),
|
||||
changeComment: (value) => updateForm((prev) => ({ ...prev, comment: value })),
|
||||
addPhotos: async (files) => {
|
||||
const photos = await readPhotoFiles(files);
|
||||
updateForm((prev) => ({ ...prev, photos: [...prev.photos, ...photos] }));
|
||||
},
|
||||
removePhoto: (index) => updateForm((prev) => ({ ...prev, photos: prev.photos.filter((_, i) => i !== index) }))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IAppServices } from '../../services/AppServices';
|
||||
import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository';
|
||||
import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository';
|
||||
|
||||
/** Репозитории формы отчёта. Один набор на открытую форму (как сервисы старого web part). */
|
||||
export interface IReportServices {
|
||||
sp: ISharePointReportRepository;
|
||||
dv: IDataverseReportRepository;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
export function createReportServices(services: IAppServices): IReportServices {
|
||||
return {
|
||||
sp: createSharePointReportRepository({
|
||||
client: services.sharePoint,
|
||||
siteUrl: services.settings.siteUrl,
|
||||
userId: services.page.userId,
|
||||
webServerRelativeUrl: services.page.webServerRelativeUrl
|
||||
}),
|
||||
dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl),
|
||||
siteUrl: services.settings.siteUrl
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Типы формы отчёта (бывший stroy-bot-report, SendReport.aspx).
|
||||
// Имена полей ответов SharePoint / Dataverse — как в старом models/IModels.ts.
|
||||
|
||||
/** Режим формы. Решение о режиме — reportRules.ts. */
|
||||
export type ReportFormMode = 'create' | 'edit' | 'readonly' | 'approve' | 'noaccess';
|
||||
|
||||
/** Тип ресурса в отчёте: персонал (профессия) или техника. */
|
||||
export type ResourceKind = 'profession' | 'technic';
|
||||
|
||||
// ============ SharePoint ============
|
||||
|
||||
/** Элемент списка «Проекты». editDays — сколько дней после даты отчёта его можно править. */
|
||||
export interface IProjectRow {
|
||||
Id: number;
|
||||
Title: string;
|
||||
editDays?: number;
|
||||
}
|
||||
|
||||
/** Элемент списка «Объекты». parentId — родитель (как читал старый код). */
|
||||
export interface IObjectRow {
|
||||
Id: number;
|
||||
Title: string;
|
||||
parentId?: number;
|
||||
}
|
||||
|
||||
export interface IObjectWithPath extends IObjectRow {
|
||||
/** «Объект / Раздел / Вид работ». */
|
||||
fullPath: string;
|
||||
}
|
||||
|
||||
export interface ICompany {
|
||||
Id: number;
|
||||
Title: string;
|
||||
}
|
||||
|
||||
export interface IShift {
|
||||
Id: number;
|
||||
Title: string;
|
||||
}
|
||||
|
||||
export interface IProfession {
|
||||
Id: number;
|
||||
Title: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ITechnic {
|
||||
Id: number;
|
||||
Title: string;
|
||||
/** Группа техники — по ней работают чипы-фильтры пикера. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/** Права текущего пользователя на проект (из списка «Доступы»). */
|
||||
export interface IReportAccess {
|
||||
id: number;
|
||||
userId: number | undefined;
|
||||
project: string;
|
||||
read: boolean;
|
||||
/** Поле edit0. */
|
||||
edit: boolean;
|
||||
aproove: boolean;
|
||||
}
|
||||
|
||||
// ============ Dataverse ============
|
||||
|
||||
/** Работа (таблица cree9_tables). */
|
||||
export interface IJobRow {
|
||||
cree9_tableid: string;
|
||||
cree9_title: string;
|
||||
cree9_unit: string;
|
||||
cree9_objectid: string;
|
||||
cree9_volume?: number;
|
||||
_cree9_parentid_value?: string;
|
||||
}
|
||||
|
||||
export interface IJobWithPath extends IJobRow {
|
||||
/** «Родитель / Дочерняя работа». */
|
||||
fullPath: string;
|
||||
}
|
||||
|
||||
/** Отчёт (таблица cree9_reports). */
|
||||
export interface IReportRow {
|
||||
cree9_reportid?: string;
|
||||
cree9_objectid: number;
|
||||
cree9_shiftid: number;
|
||||
cree9_report_date: string;
|
||||
cree9_number: number;
|
||||
cree9_companyid: number;
|
||||
cree9_projectid: number;
|
||||
cree9_comment?: string;
|
||||
cree9_aprooved?: boolean;
|
||||
}
|
||||
|
||||
/** Тело создания / изменения отчёта. Поля undefined не попадают в JSON. */
|
||||
export interface IReportPayload {
|
||||
cree9_objectid: number | undefined;
|
||||
cree9_companyid: number | undefined;
|
||||
cree9_shiftid: number | undefined;
|
||||
cree9_report_date: string;
|
||||
cree9_number: number;
|
||||
cree9_projectid: number;
|
||||
cree9_comment: string | undefined;
|
||||
}
|
||||
|
||||
/** Строка отчёта (таблица cree9_report_datas): работа, персонал или техника. */
|
||||
export interface IReportDataRow {
|
||||
cree9_report_dataid?: string;
|
||||
/** Lookup на работу. Есть у строк-работ. */
|
||||
_cree9_jobid_value?: string;
|
||||
/** Lookup на строку-работу: у персонала и техники, привязанных к работе. */
|
||||
_cree9_parentid_value?: string;
|
||||
cree9_professionid?: number | string;
|
||||
cree9_technicid?: number | string;
|
||||
cree9_count?: number;
|
||||
cree9_hours?: number;
|
||||
cree9_value?: number;
|
||||
cree9_comment?: string;
|
||||
cree9_hourstype?: string;
|
||||
}
|
||||
|
||||
/** Поля строки отчёта для сохранения. Порядок ключей = порядок полей в JSON (как в старом коде). */
|
||||
export interface IReportDataFields {
|
||||
_cree9_jobid_value?: string;
|
||||
cree9_value?: number;
|
||||
cree9_professionid?: number;
|
||||
cree9_technicid?: number;
|
||||
cree9_count?: number;
|
||||
cree9_hours?: number;
|
||||
cree9_comment?: string;
|
||||
cree9_hourstype?: string;
|
||||
}
|
||||
|
||||
/** Узел сохранения: строка-работа и её дочерние строки (персонал / техника на работе). */
|
||||
export interface IReportDataNode {
|
||||
data: IReportDataFields;
|
||||
children?: IReportDataFields[];
|
||||
}
|
||||
|
||||
// ============ Состояние формы ============
|
||||
|
||||
/** Персонал или техника в отчёте (общие или на конкретной работе). */
|
||||
export interface IResourceItem {
|
||||
/** Локальный id строки формы. */
|
||||
id: string;
|
||||
/** Id элемента списка «Профессии» или «Техника». */
|
||||
resourceId: number;
|
||||
title: string;
|
||||
count: number;
|
||||
hours: number;
|
||||
hoursType: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface IWorkItem {
|
||||
/** Локальный id строки формы. */
|
||||
id: string;
|
||||
jobId: string;
|
||||
jobTitle: string;
|
||||
jobUnit: string;
|
||||
value: number;
|
||||
plan: number;
|
||||
totalFact: number;
|
||||
comment: string;
|
||||
/** Персонал и техника на этой работе (тип часов всегда «Часы работы»). */
|
||||
professions: IResourceItem[];
|
||||
technics: IResourceItem[];
|
||||
}
|
||||
|
||||
export interface IPhotoItem {
|
||||
name: string;
|
||||
/** data: URL для превью. */
|
||||
preview: string;
|
||||
/** Содержимое файла для загрузки. */
|
||||
buffer: ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface IReportFormState {
|
||||
objectId: number | undefined;
|
||||
objectPath: string;
|
||||
companyId: number | undefined;
|
||||
companyName: string;
|
||||
shiftId: number | undefined;
|
||||
shiftName: string;
|
||||
/** YYYY-MM-DD */
|
||||
reportDate: string;
|
||||
/** 0 — номер ещё не присвоен (новый отчёт). */
|
||||
reportNumber: number;
|
||||
comment: string;
|
||||
works: IWorkItem[];
|
||||
professions: IResourceItem[];
|
||||
technics: IResourceItem[];
|
||||
photos: IPhotoItem[];
|
||||
photoFolderUrl: string | undefined;
|
||||
}
|
||||
|
||||
/** Справочники формы и пикера. */
|
||||
export interface IReportLookups {
|
||||
objects: IObjectWithPath[];
|
||||
companies: ICompany[];
|
||||
shifts: IShift[];
|
||||
professions: IProfession[];
|
||||
technics: ITechnic[];
|
||||
jobs: IJobWithPath[];
|
||||
}
|
||||
|
||||
/** Параметры маршрута формы, уже разобранные как в старом коде. */
|
||||
export interface IReportRouteParams {
|
||||
/** NaN / 0 — projectId не указан. */
|
||||
projectId: number;
|
||||
/** Id открываемого отчёта; undefined — новый отчёт. */
|
||||
reportId: string | undefined;
|
||||
/** Id отчёта-образца для копирования; '' — без копирования. */
|
||||
copyFromId: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IReportFormState } from './reportTypes';
|
||||
import { HOURS_TYPES } from './reportConstants';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
|
||||
const TEXTS = REPORT_TEXTS.validation;
|
||||
|
||||
/** Ошибки формы перед сохранением — тексты и порядок как в старом validate(). */
|
||||
export function validateReportForm(form: IReportFormState): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!form.objectId) errors.push(TEXTS.object);
|
||||
if (!form.companyId) errors.push(TEXTS.company);
|
||||
if (!form.shiftId) errors.push(TEXTS.shift);
|
||||
if (!form.reportDate) errors.push(TEXTS.reportDate);
|
||||
if (form.works.length === 0 && form.professions.length === 0 && form.technics.length === 0) {
|
||||
errors.push(TEXTS.noRows);
|
||||
}
|
||||
|
||||
// Проверяются только общие персонал и техника: у строк на работе типа часов нет.
|
||||
form.professions.forEach((item, index) => {
|
||||
if (item.hoursType === HOURS_TYPES.idle && !item.comment.trim()) {
|
||||
errors.push(TEXTS.professionComment(index + 1, item.title));
|
||||
}
|
||||
});
|
||||
form.technics.forEach((item, index) => {
|
||||
const needsComment = item.hoursType === HOURS_TYPES.idle || item.hoursType === HOURS_TYPES.repair;
|
||||
if (needsComment && !item.comment.trim()) {
|
||||
errors.push(TEXTS.technicComment(index + 1, item.title, item.hoursType));
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Обязателен ли комментарий при выбранном типе часов (звёздочка у поля). */
|
||||
export function isResourceCommentRequired(hoursType: string, allowedTypes: string[]): boolean {
|
||||
const requiresComment = hoursType === HOURS_TYPES.idle || hoursType === HOURS_TYPES.repair;
|
||||
return requiresComment && allowedTypes.indexOf(hoursType) !== -1;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,318 @@
|
||||
import { IHttpClient, IHttpResponse } from '../../services/http/IHttpClient';
|
||||
import { listItemsUrl } from '../../shared/sharepoint/spRest';
|
||||
import { LISTS, REPORT_PHOTO_LIBRARY } from '../../config/lists';
|
||||
import {
|
||||
ICompany,
|
||||
IObjectRow,
|
||||
IObjectWithPath,
|
||||
IPhotoItem,
|
||||
IProfession,
|
||||
IProjectRow,
|
||||
IReportAccess,
|
||||
IShift,
|
||||
ITechnic
|
||||
} from './reportTypes';
|
||||
import { buildObjectPaths, sortTechnics } from './reportRules';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
|
||||
// Запросы формы отчёта к SharePoint — 1:1 со stroy-bot-report (services/SharePointService.ts):
|
||||
// те же URL, параметры в том же порядке, те же заголовки и тексты ошибок.
|
||||
|
||||
/** Старый getItems всегда добавлял $top=5000. */
|
||||
const ITEMS_TOP = 5000;
|
||||
|
||||
const NOMETADATA_HEADERS: Record<string, string> = {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'odata-version': ''
|
||||
};
|
||||
|
||||
const NOMETADATA_POST_HEADERS: Record<string, string> = {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'Content-Type': 'application/json;odata=nometadata',
|
||||
'odata-version': ''
|
||||
};
|
||||
|
||||
const UPLOAD_HEADERS: Record<string, string> = {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'odata-version': ''
|
||||
};
|
||||
|
||||
/** Внутренние имена полей и $select — как в старом коде. */
|
||||
const SELECT = {
|
||||
project: 'Id,Title,editDays',
|
||||
objects: 'Id,Title,parentId',
|
||||
companies: 'Id,Title',
|
||||
companiesByProject: 'Id,companyIdId',
|
||||
shifts: 'Id,Title',
|
||||
professions: 'Id,Title,type',
|
||||
professionsByProject: 'Id,professionIdId',
|
||||
technics: 'Id,Title,group'
|
||||
} as const;
|
||||
|
||||
/** Строка «Доступы»: поле пользователя на разных сайтах называется по-разному. */
|
||||
interface IAccessRaw {
|
||||
Id: number;
|
||||
userId?: number;
|
||||
userIdId?: number;
|
||||
UserId?: number;
|
||||
UserIdId?: number;
|
||||
projectId: number;
|
||||
read: boolean;
|
||||
edit0: boolean;
|
||||
aproove: boolean;
|
||||
}
|
||||
|
||||
interface IListQuery {
|
||||
filter?: string;
|
||||
select?: string;
|
||||
}
|
||||
|
||||
// ---------- Адреса ----------
|
||||
|
||||
/** `…/items?$top=5000[&$filter=…][&$select=…]` — порядок параметров как в старом getItems. */
|
||||
export function itemsQueryUrl(siteUrl: string, listTitle: string, query: IListQuery = {}): string {
|
||||
const params: string[] = [`$top=${ITEMS_TOP}`];
|
||||
if (query.filter) params.push(`$filter=${query.filter}`);
|
||||
if (query.select) params.push(`$select=${query.select}`);
|
||||
return `${listItemsUrl(siteUrl, listTitle)}?${params.join('&')}`;
|
||||
}
|
||||
|
||||
export function itemByIdUrl(siteUrl: string, listTitle: string, id: number, select: string): string {
|
||||
return `${listItemsUrl(siteUrl, listTitle)}(${id})?$select=${select}`;
|
||||
}
|
||||
|
||||
/** Серверный относительный путь папки фото: `{web}/DocLib/{projectId}_{number}`. */
|
||||
export function photoFolderServerRelativeUrl(
|
||||
webServerRelativeUrl: string,
|
||||
projectId: number,
|
||||
reportNumber: number
|
||||
): string {
|
||||
const webRelativeUrl = webServerRelativeUrl.replace(/\/$/, '');
|
||||
return `${webRelativeUrl}/${REPORT_PHOTO_LIBRARY}/${projectId}_${reportNumber}`;
|
||||
}
|
||||
|
||||
/** Ссылка «Открыть папку с фото». */
|
||||
export function photoFolderLink(siteUrl: string, projectId: number, reportNumber: number): string {
|
||||
return `${siteUrl}/${REPORT_PHOTO_LIBRARY}/${projectId}_${reportNumber}`;
|
||||
}
|
||||
|
||||
export function folderByUrlUrl(siteUrl: string, serverRelativeFolderUrl: string): string {
|
||||
return `${siteUrl}/_api/web/GetFolderByServerRelativeUrl('${serverRelativeFolderUrl}')`;
|
||||
}
|
||||
|
||||
export function fileAddUrl(siteUrl: string, serverRelativeFolderUrl: string, fileName: string): string {
|
||||
const safeName = fileName.replace(/'/g, "''");
|
||||
return `${folderByUrlUrl(siteUrl, serverRelativeFolderUrl)}/Files/add(url='${safeName}',overwrite=true)`;
|
||||
}
|
||||
|
||||
// ---------- Репозиторий ----------
|
||||
|
||||
export interface ISharePointReportDeps {
|
||||
client: IHttpClient;
|
||||
siteUrl: string;
|
||||
/** Id текущего пользователя (legacyPageContext.userId) — по нему ищется строка «Доступы». */
|
||||
userId: number | undefined;
|
||||
/** Серверный относительный URL сайта — для пути к папке фото. */
|
||||
webServerRelativeUrl: string;
|
||||
}
|
||||
|
||||
export interface ISharePointReportRepository {
|
||||
getProjectById(projectId: number): Promise<IProjectRow>;
|
||||
getObjectsByProject(projectTitle: string): Promise<IObjectWithPath[]>;
|
||||
getCompaniesWithFallback(projectId: number): Promise<ICompany[]>;
|
||||
getShifts(): Promise<IShift[]>;
|
||||
getProfessionsWithFallback(projectId: number): Promise<IProfession[]>;
|
||||
getProfessionById(id: number): Promise<IProfession>;
|
||||
getTechnics(): Promise<ITechnic[]>;
|
||||
getTechnicById(id: number): Promise<ITechnic>;
|
||||
createListItem(listTitle: string, data: Record<string, unknown>): Promise<{ Id: number; Title: string }>;
|
||||
checkAccess(projectId: number): Promise<IReportAccess | undefined>;
|
||||
uploadPhotos(projectId: number, reportNumber: number, photos: IPhotoItem[]): Promise<string>;
|
||||
}
|
||||
|
||||
async function readErrorBody(response: IHttpResponse): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createSharePointReportRepository(deps: ISharePointReportDeps): ISharePointReportRepository {
|
||||
const { client, siteUrl } = deps;
|
||||
|
||||
const getItems = async <T>(listTitle: string, query?: IListQuery): Promise<T[]> => {
|
||||
const response = await client.fetch(itemsQueryUrl(siteUrl, listTitle, query), { headers: NOMETADATA_HEADERS });
|
||||
if (!response.ok) {
|
||||
const body = await readErrorBody(response);
|
||||
throw new Error(REPORT_TEXTS.errors.spList(response.status, response.statusText, body));
|
||||
}
|
||||
const json = (await response.json()) as { value?: T[] };
|
||||
return json.value || [];
|
||||
};
|
||||
|
||||
const getItemById = async <T>(listTitle: string, id: number, select: string): Promise<T> => {
|
||||
const response = await client.fetch(itemByIdUrl(siteUrl, listTitle, id, select), {
|
||||
headers: NOMETADATA_HEADERS
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await readErrorBody(response);
|
||||
throw new Error(REPORT_TEXTS.errors.spItem(response.status, body));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
const getCompanies = (): Promise<ICompany[]> => getItems<ICompany>(LISTS.companies, { select: SELECT.companies });
|
||||
const getProfessions = (): Promise<IProfession[]> =>
|
||||
getItems<IProfession>(LISTS.professions, { select: SELECT.professions });
|
||||
|
||||
const createListItem = async (
|
||||
listTitle: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<{ Id: number; Title: string }> => {
|
||||
const response = await client.fetch(listItemsUrl(siteUrl, listTitle), {
|
||||
method: 'POST',
|
||||
headers: NOMETADATA_POST_HEADERS,
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(REPORT_TEXTS.errors.spCreate(listTitle, body));
|
||||
}
|
||||
return (await response.json()) as { Id: number; Title: string };
|
||||
};
|
||||
|
||||
return {
|
||||
getProjectById: (projectId) => getItemById<IProjectRow>(LISTS.projects, projectId, SELECT.project),
|
||||
|
||||
getObjectsByProject: async (projectTitle) => {
|
||||
// Название проекта подставляется без экранирования апострофа — как в старом коде.
|
||||
const rows = await getItems<IObjectRow>(LISTS.objects, {
|
||||
filter: `project eq '${projectTitle}'`,
|
||||
select: SELECT.objects
|
||||
});
|
||||
return buildObjectPaths(rows);
|
||||
},
|
||||
|
||||
getCompaniesWithFallback: (projectId) =>
|
||||
withProjectFallback(
|
||||
() => getItems<{ companyIdId: number }>(LISTS.companiesByProject, junctionQuery(projectId, SELECT.companiesByProject)),
|
||||
(row) => row.companyIdId,
|
||||
getCompanies
|
||||
),
|
||||
|
||||
getShifts: () => getItems<IShift>(LISTS.shifts, { select: SELECT.shifts }),
|
||||
|
||||
getProfessionsWithFallback: (projectId) =>
|
||||
withProjectFallback(
|
||||
() =>
|
||||
getItems<{ professionIdId: number }>(
|
||||
LISTS.professionsByProject,
|
||||
junctionQuery(projectId, SELECT.professionsByProject)
|
||||
),
|
||||
(row) => row.professionIdId,
|
||||
getProfessions
|
||||
),
|
||||
|
||||
getProfessionById: (id) => getItemById<IProfession>(LISTS.professions, id, SELECT.professions),
|
||||
|
||||
getTechnics: async () => sortTechnics(await getItems<ITechnic>(LISTS.technics, { select: SELECT.technics })),
|
||||
|
||||
getTechnicById: (id) => getItemById<ITechnic>(LISTS.technics, id, SELECT.technics),
|
||||
|
||||
createListItem,
|
||||
|
||||
checkAccess: async (projectId) => {
|
||||
const rows = await getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` });
|
||||
return pickAccess(rows, deps.userId);
|
||||
},
|
||||
|
||||
uploadPhotos: async (projectId, reportNumber, photos) => {
|
||||
const folderUrl = photoFolderServerRelativeUrl(deps.webServerRelativeUrl, projectId, reportNumber);
|
||||
await ensureFolder(client, siteUrl, folderUrl);
|
||||
for (const photo of photos) {
|
||||
await uploadPhoto(client, siteUrl, folderUrl, photo);
|
||||
}
|
||||
return photoFolderLink(siteUrl, projectId, reportNumber);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function junctionQuery(projectId: number, select: string): IListQuery {
|
||||
return { filter: `projectIdId eq ${projectId}`, select };
|
||||
}
|
||||
|
||||
/**
|
||||
* Справочник «по проекту» с откатом на полный список — как getCompaniesWithFallback / getProfessionsWithFallback:
|
||||
* есть привязки к проекту — полный список, отфильтрованный по ним; нет привязок или ошибка — полный список.
|
||||
*/
|
||||
async function withProjectFallback<TJunction, TItem extends { Id: number }>(
|
||||
loadJunction: () => Promise<TJunction[]>,
|
||||
readItemId: (row: TJunction) => number,
|
||||
loadAll: () => Promise<TItem[]>
|
||||
): Promise<TItem[]> {
|
||||
try {
|
||||
const junction = await loadJunction();
|
||||
if (junction.length > 0) {
|
||||
const all = await loadAll();
|
||||
const ids = new Set(junction.map(readItemId));
|
||||
return all.filter((item) => ids.has(item.Id));
|
||||
}
|
||||
} catch {
|
||||
// Списка привязок нет или нет доступа — берём полный список.
|
||||
}
|
||||
return loadAll();
|
||||
}
|
||||
|
||||
/** Строка «Доступы» текущего пользователя. Имя поля пользователя перебирается, как в старом коде. */
|
||||
export function pickAccess(rows: IAccessRaw[], currentUserId: number | undefined): IReportAccess | undefined {
|
||||
const row = rows.filter((item) => {
|
||||
const itemUserId = item.userId || item.userIdId || item.UserId || item.UserIdId;
|
||||
return itemUserId === currentUserId;
|
||||
})[0];
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: row.Id,
|
||||
userId: currentUserId,
|
||||
project: String(row.projectId),
|
||||
read: !!row.read,
|
||||
edit: !!row.edit0,
|
||||
aproove: !!row.aproove
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureFolder(client: IHttpClient, siteUrl: string, serverRelativeFolderUrl: string): Promise<void> {
|
||||
const check = await client.fetch(folderByUrlUrl(siteUrl, serverRelativeFolderUrl), { headers: NOMETADATA_HEADERS });
|
||||
if (check.ok) {
|
||||
return;
|
||||
}
|
||||
const created = await client.fetch(`${siteUrl}/_api/web/folders`, {
|
||||
method: 'POST',
|
||||
headers: NOMETADATA_POST_HEADERS,
|
||||
body: JSON.stringify({ ServerRelativeUrl: serverRelativeFolderUrl })
|
||||
});
|
||||
if (!created.ok) {
|
||||
const body = await readErrorBody(created);
|
||||
throw new Error(REPORT_TEXTS.errors.folderCreate(created.status, body));
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPhoto(
|
||||
client: IHttpClient,
|
||||
siteUrl: string,
|
||||
serverRelativeFolderUrl: string,
|
||||
photo: IPhotoItem
|
||||
): Promise<void> {
|
||||
const response = await client.fetch(fileAddUrl(siteUrl, serverRelativeFolderUrl, photo.name), {
|
||||
method: 'POST',
|
||||
headers: UPLOAD_HEADERS,
|
||||
body: photo.buffer
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await readErrorBody(response);
|
||||
throw new Error(REPORT_TEXTS.errors.photoUpload(photo.name, response.status, body));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Тексты формы отчёта — дословно из stroy-bot-report (StroyBotReport.tsx, sections/*, PickerModal.tsx, services/*).
|
||||
|
||||
export const REPORT_TEXTS = {
|
||||
loading: 'Загрузка отчета...',
|
||||
noAccess: {
|
||||
title: 'Нет доступа',
|
||||
text: 'У вас нет прав для просмотра отчетов данного проекта.'
|
||||
},
|
||||
back: '← Вернуться назад',
|
||||
header: {
|
||||
newReport: 'Новый отчет',
|
||||
reportNumber: (num: number): string => `Отчет №${num}`,
|
||||
project: 'Проект:',
|
||||
approved: 'Утверждён',
|
||||
pending: 'На утверждении'
|
||||
},
|
||||
fields: {
|
||||
reportDate: 'Дата отчета',
|
||||
shift: 'Смена',
|
||||
object: 'Объект',
|
||||
objectPlaceholder: 'Выберите объект...',
|
||||
company: 'Компания',
|
||||
companyPlaceholder: 'Выберите компанию...'
|
||||
},
|
||||
sections: {
|
||||
works: 'Работы',
|
||||
professions: 'Персонал',
|
||||
technics: 'Техника',
|
||||
comment: 'Комментарий',
|
||||
photos: 'Фото'
|
||||
},
|
||||
work: {
|
||||
value: 'Значение',
|
||||
valuePlaceholder: '0',
|
||||
planFact: 'План-факт',
|
||||
plan: 'П:',
|
||||
fact: 'Ф:',
|
||||
difference: '+/-:',
|
||||
comment: 'Комментарий',
|
||||
commentPlaceholder: 'Комментарий',
|
||||
add: '+ Выбрать работу',
|
||||
nestedProfessions: 'Персонал на работе',
|
||||
nestedTechnics: 'Техника на работе',
|
||||
addNestedProfession: '+ Добавить профессию',
|
||||
addNestedTechnic: '+ Добавить технику'
|
||||
},
|
||||
resource: {
|
||||
count: 'Кол-во',
|
||||
hours: 'Часы',
|
||||
numberPlaceholder: '0',
|
||||
hoursType: 'Тип часов',
|
||||
comment: 'Комментарий',
|
||||
commentPlaceholder: 'Комментарий...',
|
||||
remove: 'Удалить',
|
||||
addProfession: '+ Выбрать профессию',
|
||||
addTechnic: '+ Выбрать технику'
|
||||
},
|
||||
commentPlaceholder: 'Общий комментарий...',
|
||||
photos: {
|
||||
openFolder: '📁 Открыть папку с фото',
|
||||
add: '📷 Добавить фото',
|
||||
remove: 'Удалить',
|
||||
readError: (fileName: string): string => `Не удалось прочитать файл: ${fileName}`
|
||||
},
|
||||
actions: {
|
||||
save: 'Сохранить отчет',
|
||||
saving: 'Сохранение...',
|
||||
approve: 'Утвердить отчет',
|
||||
approving: 'Утверждение...'
|
||||
},
|
||||
messages: {
|
||||
missingProjectId: 'Не указан projectId в URL',
|
||||
loadError: (message: string): string => `Ошибка загрузки: ${message}`,
|
||||
saveError: (message: string): string => `Ошибка сохранения: ${message}`,
|
||||
approveError: (message: string): string => `Ошибка утверждения: ${message}`,
|
||||
saved: 'Отчет успешно сохранен!',
|
||||
approved: 'Отчет утвержден!',
|
||||
approveConfirm: 'Утвердить отчет? Это действие нельзя отменить.',
|
||||
selectObjectFirst: 'Сначала выберите объект'
|
||||
},
|
||||
validation: {
|
||||
object: 'Выберите объект',
|
||||
company: 'Выберите компанию',
|
||||
shift: 'Выберите смену',
|
||||
reportDate: 'Укажите дату отчета',
|
||||
noRows: 'Добавьте хотя бы одну работу, профессию или технику',
|
||||
professionComment: (index: number, title: string): string =>
|
||||
`Персонал #${index} (${title}): укажите комментарий для типа "Часы простоя"`,
|
||||
technicComment: (index: number, title: string, hoursType: string): string =>
|
||||
`Техника #${index} (${title}): укажите комментарий для типа "${hoursType}"`
|
||||
},
|
||||
picker: {
|
||||
titles: {
|
||||
object: 'Выбор объекта',
|
||||
company: 'Выбор компании',
|
||||
work: 'Выбор работы',
|
||||
profession: 'Выбор профессии',
|
||||
technic: 'Выбор техники'
|
||||
},
|
||||
close: 'Закрыть',
|
||||
searchPlaceholder: 'Поиск...',
|
||||
loading: 'Загрузка...',
|
||||
notFound: 'Ничего не найдено',
|
||||
emptyLevel: 'Нет работ на этом уровне',
|
||||
allWorks: 'Все работы',
|
||||
selectCurrent: (title: string): string => `Выбрать «${title}»`,
|
||||
filters: 'Фильтры',
|
||||
allGroups: 'Все',
|
||||
createNew: '+ Создать новую позицию',
|
||||
addInto: (title: string): string => `+ Добавить в «${title}»`,
|
||||
newTitle: 'Название *',
|
||||
newUnit: 'Единица изм. (м, т, шт...)',
|
||||
newPlan: 'План (объем)',
|
||||
nestedIn: (title: string): string => `Вложена в: ${title}`,
|
||||
parentPlaceholder: 'Родительская работа (необязательно)',
|
||||
create: 'Создать',
|
||||
creating: 'Создание...',
|
||||
cancel: 'Отмена'
|
||||
},
|
||||
// Тексты ошибок запросов — попадают в «Ошибка загрузки: …» / «Ошибка сохранения: …».
|
||||
errors: {
|
||||
spList: (status: number, statusText: string, body: string): string =>
|
||||
`SP REST error: ${status} ${statusText} ${body}`,
|
||||
spItem: (status: number, body: string): string => `SP REST error: ${status} ${body}`,
|
||||
spCreate: (listTitle: string, body: string): string => `Ошибка создания элемента в "${listTitle}": ${body}`,
|
||||
folderCreate: (status: number, body: string): string => `Не удалось создать папку: ${status} ${body}`,
|
||||
photoUpload: (fileName: string, status: number, body: string): string =>
|
||||
`Ошибка загрузки фото "${fileName}": ${status} ${body}`,
|
||||
dataverse: (method: string, status: number, body: string): string =>
|
||||
`Dataverse ${method} error: ${status} — ${body}`,
|
||||
emptyReportId: 'reportId is empty — cannot bind report data'
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as React from 'react';
|
||||
import { LISTS } from '../../config/lists';
|
||||
import { IReportLookups, IWorkItem } from './reportTypes';
|
||||
import { IPickerCreateData, IPickerItem, IPickerOptions } from './picker/pickerTypes';
|
||||
import { PickerKind, PICKER_OPTIONS, toPickerItems } from './reportPicker';
|
||||
import { addResource, addWork, createLocalId, createResourceItem } from './reportFormUpdates';
|
||||
import { newJobPath } from './reportRules';
|
||||
import { IReportFormApi } from './useReportForm';
|
||||
import { IReportServices } from './reportServices';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
|
||||
interface IActivePicker {
|
||||
kind: PickerKind;
|
||||
/** Работа, к которой добавляется персонал / техника (не задано — общая секция). */
|
||||
targetWorkId?: string;
|
||||
}
|
||||
|
||||
export interface IPickerModalApi {
|
||||
isOpen: boolean;
|
||||
options: IPickerOptions | undefined;
|
||||
items: IPickerItem[];
|
||||
isLoading: boolean;
|
||||
open: (kind: PickerKind, targetWorkId?: string) => void;
|
||||
select: (item: IPickerItem) => void;
|
||||
createNew: (data: IPickerCreateData) => Promise<IPickerItem>;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
interface IPickerDeps {
|
||||
services: IReportServices;
|
||||
report: IReportFormApi;
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
function newWorkItem(item: IPickerItem, lookups: IReportLookups, totalFact: number): IWorkItem {
|
||||
const job = lookups.jobs.filter((candidate) => candidate.cree9_tableid === item.id)[0];
|
||||
return {
|
||||
id: createLocalId(),
|
||||
jobId: String(item.id),
|
||||
jobTitle: job?.cree9_title || item.title,
|
||||
jobUnit: job?.cree9_unit || '',
|
||||
value: 0,
|
||||
plan: job?.cree9_volume || 0,
|
||||
totalFact,
|
||||
comment: '',
|
||||
professions: [],
|
||||
technics: []
|
||||
};
|
||||
}
|
||||
|
||||
/** Справочник для пикера: грузится при открытии, если ещё не загружен (работы — каждый раз). */
|
||||
async function loadLookupFor(kind: PickerKind, deps: IPickerDeps): Promise<Partial<IReportLookups>> {
|
||||
const { services, report, projectId } = deps;
|
||||
const { lookups, form } = report;
|
||||
switch (kind) {
|
||||
case 'object':
|
||||
return lookups.objects.length === 0
|
||||
? { objects: await services.sp.getObjectsByProject(report.projectName) }
|
||||
: {};
|
||||
case 'company':
|
||||
return lookups.companies.length === 0 ? { companies: await services.sp.getCompaniesWithFallback(projectId) } : {};
|
||||
case 'work':
|
||||
return form.objectId ? { jobs: await services.dv.getJobsByObjectId(form.objectId) } : {};
|
||||
case 'profession':
|
||||
return lookups.professions.length === 0
|
||||
? { professions: await services.sp.getProfessionsWithFallback(projectId) }
|
||||
: {};
|
||||
case 'technic':
|
||||
return lookups.technics.length === 0 ? { technics: await services.sp.getTechnics() } : {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Пикер формы отчёта: какой справочник открыт, его загрузка, выбор и создание позиций. */
|
||||
export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
|
||||
const { services, report } = deps;
|
||||
const [active, setActive] = React.useState<IActivePicker | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
|
||||
const open = (kind: PickerKind, targetWorkId?: string): void => {
|
||||
setActive({ kind, targetWorkId });
|
||||
setIsLoading(true);
|
||||
loadLookupFor(kind, deps)
|
||||
.then((loaded) => report.updateLookups(() => loaded))
|
||||
// Как в старом коде: ошибка загрузки справочника молча оставляет список пустым.
|
||||
.catch(() => undefined)
|
||||
.then(() => setIsLoading(false))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const close = (): void => setActive(undefined);
|
||||
|
||||
const applySelection = async (picker: IActivePicker, item: IPickerItem): Promise<void> => {
|
||||
report.clearValidation();
|
||||
const { kind, targetWorkId } = picker;
|
||||
if (kind === 'object') {
|
||||
const object = report.lookups.objects.filter((candidate) => candidate.Id === item.id)[0];
|
||||
const objectPath = object?.fullPath || item.title;
|
||||
report.updateForm((prev) => ({ ...prev, objectId: Number(item.id), objectPath, works: [] }));
|
||||
report.updateLookups(() => ({ jobs: [] }));
|
||||
} else if (kind === 'company') {
|
||||
report.updateForm((prev) => ({ ...prev, companyId: Number(item.id), companyName: item.title }));
|
||||
} else if (kind === 'work') {
|
||||
// 🐞 Как в старом коде: только что созданной работы ещё нет в lookups.jobs этого рендера,
|
||||
// поэтому у неё название = полный путь, ед. изм. пустая и план 0.
|
||||
const totalFact = await services.dv.getJobTotalFact(String(item.id));
|
||||
const work = newWorkItem(item, report.lookups, totalFact);
|
||||
report.updateForm((prev) => addWork(prev, work));
|
||||
} else {
|
||||
const resource = createResourceItem(Number(item.id), item.title);
|
||||
report.updateForm((prev) => addResource(prev, { kind, workId: targetWorkId }, resource));
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
const select = (item: IPickerItem): void => {
|
||||
if (!active) return;
|
||||
// Ошибка (например, при подсчёте факта работы) оставляет пикер открытым, как раньше.
|
||||
applySelection(active, item).catch((error: unknown) => console.error(error));
|
||||
};
|
||||
|
||||
const createNew = async (data: IPickerCreateData): Promise<IPickerItem> => {
|
||||
if (active?.kind === 'technic') {
|
||||
const created = await services.sp.createListItem(LISTS.technics, { Title: data.title });
|
||||
report.updateLookups((prev) => ({ technics: [...prev.technics, { Id: created.Id, Title: data.title }] }));
|
||||
return { id: created.Id, title: data.title };
|
||||
}
|
||||
const objectId = report.form.objectId;
|
||||
if (!objectId) throw new Error(REPORT_TEXTS.messages.selectObjectFirst);
|
||||
const created = await services.dv.createJob({
|
||||
title: data.title,
|
||||
unit: data.unit || '',
|
||||
objectId,
|
||||
volume: data.plan,
|
||||
parentId: data.parentId
|
||||
});
|
||||
const fullPath = newJobPath(data.title, data.parentId, report.lookups.jobs);
|
||||
report.updateLookups((prev) => ({ jobs: [...prev.jobs, { ...created, fullPath }] }));
|
||||
return { id: created.cree9_tableid, title: fullPath, subtitle: data.unit };
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen: !!active,
|
||||
options: active ? PICKER_OPTIONS[active.kind] : undefined,
|
||||
items: active ? toPickerItems(active.kind, report.lookups) : [],
|
||||
isLoading,
|
||||
open,
|
||||
select,
|
||||
createNew,
|
||||
dismiss: close
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as React from 'react';
|
||||
import { confirmModal } from '@tnsu/ui-kit';
|
||||
import { IReportAccess, IReportFormState, IReportLookups, IReportRouteParams, ReportFormMode } from './reportTypes';
|
||||
import { createEmptyForm } from './reportFormUpdates';
|
||||
import { isFormReadOnly, todayIsoDate } from './reportRules';
|
||||
import { validateReportForm } from './reportValidation';
|
||||
import { IReportLoadSink, loadReportForm } from './reportLoader';
|
||||
import { saveReport } from './reportSave';
|
||||
import { IReportServices } from './reportServices';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
|
||||
const MESSAGES = REPORT_TEXTS.messages;
|
||||
|
||||
const EMPTY_LOOKUPS: IReportLookups = {
|
||||
objects: [],
|
||||
companies: [],
|
||||
shifts: [],
|
||||
professions: [],
|
||||
technics: [],
|
||||
jobs: []
|
||||
};
|
||||
|
||||
export type FormUpdater = (prev: IReportFormState) => IReportFormState;
|
||||
export type LookupsUpdater = (prev: IReportLookups) => Partial<IReportLookups>;
|
||||
|
||||
export interface IReportFormApi {
|
||||
form: IReportFormState;
|
||||
mode: ReportFormMode;
|
||||
isReadOnly: boolean;
|
||||
projectName: string;
|
||||
access: IReportAccess | undefined;
|
||||
isApproved: boolean;
|
||||
lookups: IReportLookups;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
errorMessage: string;
|
||||
successMessage: string;
|
||||
validationErrors: string[];
|
||||
updateForm: (updater: FormUpdater) => void;
|
||||
updateLookups: (updater: LookupsUpdater) => void;
|
||||
setErrorMessage: (message: string) => void;
|
||||
clearValidation: () => void;
|
||||
save: () => Promise<void>;
|
||||
approve: () => Promise<void>;
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Всё состояние формы отчёта живёт здесь (один источник истины): данные формы, режим, справочники,
|
||||
* сообщения. Секции получают срезы и колбэки. Логика загрузки / сохранения — в reportLoader / reportSave.
|
||||
*/
|
||||
export function useReportForm(services: IReportServices, params: IReportRouteParams): IReportFormApi {
|
||||
const [form, setForm] = React.useState<IReportFormState>(() => createEmptyForm(todayIsoDate(new Date())));
|
||||
const [mode, setMode] = React.useState<ReportFormMode>('create');
|
||||
const [projectName, setProjectName] = React.useState<string>('');
|
||||
const [access, setAccess] = React.useState<IReportAccess | undefined>(undefined);
|
||||
const [isApproved, setIsApproved] = React.useState<boolean>(false);
|
||||
const [lookups, setLookups] = React.useState<IReportLookups>(EMPTY_LOOKUPS);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
const [isSaving, setIsSaving] = React.useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string>('');
|
||||
const [successMessage, setSuccessMessage] = React.useState<string>('');
|
||||
const [validationErrors, setValidationErrors] = React.useState<string[]>([]);
|
||||
// После первого сохранения нового отчёта форма знает его id (как setExistingReportId в старом коде).
|
||||
const [existingReportId, setExistingReportId] = React.useState<string | undefined>(params.reportId);
|
||||
|
||||
const updateLookups = React.useCallback((updater: LookupsUpdater): void => {
|
||||
setLookups((prev) => ({ ...prev, ...updater(prev) }));
|
||||
}, []);
|
||||
|
||||
useReportLoading(services, params, {
|
||||
setProjectName,
|
||||
setMode,
|
||||
setAccess,
|
||||
setApproved: setIsApproved,
|
||||
setLookups: (partial) => updateLookups(() => partial),
|
||||
setForm,
|
||||
setIsLoading,
|
||||
setErrorMessage
|
||||
});
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
const errors = validateReportForm(form);
|
||||
setValidationErrors(errors);
|
||||
if (errors.length > 0) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setErrorMessage('');
|
||||
setSuccessMessage('');
|
||||
try {
|
||||
const result = await saveReport(services, params.projectId, form, existingReportId);
|
||||
setForm((prev) =>
|
||||
result.photoFolderUrl === undefined
|
||||
? { ...prev, reportNumber: result.reportNumber }
|
||||
: { ...prev, reportNumber: result.reportNumber, photoFolderUrl: result.photoFolderUrl, photos: [] }
|
||||
);
|
||||
setExistingReportId(result.reportId);
|
||||
setSuccessMessage(MESSAGES.saved);
|
||||
} catch (error) {
|
||||
setErrorMessage(MESSAGES.saveError(messageOf(error)));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const approve = async (): Promise<void> => {
|
||||
if (!existingReportId) return;
|
||||
if (!(await confirmModal({ message: MESSAGES.approveConfirm }))) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
await services.dv.approveReport(existingReportId);
|
||||
setSuccessMessage(MESSAGES.approved);
|
||||
// 🐞 Как в старом коде: флаг «утверждён» не обновляется, бейдж остаётся «На утверждении».
|
||||
setMode('readonly');
|
||||
} catch (error) {
|
||||
setErrorMessage(MESSAGES.approveError(messageOf(error)));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
mode,
|
||||
isReadOnly: isFormReadOnly(mode),
|
||||
projectName,
|
||||
access,
|
||||
isApproved,
|
||||
lookups,
|
||||
isLoading,
|
||||
isSaving,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
validationErrors,
|
||||
updateForm: setForm,
|
||||
updateLookups,
|
||||
setErrorMessage,
|
||||
clearValidation: () => setValidationErrors([]),
|
||||
save,
|
||||
approve
|
||||
};
|
||||
}
|
||||
|
||||
interface ILoadingSetters extends IReportLoadSink {
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
setErrorMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
/** Загрузка один раз на открытую форму (ReportPage пересоздаёт форму при смене адреса). */
|
||||
function useReportLoading(services: IReportServices, params: IReportRouteParams, setters: ILoadingSetters): void {
|
||||
const settersRef = React.useRef(setters);
|
||||
settersRef.current = setters;
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
// Ответы, пришедшие после ухода со страницы, не трогают состояние.
|
||||
const sink: IReportLoadSink = {
|
||||
setProjectName: (name) => !isCancelled && settersRef.current.setProjectName(name),
|
||||
setMode: (value) => !isCancelled && settersRef.current.setMode(value),
|
||||
setAccess: (value) => !isCancelled && settersRef.current.setAccess(value),
|
||||
setApproved: (value) => !isCancelled && settersRef.current.setApproved(value),
|
||||
setLookups: (value) => !isCancelled && settersRef.current.setLookups(value),
|
||||
setForm: (value) => !isCancelled && settersRef.current.setForm(value)
|
||||
};
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
settersRef.current.setIsLoading(true);
|
||||
settersRef.current.setErrorMessage('');
|
||||
try {
|
||||
const outcome = await loadReportForm(services, params, new Date(), sink);
|
||||
if (!isCancelled && outcome === 'missingProjectId') {
|
||||
settersRef.current.setErrorMessage(MESSAGES.missingProjectId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) settersRef.current.setErrorMessage(MESSAGES.loadError(messageOf(error)));
|
||||
} finally {
|
||||
if (!isCancelled) settersRef.current.setIsLoading(false);
|
||||
}
|
||||
};
|
||||
// run() сам обрабатывает ошибки — catch только чтобы промис не остался «висящим».
|
||||
run().catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
// Параметры адреса в зависимостях не нужны: при их смене ReportPage монтирует форму заново (key).
|
||||
}, [services]);
|
||||
}
|
||||
Reference in New Issue
Block a user