wip: add report form page (not wired yet)

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-16 09:48:06 +05:00
parent e12fce6032
commit bfb3eb7b75
51 changed files with 4228 additions and 0 deletions
@@ -0,0 +1,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=…|&copyFromId=…].
* Старая страница читала адрес один раз при загрузке; здесь при смене параметров форма монтируется заново (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}&nbsp;<strong>{plan}</strong>
{TEXTS.fact}&nbsp;<strong>{totalFact}</strong>
{TEXTS.difference}&nbsp;<strong className={difference >= 0 ? styles.positive : styles.negative}>{difference}</strong>
</span>
);
};
@@ -0,0 +1,54 @@
import * as React from 'react';
import { FormField, IconButton, Select, TextInput } from '@tnsu/ui-kit';
import { IResourceItem } from '../reportTypes';
import { HOURS_TYPES } from '../reportConstants';
import { isResourceCommentRequired } from '../reportValidation';
import { CountHoursFields } from './CountHoursFields';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
const TEXTS = REPORT_TEXTS.resource;
interface Props {
item: IResourceItem;
/** Варианты «Тип часов»: у персонала — работы / простоя, у техники ещё ремонта. */
hoursTypes: string[];
readOnly: boolean;
onChange: (patch: Partial<IResourceItem>) => void;
onRemove: () => void;
}
/** Карточка общего персонала / техники: Кол-во, Часы, Тип часов, Комментарий (обязателен для простоя / ремонта). */
export const ResourceCard: React.FC<Props> = ({ item, hoursTypes, readOnly, onChange, onRemove }) => {
const hoursTypeOptions = hoursTypes.map((type) => ({ value: type, label: type }));
return (
<div className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardTitle}>{item.title}</span>
{!readOnly && <IconButton icon="close" title={TEXTS.remove} danger size="sm" onClick={onRemove} />}
</div>
<CountHoursFields count={item.count} hours={item.hours} readOnly={readOnly} onChange={onChange} />
<FormField label={TEXTS.hoursType}>
<Select
value={item.hoursType || HOURS_TYPES.work}
onChange={(value) => onChange({ hoursType: value })}
options={hoursTypeOptions}
readOnly={readOnly}
/>
</FormField>
<FormField label={TEXTS.comment} required={isResourceCommentRequired(item.hoursType, hoursTypes)}>
<TextInput
value={item.comment || ''}
onChange={(value) => onChange({ comment: value })}
placeholder={TEXTS.commentPlaceholder}
readOnly={readOnly}
ariaLabel={TEXTS.comment}
/>
</FormField>
</div>
);
};
@@ -0,0 +1,65 @@
import * as React from 'react';
import { Button, IconName } from '@tnsu/ui-kit';
import { IResourceItem, ResourceKind } from '../reportTypes';
import { PROFESSION_HOURS_TYPES, TECHNIC_HOURS_TYPES } from '../reportConstants';
import { SectionTitle } from './SectionTitle';
import { ResourceCard } from './ResourceCard';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
interface IKindView {
icon: IconName;
title: string;
addLabel: string;
hoursTypes: string[];
}
const VIEW_BY_KIND: Record<ResourceKind, IKindView> = {
profession: {
icon: 'people',
title: REPORT_TEXTS.sections.professions,
addLabel: REPORT_TEXTS.resource.addProfession,
hoursTypes: PROFESSION_HOURS_TYPES
},
technic: {
icon: 'vehicle',
title: REPORT_TEXTS.sections.technics,
addLabel: REPORT_TEXTS.resource.addTechnic,
hoursTypes: TECHNIC_HOURS_TYPES
}
};
interface Props {
kind: ResourceKind;
items: IResourceItem[];
readOnly: boolean;
onAdd: () => void;
onChange: (itemId: string, patch: Partial<IResourceItem>) => void;
onRemove: (itemId: string) => void;
}
/** Секция «Персонал» / «Техника» — общие, не привязанные к работе. */
export const ResourceSection: React.FC<Props> = ({ kind, items, readOnly, onAdd, onChange, onRemove }) => {
const view = VIEW_BY_KIND[kind];
return (
<section className={styles.section}>
<SectionTitle icon={view.icon} title={view.title} />
{items.map((item) => (
<ResourceCard
key={item.id}
item={item}
hoursTypes={view.hoursTypes}
readOnly={readOnly}
onChange={(patch) => onChange(item.id, patch)}
onRemove={() => onRemove(item.id)}
/>
))}
{!readOnly && (
<Button type="button" variant="secondary" size="sm" className={styles.addButton} onClick={onAdd}>
{view.addLabel}
</Button>
)}
</section>
);
};
@@ -0,0 +1,16 @@
import * as React from 'react';
import { Icon, IconName } from '@tnsu/ui-kit';
import styles from '../ReportPage.module.scss';
interface Props {
icon: IconName;
title: string;
}
/** Заголовок секции формы с иконкой. */
export const SectionTitle: React.FC<Props> = ({ icon, title }) => (
<h4 className={styles.sectionTitle}>
<Icon name={icon} size="sm" />
{title}
</h4>
);
@@ -0,0 +1,82 @@
import * as React from 'react';
import { FormField, IconButton, TextInput } from '@tnsu/ui-kit';
import { IResourceItem, IWorkItem, ResourceKind } from '../reportTypes';
import { WorkValueInput } from './WorkValueInput';
import { PlanFact } from './PlanFact';
import { NestedResourceGroup } from './NestedResourceGroup';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
const TEXTS = REPORT_TEXTS.work;
interface Props {
work: IWorkItem;
readOnly: boolean;
onChange: (patch: Partial<IWorkItem>) => void;
onRemove: () => void;
onAddResource: (kind: ResourceKind) => void;
onChangeResource: (kind: ResourceKind, itemId: string, patch: Partial<IResourceItem>) => void;
onRemoveResource: (kind: ResourceKind, itemId: string) => void;
}
/** Карточка работы: «Название, ед.», Значение, План-факт, Комментарий и вложенные персонал / техника. */
export const WorkCard: React.FC<Props> = (props) => {
const { work, readOnly } = props;
const valueInputId = `work-value-${work.id}`;
return (
<div className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardTitle}>
{work.jobTitle}
{work.jobUnit ? `, ${work.jobUnit}` : ''}
</span>
{!readOnly && (
<IconButton icon="close" title={REPORT_TEXTS.resource.remove} danger size="sm" onClick={props.onRemove} />
)}
</div>
<FormField label={TEXTS.value} htmlFor={valueInputId}>
<WorkValueInput
id={valueInputId}
value={work.value}
readOnly={readOnly}
onChange={(value) => props.onChange({ value })}
/>
</FormField>
<FormField label={TEXTS.planFact}>
<PlanFact plan={work.plan} totalFact={work.totalFact} />
</FormField>
<FormField label={TEXTS.comment}>
<TextInput
value={work.comment || ''}
onChange={(comment) => props.onChange({ comment })}
placeholder={TEXTS.commentPlaceholder}
readOnly={readOnly}
ariaLabel={TEXTS.comment}
/>
</FormField>
<NestedResourceGroup
title={TEXTS.nestedProfessions}
addLabel={TEXTS.addNestedProfession}
items={work.professions}
readOnly={readOnly}
onAdd={() => props.onAddResource('profession')}
onChange={(itemId, patch) => props.onChangeResource('profession', itemId, patch)}
onRemove={(itemId) => props.onRemoveResource('profession', itemId)}
/>
<NestedResourceGroup
title={TEXTS.nestedTechnics}
addLabel={TEXTS.addNestedTechnic}
items={work.technics}
readOnly={readOnly}
onAdd={() => props.onAddResource('technic')}
onChange={(itemId, patch) => props.onChangeResource('technic', itemId, patch)}
onRemove={(itemId) => props.onRemoveResource('technic', itemId)}
/>
</div>
);
};
@@ -0,0 +1,43 @@
import * as React from 'react';
import { Button } from '@tnsu/ui-kit';
import { IWorkItem } from '../reportTypes';
import { IReportSectionHandlers } from '../reportSectionHandlers';
import { SectionTitle } from './SectionTitle';
import { WorkCard } from './WorkCard';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
type WorkHandlers = Pick<
IReportSectionHandlers,
'addWork' | 'changeWork' | 'removeWork' | 'addResource' | 'changeResource' | 'removeResource'
>;
interface Props {
works: IWorkItem[];
readOnly: boolean;
handlers: WorkHandlers;
}
/** Секция «Работы»: карточки работ и «+ Выбрать работу». */
export const WorkSection: React.FC<Props> = ({ works, readOnly, handlers }) => (
<section className={styles.section}>
<SectionTitle icon="clipboard" title={REPORT_TEXTS.sections.works} />
{works.map((work) => (
<WorkCard
key={work.id}
work={work}
readOnly={readOnly}
onChange={(patch) => handlers.changeWork(work.id, patch)}
onRemove={() => handlers.removeWork(work.id)}
onAddResource={(kind) => handlers.addResource({ kind, workId: work.id })}
onChangeResource={(kind, itemId, patch) => handlers.changeResource({ kind, workId: work.id }, itemId, patch)}
onRemoveResource={(kind, itemId) => handlers.removeResource({ kind, workId: work.id }, itemId)}
/>
))}
{!readOnly && (
<Button type="button" variant="secondary" size="sm" className={styles.addButton} onClick={handlers.addWork}>
{REPORT_TEXTS.work.add}
</Button>
)}
</section>
);
@@ -0,0 +1,50 @@
import * as React from 'react';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
interface Props {
id: string;
value: number;
readOnly: boolean;
onChange: (value: number) => void;
}
/**
* «Значение» работы. Пока поле в фокусе, хранится введённый текст («1,» / «1.»),
* в форму уходит parseFloat; при уходе с поля мусор превращается в 0 — как в старой WorkSection.
* Обычный input, потому что TextInput кита не поддерживает inputMode="decimal" (цифровая клавиатура на телефоне).
*/
export const WorkValueInput: React.FC<Props> = ({ id, value, readOnly, onChange }) => {
const [draft, setDraft] = React.useState<string | undefined>(undefined);
const displayValue = draft !== undefined ? draft : String(value);
const handleChange = (raw: string): void => {
setDraft(raw);
const parsed = parseFloat(raw);
if (!isNaN(parsed)) {
onChange(parsed);
} else if (raw === '') {
onChange(0);
}
};
const handleBlur = (raw: string): void => {
const parsed = parseFloat(raw);
onChange(isNaN(parsed) ? 0 : parsed);
setDraft(undefined);
};
return (
<input
id={id}
type="text"
inputMode="decimal"
className={styles.valueInput}
value={displayValue}
onChange={(event) => handleChange(event.target.value)}
onBlur={(event) => handleBlur(event.target.value)}
disabled={readOnly}
placeholder={REPORT_TEXTS.work.valuePlaceholder}
/>
);
};
@@ -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));
}
}
+133
View File
@@ -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]);
}