refactor: extract shared Excel import parts
File picking, the server check, the result table and value parsing do not depend on what the rows contain. Move them to pages/_excelImport so the upcoming works import reuses them instead of copying. Report import behaves as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-10
@@ -1,27 +1,27 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { IReportImportCheck } from './reportImportTypes';
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
import { ReportImportSummary } from './ReportImportSummary';
|
import { ExcelCheckSummary } from './ExcelCheckSummary';
|
||||||
import { ReportImportTable } from './ReportImportTable';
|
import { ExcelCheckTable } from './ExcelCheckTable';
|
||||||
import { REPORT_IMPORT_TEXTS } from './texts';
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
import styles from './ReportImportPage.module.scss';
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
check: IReportImportCheck;
|
check: IExcelImportCheck;
|
||||||
/** Импорт — сразу под итогом проверки, чтобы кнопка не терялась под длинной таблицей. */
|
/** Импорт — сразу под итогом проверки, чтобы кнопка не терялась под длинной таблицей. */
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Результат проверки файла: итог, импорт, таблица строк и примечания сервера. */
|
/** Результат проверки файла: итог, импорт, таблица строк и примечания сервера. */
|
||||||
export const ReportImportResult: React.FC<Props> = ({ check, actions }) => {
|
export const ExcelCheckResult: React.FC<Props> = ({ check, actions }) => {
|
||||||
const notes = check.notes || [];
|
const notes = check.notes || [];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ReportImportSummary check={check} />
|
<ExcelCheckSummary check={check} />
|
||||||
{actions}
|
{actions}
|
||||||
<ReportImportTable key={check.file_name} check={check} />
|
<ExcelCheckTable key={check.file_name} check={check} />
|
||||||
{notes.length > 0 && (
|
{notes.length > 0 && (
|
||||||
<section className={styles.notes}>
|
<section className={styles.notes}>
|
||||||
<h3 className={styles.sectionLabel}>{REPORT_IMPORT_TEXTS.notesTitle}</h3>
|
<h3 className={styles.sectionLabel}>{EXCEL_IMPORT_TEXTS.notesTitle}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{notes.map((note, index) => (
|
{notes.map((note, index) => (
|
||||||
<li key={`${index}-${note}`}>{note}</li>
|
<li key={`${index}-${note}`}>{note}</li>
|
||||||
+7
-7
@@ -1,16 +1,16 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { issuesByColumn } from './reportImportRules';
|
import { issuesByColumn } from './excelImportRules';
|
||||||
import { IReportImportRow } from './reportImportTypes';
|
import { IExcelImportRow } from './excelImportTypes';
|
||||||
import { REPORT_IMPORT_TEXTS } from './texts';
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
import styles from './ReportImportPage.module.scss';
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
row: IReportImportRow;
|
row: IExcelImportRow;
|
||||||
columns: string[];
|
columns: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Строка файла: ячейки с ошибкой подсвечены (сообщение — во всплывающей подсказке), справа — список ошибок. */
|
/** Строка файла: ячейки с ошибкой подсвечены (сообщение — во всплывающей подсказке), справа — список ошибок. */
|
||||||
export const ReportImportRow: React.FC<Props> = ({ row, columns }) => {
|
export const ExcelCheckRow: React.FC<Props> = ({ row, columns }) => {
|
||||||
const issues = issuesByColumn(row);
|
const issues = issuesByColumn(row);
|
||||||
return (
|
return (
|
||||||
<tr className={row.valid ? undefined : styles.invalidRow}>
|
<tr className={row.valid ? undefined : styles.invalidRow}>
|
||||||
@@ -20,7 +20,7 @@ export const ReportImportRow: React.FC<Props> = ({ row, columns }) => {
|
|||||||
const value = row.values?.[column];
|
const value = row.values?.[column];
|
||||||
return (
|
return (
|
||||||
<td key={column} className={messages ? styles.invalidCell : undefined} title={messages?.join('\n')}>
|
<td key={column} className={messages ? styles.invalidCell : undefined} title={messages?.join('\n')}>
|
||||||
{value || <span className={styles.muted}>{REPORT_IMPORT_TEXTS.empty}</span>}
|
{value || <span className={styles.muted}>{EXCEL_IMPORT_TEXTS.empty}</span>}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
+10
-10
@@ -1,18 +1,18 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Alert, Badge } from '@tnsu/ui-kit';
|
import { Alert, Badge } from '@tnsu/ui-kit';
|
||||||
import { canImportFile } from './reportImportRules';
|
import { canImportFile } from './excelImportRules';
|
||||||
import { IReportImportCheck } from './reportImportTypes';
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
import { REPORT_IMPORT_TEXTS } from './texts';
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
import styles from './ReportImportPage.module.scss';
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
const TEXTS = REPORT_IMPORT_TEXTS.summary;
|
const TEXTS = EXCEL_IMPORT_TEXTS.summary;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
check: IReportImportCheck;
|
check: IExcelImportCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Итог проверки: имя файла, счётчики строк и что делать дальше. */
|
/** Итог проверки: имя файла, счётчики строк и что делать дальше. */
|
||||||
export const ReportImportSummary: React.FC<Props> = ({ check }) => {
|
export const ExcelCheckSummary: React.FC<Props> = ({ check }) => {
|
||||||
const isEmpty = check.total_rows === 0;
|
const isEmpty = check.total_rows === 0;
|
||||||
return (
|
return (
|
||||||
<div className={styles.summary}>
|
<div className={styles.summary}>
|
||||||
@@ -22,9 +22,9 @@ export const ReportImportSummary: React.FC<Props> = ({ check }) => {
|
|||||||
<Badge variant="success">{TEXTS.valid(check.valid_rows)}</Badge>
|
<Badge variant="success">{TEXTS.valid(check.valid_rows)}</Badge>
|
||||||
{check.invalid_rows > 0 && <Badge variant="danger">{TEXTS.invalid(check.invalid_rows)}</Badge>}
|
{check.invalid_rows > 0 && <Badge variant="danger">{TEXTS.invalid(check.invalid_rows)}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
{isEmpty && <Alert variant="warn">{REPORT_IMPORT_TEXTS.emptyFile}</Alert>}
|
{isEmpty && <Alert variant="warn">{EXCEL_IMPORT_TEXTS.emptyFile}</Alert>}
|
||||||
{!isEmpty && canImportFile(check) && <Alert variant="ok">{REPORT_IMPORT_TEXTS.readyToImport}</Alert>}
|
{!isEmpty && canImportFile(check) && <Alert variant="ok">{EXCEL_IMPORT_TEXTS.readyToImport}</Alert>}
|
||||||
{check.invalid_rows > 0 && <Alert variant="warn">{REPORT_IMPORT_TEXTS.fixInExcel}</Alert>}
|
{check.invalid_rows > 0 && <Alert variant="warn">{EXCEL_IMPORT_TEXTS.fixInExcel}</Alert>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+10
-10
@@ -1,19 +1,19 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Checkbox } from '@tnsu/ui-kit';
|
import { Checkbox } from '@tnsu/ui-kit';
|
||||||
import { columnsOf, visibleRows } from './reportImportRules';
|
import { columnsOf, visibleRows } from './excelImportRules';
|
||||||
import { IReportImportCheck } from './reportImportTypes';
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
import { ReportImportRow } from './ReportImportRow';
|
import { ExcelCheckRow } from './ExcelCheckRow';
|
||||||
import { REPORT_IMPORT_TEXTS } from './texts';
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
import styles from './ReportImportPage.module.scss';
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
const COLUMNS = REPORT_IMPORT_TEXTS.columns;
|
const COLUMNS = EXCEL_IMPORT_TEXTS.columns;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
check: IReportImportCheck;
|
check: IExcelImportCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Таблица строк файла с фильтром «Только строки с ошибками». */
|
/** Таблица строк файла с фильтром «Только строки с ошибками». */
|
||||||
export const ReportImportTable: React.FC<Props> = ({ check }) => {
|
export const ExcelCheckTable: React.FC<Props> = ({ check }) => {
|
||||||
const [onlyInvalid, setOnlyInvalid] = React.useState<boolean>(false);
|
const [onlyInvalid, setOnlyInvalid] = React.useState<boolean>(false);
|
||||||
const columns = columnsOf(check);
|
const columns = columnsOf(check);
|
||||||
const rows = visibleRows(check, onlyInvalid);
|
const rows = visibleRows(check, onlyInvalid);
|
||||||
@@ -23,7 +23,7 @@ export const ReportImportTable: React.FC<Props> = ({ check }) => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
{check.invalid_rows > 0 && (
|
{check.invalid_rows > 0 && (
|
||||||
<Checkbox label={REPORT_IMPORT_TEXTS.onlyInvalid} checked={onlyInvalid} onChange={setOnlyInvalid} />
|
<Checkbox label={EXCEL_IMPORT_TEXTS.onlyInvalid} checked={onlyInvalid} onChange={setOnlyInvalid} />
|
||||||
)}
|
)}
|
||||||
<div className={styles.tableWrap}>
|
<div className={styles.tableWrap}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
@@ -38,7 +38,7 @@ export const ReportImportTable: React.FC<Props> = ({ check }) => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<ReportImportRow key={row.row} row={row} columns={columns} />
|
<ExcelCheckRow key={row.row} row={row} columns={columns} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
+6
-6
@@ -1,8 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Button, Icon } from '@tnsu/ui-kit';
|
import { Button, Icon } from '@tnsu/ui-kit';
|
||||||
import { EXCEL_ACCEPT } from './reportImportRules';
|
import { EXCEL_ACCEPT } from './excelImportRules';
|
||||||
import { REPORT_IMPORT_TEXTS } from './texts';
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
import styles from './ReportImportPage.module.scss';
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedFileName: string | undefined;
|
selectedFileName: string | undefined;
|
||||||
@@ -14,7 +14,7 @@ interface Props {
|
|||||||
* Выбор Excel-файла — только выбор, без отправки. Скрытый input сбрасывается,
|
* Выбор Excel-файла — только выбор, без отправки. Скрытый input сбрасывается,
|
||||||
* чтобы тот же файл можно было выбрать повторно.
|
* чтобы тот же файл можно было выбрать повторно.
|
||||||
*/
|
*/
|
||||||
export const ReportFileField: React.FC<Props> = ({ selectedFileName, disabled, onSelect }) => {
|
export const ExcelFileField: React.FC<Props> = ({ selectedFileName, disabled, onSelect }) => {
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
@@ -29,11 +29,11 @@ export const ReportFileField: React.FC<Props> = ({ selectedFileName, disabled, o
|
|||||||
<Button variant="secondary" onClick={() => inputRef.current?.click()} disabled={disabled}>
|
<Button variant="secondary" onClick={() => inputRef.current?.click()} disabled={disabled}>
|
||||||
<span className={styles.buttonContent}>
|
<span className={styles.buttonContent}>
|
||||||
<Icon name="upload" size="sm" />
|
<Icon name="upload" size="sm" />
|
||||||
{selectedFileName ? REPORT_IMPORT_TEXTS.chooseAnotherFile : REPORT_IMPORT_TEXTS.chooseFile}
|
{selectedFileName ? EXCEL_IMPORT_TEXTS.chooseAnotherFile : EXCEL_IMPORT_TEXTS.chooseFile}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
{selectedFileName && (
|
{selectedFileName && (
|
||||||
<span className={styles.fileName}>{REPORT_IMPORT_TEXTS.selectedFile(selectedFileName)}</span>
|
<span className={styles.fileName}>{EXCEL_IMPORT_TEXTS.selectedFile(selectedFileName)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// В файле может быть много колонок — таблица прокручивается внутри рамки, а не растягивает страницу.
|
||||||
|
$issues-min-width: 260px;
|
||||||
|
$cell-max-width: 280px;
|
||||||
|
|
||||||
|
.fileField {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonContent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
margin-right: $sp-2;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
background: $gray-50;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
max-width: $cell-max-width;
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
border-bottom: 1px solid $gray-100;
|
||||||
|
vertical-align: top;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowNumber {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalidRow .rowNumber {
|
||||||
|
border-left: 3px solid $red-500;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalidCell {
|
||||||
|
background: $red-50;
|
||||||
|
box-shadow: inset 0 0 0 1px $red-500;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issues {
|
||||||
|
min-width: $issues-min-width;
|
||||||
|
font-size: $fs-label;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: $red-500;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-700;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: $sp-2 0 0;
|
||||||
|
padding-left: $sp-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
margin: 0;
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-section-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { canImportFile, isExcelFileName } from './excelImportRules';
|
||||||
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
|
import { CheckUrlBuilder, useExcelFileCheck } from './useExcelFileCheck';
|
||||||
|
import { ExcelFileField } from './ExcelFileField';
|
||||||
|
import { ExcelCheckResult } from './ExcelCheckResult';
|
||||||
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: number;
|
||||||
|
checkUrl: CheckUrlBuilder;
|
||||||
|
/** Пока раздел записывает данные, новый файл не выбрать: иначе итог записи пропадёт с экрана. */
|
||||||
|
isLocked: boolean;
|
||||||
|
/** Выбран новый файл — раздел сбрасывает итог прошлой записи. */
|
||||||
|
onFileChange: () => void;
|
||||||
|
/** Блок «Импортировать» — только для файла без ошибок. */
|
||||||
|
renderImport: (check: IExcelImportCheck) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбор Excel-файла, проверка на сервере сразу после выбора и результат проверки. */
|
||||||
|
export const ExcelImportSection: React.FC<Props> = ({ projectId, checkUrl, isLocked, onFileChange, renderImport }) => {
|
||||||
|
const { status, checkFile, reset } = useExcelFileCheck(projectId, checkUrl);
|
||||||
|
const [selectedFileName, setSelectedFileName] = React.useState<string | undefined>(undefined);
|
||||||
|
const [fileError, setFileError] = React.useState<string>('');
|
||||||
|
const isChecking = status.kind === 'checking';
|
||||||
|
|
||||||
|
const selectFile = (file: File): void => {
|
||||||
|
onFileChange();
|
||||||
|
if (!isExcelFileName(file.name)) {
|
||||||
|
reset();
|
||||||
|
setSelectedFileName(undefined);
|
||||||
|
setFileError(EXCEL_IMPORT_TEXTS.wrongExtension);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFileName(file.name);
|
||||||
|
setFileError('');
|
||||||
|
checkFile(file).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExcelFileField selectedFileName={selectedFileName} disabled={isChecking || isLocked} onSelect={selectFile} />
|
||||||
|
{fileError && <Alert variant="err">{fileError}</Alert>}
|
||||||
|
{isChecking && <Spinner size="small" label={EXCEL_IMPORT_TEXTS.checking} />}
|
||||||
|
{status.kind === 'error' && <Alert variant="err">{status.message}</Alert>}
|
||||||
|
{status.kind === 'done' && (
|
||||||
|
<ExcelCheckResult
|
||||||
|
check={status.check}
|
||||||
|
actions={canImportFile(status.check) ? renderImport(status.check) : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { checkExcelFile } from './excelImportRepository';
|
||||||
|
import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
|
||||||
|
|
||||||
|
const URL = 'https://api.tnsu.kz/Import/StroyBot/Reports?projectId=51';
|
||||||
|
|
||||||
|
describe('проверка Excel-файла', () => {
|
||||||
|
it('POST multipart: файл в поле file, Content-Type не задаём (boundary ставит браузер)', async () => {
|
||||||
|
const body = { total_rows: 0, valid_rows: 0, invalid_rows: 0, rows: [], notes: [] };
|
||||||
|
const client = createFakeHttpClient([{ match: '/Import/StroyBot/Reports', response: { body } }]);
|
||||||
|
const file = new File(['xlsx'], 'отчёт.xlsx');
|
||||||
|
|
||||||
|
expect(await checkExcelFile(client, URL, file)).toEqual(body);
|
||||||
|
|
||||||
|
const sent = client.requests[0].request;
|
||||||
|
expect(sent.method).toEqual('POST');
|
||||||
|
expect(sent.headers).toEqual({ accept: '*/*' });
|
||||||
|
expect(sent.body).toBeInstanceOf(FormData);
|
||||||
|
expect(((sent.body as FormData).get('file') as File).name).toEqual('отчёт.xlsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка сервера пробрасывается с текстом ответа', async () => {
|
||||||
|
const client = createFakeHttpClient([
|
||||||
|
{ match: '/Import/StroyBot/Reports', response: { status: 400, body: 'неверный формат' } }
|
||||||
|
]);
|
||||||
|
await expect(checkExcelFile(client, URL, new File(['x'], 'a.xlsx'))).rejects.toThrow('неверный формат');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ensureOk, IHttpClient } from '../../services/http/IHttpClient';
|
||||||
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
|
|
||||||
|
/** Имя поля формы с файлом — как в Swagger эндпоинтов. */
|
||||||
|
const FILE_FIELD = 'file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка Excel-файла на сервере. Эндпоинт ничего не сохраняет: разбирает строки, сопоставляет значения
|
||||||
|
* со справочниками (ids) и возвращает ошибки по строкам. Данные по результату записывает фронт.
|
||||||
|
* Content-Type не задаём: для FormData браузер сам ставит multipart/form-data с boundary.
|
||||||
|
*/
|
||||||
|
export async function checkExcelFile(api: IHttpClient, url: string, file: File): Promise<IExcelImportCheck> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append(FILE_FIELD, file, file.name);
|
||||||
|
const response = await ensureOk(
|
||||||
|
await api.fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { accept: '*/*' },
|
||||||
|
body: form
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return (await response.json()) as IExcelImportCheck;
|
||||||
|
}
|
||||||
+6
-6
@@ -1,14 +1,14 @@
|
|||||||
import { canImportFile, columnsOf, isExcelFileName, issuesByColumn, visibleRows } from './reportImportRules';
|
import { canImportFile, columnsOf, isExcelFileName, issuesByColumn, visibleRows } from './excelImportRules';
|
||||||
import { IReportImportCheck, IReportImportRow } from './reportImportTypes';
|
import { IExcelImportCheck, IExcelImportRow } from './excelImportTypes';
|
||||||
|
|
||||||
// Строки — по ответу сервера на тестовый файл проекта 51 (сокращённо).
|
// Строки — по ответу сервера на тестовый файл проекта 51 (сокращённо).
|
||||||
const row = (
|
const row = (
|
||||||
num: number,
|
num: number,
|
||||||
values: IReportImportRow['values'],
|
values: IExcelImportRow['values'],
|
||||||
issues: IReportImportRow['issues'] = []
|
issues: IExcelImportRow['issues'] = []
|
||||||
): IReportImportRow => ({ row: num, valid: issues.length === 0, values, ids: {}, issues });
|
): IExcelImportRow => ({ row: num, valid: issues.length === 0, values, ids: {}, issues });
|
||||||
|
|
||||||
const check = (rows: IReportImportRow[]): IReportImportCheck => ({
|
const check = (rows: IExcelImportRow[]): IExcelImportCheck => ({
|
||||||
kind: 'reports',
|
kind: 'reports',
|
||||||
file_name: 'test.xlsx',
|
file_name: 'test.xlsx',
|
||||||
project_id: 51,
|
project_id: 51,
|
||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
import { IReportImportCheck, IReportImportRow } from './reportImportTypes';
|
import { IExcelImportCheck, IExcelImportRow } from './excelImportTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Колонки таблицы — в порядке, в каком их прислал сервер (это порядок колонок файла).
|
* Колонки таблицы — в порядке, в каком их прислал сервер (это порядок колонок файла).
|
||||||
* Колонка, которая есть только в ошибках (её нет в values), добавляется в конец — чтобы ошибка не потерялась.
|
* Колонка, которая есть только в ошибках (её нет в values), добавляется в конец — чтобы ошибка не потерялась.
|
||||||
*/
|
*/
|
||||||
export function columnsOf(check: IReportImportCheck): string[] {
|
export function columnsOf(check: IExcelImportCheck): string[] {
|
||||||
const columns: string[] = [];
|
const columns: string[] = [];
|
||||||
const add = (column: string): void => {
|
const add = (column: string): void => {
|
||||||
if (columns.indexOf(column) === -1) columns.push(column);
|
if (columns.indexOf(column) === -1) columns.push(column);
|
||||||
@@ -15,7 +15,7 @@ export function columnsOf(check: IReportImportCheck): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ошибки строки по колонкам: одна колонка может иметь несколько сообщений. */
|
/** Ошибки строки по колонкам: одна колонка может иметь несколько сообщений. */
|
||||||
export function issuesByColumn(row: IReportImportRow): Map<string, string[]> {
|
export function issuesByColumn(row: IExcelImportRow): Map<string, string[]> {
|
||||||
const result = new Map<string, string[]>();
|
const result = new Map<string, string[]>();
|
||||||
(row.issues || []).forEach((issue) => {
|
(row.issues || []).forEach((issue) => {
|
||||||
const messages = result.get(issue.column) ?? [];
|
const messages = result.get(issue.column) ?? [];
|
||||||
@@ -25,12 +25,12 @@ export function issuesByColumn(row: IReportImportRow): Map<string, string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Строки для показа: все или только с ошибками. */
|
/** Строки для показа: все или только с ошибками. */
|
||||||
export function visibleRows(check: IReportImportCheck, onlyInvalid: boolean): IReportImportRow[] {
|
export function visibleRows(check: IExcelImportCheck, onlyInvalid: boolean): IExcelImportRow[] {
|
||||||
return onlyInvalid ? check.rows.filter((row) => !row.valid) : check.rows;
|
return onlyInvalid ? check.rows.filter((row) => !row.valid) : check.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Импортировать можно, только если в файле есть строки и ни одной с ошибкой. */
|
/** Импортировать можно, только если в файле есть строки и ни одной с ошибкой. */
|
||||||
export function canImportFile(check: IReportImportCheck): boolean {
|
export function canImportFile(check: IExcelImportCheck): boolean {
|
||||||
return check.total_rows > 0 && check.invalid_rows === 0;
|
return check.total_rows > 0 && check.invalid_rows === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
+10
-9
@@ -1,27 +1,28 @@
|
|||||||
// Ответ проверки Excel-файла отчётов: POST /Import/StroyBot/Reports?projectId= (multipart, поле file).
|
// Ответ проверки Excel-файла: POST /Import/StroyBot/{Reports|Works}?projectId= (multipart, поле file).
|
||||||
// Эндпоинт ничего не сохраняет — только разбирает файл и сопоставляет значения со справочниками.
|
// Эндпоинты ничего не сохраняют — только разбирают файл и сопоставляют значения со справочниками.
|
||||||
// Имена полей — как в ответе API (snake_case), поэтому не переименовываем.
|
// Имена полей — как в ответе API (snake_case), поэтому не переименовываем.
|
||||||
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
/** Ошибка в ячейке: колонка — заголовок колонки файла («Профессия», «Техника»…). */
|
/** Ошибка в ячейке: колонка — заголовок колонки файла («Профессия», «Вид работы»…). */
|
||||||
export interface IReportImportIssue {
|
export interface IExcelImportIssue {
|
||||||
column: string;
|
column: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Строка файла после проверки. */
|
/** Строка файла после проверки. */
|
||||||
export interface IReportImportRow {
|
export interface IExcelImportRow {
|
||||||
/** Номер строки в Excel (заголовок — строка 1). */
|
/** Номер строки в Excel (заголовок — строка 1). */
|
||||||
row: number;
|
row: number;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
/** Значения ячеек по заголовкам колонок, как их прочитал сервер. Пустая ячейка — null. */
|
/** Значения ячеек по заголовкам колонок, как их прочитал сервер. Пустая ячейка — null. */
|
||||||
values: Record<string, string | null>;
|
values: Record<string, string | null>;
|
||||||
/** Во что сервер сопоставил значения (id объектов, работы, компании…). Нам нужно только для отладки. */
|
/** Во что сервер сопоставил значения (id объектов, работ, компаний…) — по ним фронт записывает данные. */
|
||||||
ids: Record<string, unknown>;
|
ids: Record<string, unknown>;
|
||||||
issues: IReportImportIssue[];
|
issues: IExcelImportIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IReportImportCheck {
|
export interface IExcelImportCheck {
|
||||||
|
/** Что проверялось: 'reports' / 'works'. */
|
||||||
kind: string;
|
kind: string;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
project_id: number;
|
project_id: number;
|
||||||
@@ -29,6 +30,6 @@ export interface IReportImportCheck {
|
|||||||
total_rows: number;
|
total_rows: number;
|
||||||
valid_rows: number;
|
valid_rows: number;
|
||||||
invalid_rows: number;
|
invalid_rows: number;
|
||||||
rows: IReportImportRow[];
|
rows: IExcelImportRow[];
|
||||||
notes: string[];
|
notes: string[];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { deepestObjectId, idNumber, textValue, toNumber } from './excelImportValues';
|
||||||
|
import { IExcelImportRow } from './excelImportTypes';
|
||||||
|
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
const row = (values: IExcelImportRow['values'], ids: IExcelImportRow['ids']): IExcelImportRow => ({
|
||||||
|
row: 2,
|
||||||
|
valid: true,
|
||||||
|
values,
|
||||||
|
ids,
|
||||||
|
issues: []
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('значения строки Excel', () => {
|
||||||
|
it('число: запятая как разделитель, пусто → undefined', () => {
|
||||||
|
expect(toNumber('5,5')).toEqual(5.5);
|
||||||
|
expect(toNumber('11')).toEqual(11);
|
||||||
|
expect(toNumber('')).toBeUndefined();
|
||||||
|
expect(toNumber(null)).toBeUndefined();
|
||||||
|
expect(toNumber('abc')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('id: только число; массив (неоднозначно) и отсутствие → undefined', () => {
|
||||||
|
const item = row({}, { 'Ед.изм.': 37, 'Профессия': [180, 360] });
|
||||||
|
expect(idNumber(item, 'Ед.изм.')).toEqual(37);
|
||||||
|
expect(idNumber(item, 'Профессия')).toBeUndefined();
|
||||||
|
expect(idNumber(item, 'Техника')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('текст: без краевых пробелов, пусто → undefined', () => {
|
||||||
|
const item = row({ 'Вид работ': ' Арматура ', 'Комментарий': ' ', 'Объект 3': null }, {});
|
||||||
|
expect(textValue(item, 'Вид работ')).toEqual('Арматура');
|
||||||
|
expect(textValue(item, 'Комментарий')).toBeUndefined();
|
||||||
|
expect(textValue(item, 'Объект 3')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('объект — самый глубокий уровень, даже если сервер прислал только его', () => {
|
||||||
|
const columns = ['Объект 1', 'Объект 2', 'Объект 3'];
|
||||||
|
expect(deepestObjectId(row({}, { 'Объект 1': 1516, 'Объект 2': 1517 }), columns)).toEqual(1517);
|
||||||
|
expect(deepestObjectId(row({}, { 'Объект 2': 1517 }), columns)).toEqual(1517);
|
||||||
|
expect(deepestObjectId(row({}, {}), columns)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { IExcelImportRow } from './excelImportTypes';
|
||||||
|
|
||||||
|
// Чтение значений строки из ответа проверки — общее для импорта отчётов и работ.
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
/** «5», «5,5» → число; пусто или не число → undefined. */
|
||||||
|
export function toNumber(value: string | null | undefined): number | undefined {
|
||||||
|
if (value === null || value === undefined || value.trim() === '') return undefined;
|
||||||
|
const result = Number(value.replace(',', '.'));
|
||||||
|
return isNaN(result) ? undefined : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Id, который сервер сопоставил ячейке; не число (нет, массив при неоднозначности) → undefined. */
|
||||||
|
export function idNumber(row: IExcelImportRow, column: string): number | undefined {
|
||||||
|
const id = row.ids[column];
|
||||||
|
return typeof id === 'number' ? id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Значение ячейки без краевых пробелов; пустое → undefined. */
|
||||||
|
export function textValue(row: IExcelImportRow, column: string): string | undefined {
|
||||||
|
return row.values[column]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Самый глубокий заполненный уровень объекта (Объект 3 → 2 → 1). */
|
||||||
|
export function deepestObjectId(row: IExcelImportRow, objectColumns: readonly string[]): number | undefined {
|
||||||
|
const ids = objectColumns.map((column) => idNumber(row, column)).filter((id) => id !== undefined);
|
||||||
|
return ids[ids.length - 1];
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Общие тексты импорта из Excel: выбор файла и результат проверки.
|
||||||
|
|
||||||
|
export const EXCEL_IMPORT_TEXTS = {
|
||||||
|
chooseFile: 'Выбрать файл',
|
||||||
|
chooseAnotherFile: 'Выбрать другой файл',
|
||||||
|
selectedFile: (name: string): string => `Выбран файл: ${name}`,
|
||||||
|
checking: 'Проверяем файл…',
|
||||||
|
wrongExtension: 'Нужен файл Excel в формате .xlsx',
|
||||||
|
checkError: (details: string): string => `Не удалось проверить файл: ${details}`,
|
||||||
|
summary: {
|
||||||
|
file: (name: string): string => `Файл: ${name}`,
|
||||||
|
total: (count: number): string => `Строк: ${count}`,
|
||||||
|
valid: (count: number): string => `Без ошибок: ${count}`,
|
||||||
|
invalid: (count: number): string => `С ошибками: ${count}`
|
||||||
|
},
|
||||||
|
emptyFile: 'В файле нет строк с данными.',
|
||||||
|
readyToImport: 'Ошибок нет — файл готов к импорту.',
|
||||||
|
fixInExcel: 'Исправьте ошибки в Excel и загрузите файл снова.',
|
||||||
|
onlyInvalid: 'Только строки с ошибками',
|
||||||
|
columns: {
|
||||||
|
row: 'Строка',
|
||||||
|
issues: 'Ошибки'
|
||||||
|
},
|
||||||
|
notesTitle: 'Примечания',
|
||||||
|
empty: '—'
|
||||||
|
} as const;
|
||||||
+18
-11
@@ -1,26 +1,32 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useAppServices } from '../../app/AppServicesContext';
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
import { checkReportFile } from './reportImportRepository';
|
import { checkExcelFile } from './excelImportRepository';
|
||||||
import { IReportImportCheck } from './reportImportTypes';
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
import { REPORT_IMPORT_TEXTS } from './texts';
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
export type ReportFileCheckStatus =
|
export type ExcelFileCheckStatus =
|
||||||
| { kind: 'idle' }
|
| { kind: 'idle' }
|
||||||
| { kind: 'checking' }
|
| { kind: 'checking' }
|
||||||
| { kind: 'error'; message: string }
|
| { kind: 'error'; message: string }
|
||||||
| { kind: 'done'; check: IReportImportCheck };
|
| { kind: 'done'; check: IExcelImportCheck };
|
||||||
|
|
||||||
|
/** Адрес эндпоинта проверки для раздела (отчёты, работы). */
|
||||||
|
export type CheckUrlBuilder = (apiBaseUrl: string, projectId: number) => string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверка Excel-файла на сервере. Сервер ничего не сохраняет, поэтому проверку можно
|
* Проверка Excel-файла на сервере. Сервер ничего не сохраняет, поэтому проверку можно
|
||||||
* запускать сразу при выборе файла. reset — сбросить результат (файл не Excel).
|
* запускать сразу при выборе файла. reset — сбросить результат (файл не Excel).
|
||||||
*/
|
*/
|
||||||
export function useReportFileCheck(projectId: number | undefined): {
|
export function useExcelFileCheck(
|
||||||
status: ReportFileCheckStatus;
|
projectId: number | undefined,
|
||||||
|
checkUrl: CheckUrlBuilder
|
||||||
|
): {
|
||||||
|
status: ExcelFileCheckStatus;
|
||||||
checkFile: (file: File) => Promise<void>;
|
checkFile: (file: File) => Promise<void>;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
} {
|
} {
|
||||||
const services = useAppServices();
|
const services = useAppServices();
|
||||||
const [status, setStatus] = React.useState<ReportFileCheckStatus>({ kind: 'idle' });
|
const [status, setStatus] = React.useState<ExcelFileCheckStatus>({ kind: 'idle' });
|
||||||
const isMountedRef = React.useRef<boolean>(true);
|
const isMountedRef = React.useRef<boolean>(true);
|
||||||
|
|
||||||
React.useEffect(
|
React.useEffect(
|
||||||
@@ -34,12 +40,13 @@ export function useReportFileCheck(projectId: number | undefined): {
|
|||||||
if (projectId === undefined) return;
|
if (projectId === undefined) return;
|
||||||
setStatus({ kind: 'checking' });
|
setStatus({ kind: 'checking' });
|
||||||
try {
|
try {
|
||||||
const check = await checkReportFile(services.tnsuApi, services.settings.apiBaseUrl, projectId, file);
|
const url = checkUrl(services.settings.apiBaseUrl, projectId);
|
||||||
|
const check = await checkExcelFile(services.tnsuApi, url, file);
|
||||||
if (isMountedRef.current) setStatus({ kind: 'done', check });
|
if (isMountedRef.current) setStatus({ kind: 'done', check });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка проверки файла отчёта:', error);
|
console.error('Ошибка проверки Excel-файла:', error);
|
||||||
const details = error instanceof Error ? error.message : String(error);
|
const details = error instanceof Error ? error.message : String(error);
|
||||||
if (isMountedRef.current) setStatus({ kind: 'error', message: REPORT_IMPORT_TEXTS.checkError(details) });
|
if (isMountedRef.current) setStatus({ kind: 'error', message: EXCEL_IMPORT_TEXTS.checkError(details) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
// В файле 18 колонок — таблица прокручивается внутри рамки, а не растягивает страницу.
|
|
||||||
$issues-min-width: 260px;
|
|
||||||
$cell-max-width: 280px;
|
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -17,146 +13,6 @@ $cell-max-width: 280px;
|
|||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileField {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $sp-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hiddenInput {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonContent {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $sp-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: $sp-5;
|
|
||||||
background: $white;
|
|
||||||
border: 1px solid $gray-200;
|
|
||||||
border-radius: $r-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $sp-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summaryRow {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: $sp-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileName {
|
|
||||||
margin-right: $sp-2;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $navy-900;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableSection {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $sp-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableWrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
background: $white;
|
|
||||||
border: 1px solid $gray-200;
|
|
||||||
border-radius: $r-md;
|
|
||||||
box-shadow: $sh-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: $fs-sm;
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: $sp-2 $sp-3;
|
|
||||||
border-bottom: 1px solid $gray-200;
|
|
||||||
background: $gray-50;
|
|
||||||
font-size: $fs-caption;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
text-align: left;
|
|
||||||
text-transform: uppercase;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: $gray-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
max-width: $cell-max-width;
|
|
||||||
padding: $sp-2 $sp-3;
|
|
||||||
border-bottom: 1px solid $gray-100;
|
|
||||||
vertical-align: top;
|
|
||||||
color: $gray-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr:last-child td {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowNumber {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: $gray-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invalidRow .rowNumber {
|
|
||||||
border-left: 3px solid $red-500;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $red-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invalidCell {
|
|
||||||
background: $red-50;
|
|
||||||
box-shadow: inset 0 0 0 1px $red-500;
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
.issues {
|
|
||||||
min-width: $issues-min-width;
|
|
||||||
font-size: $fs-label;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: $red-500;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
color: $gray-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes {
|
|
||||||
font-size: $fs-sm;
|
|
||||||
color: $gray-700;
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: $sp-2 0 0;
|
|
||||||
padding-left: $sp-4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionLabel {
|
|
||||||
margin: 0;
|
|
||||||
font-family: $font-serif;
|
|
||||||
font-size: $fs-section-title;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $navy-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outcomeLine {
|
.outcomeLine {
|
||||||
margin: 0 0 $sp-2;
|
margin: 0 0 $sp-2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Alert, Spinner } from '@tnsu/ui-kit';
|
import { Alert } from '@tnsu/ui-kit';
|
||||||
import { useProject } from '../../app/project/ProjectContext';
|
import { useProject } from '../../app/project/ProjectContext';
|
||||||
import { isSectionAvailable } from '../../app/project/projectRules';
|
import { isSectionAvailable } from '../../app/project/projectRules';
|
||||||
import { TreePageHeader } from '../_tree/TreePageHeader';
|
import { TreePageHeader } from '../_tree/TreePageHeader';
|
||||||
import { canImportFile, isExcelFileName } from './reportImportRules';
|
import { ExcelImportSection } from '../_excelImport/ExcelImportSection';
|
||||||
import { useReportFileCheck } from './useReportFileCheck';
|
import { reportImportUrl } from './reportImportRepository';
|
||||||
import { useReportImport } from './useReportImport';
|
import { useReportImport } from './useReportImport';
|
||||||
import { ReportFileField } from './ReportFileField';
|
|
||||||
import { ReportImportResult } from './ReportImportResult';
|
|
||||||
import { ReportImportStep } from './ReportImportStep';
|
import { ReportImportStep } from './ReportImportStep';
|
||||||
import { REPORT_IMPORT_TEXTS } from './texts';
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
import styles from './ReportImportPage.module.scss';
|
import styles from './ReportImportPage.module.scss';
|
||||||
@@ -19,26 +17,7 @@ import styles from './ReportImportPage.module.scss';
|
|||||||
*/
|
*/
|
||||||
export const ReportImportPage: React.FC = () => {
|
export const ReportImportPage: React.FC = () => {
|
||||||
const { currentProject } = useProject();
|
const { currentProject } = useProject();
|
||||||
const { status, checkFile, reset } = useReportFileCheck(currentProject?.id);
|
|
||||||
const importer = useReportImport(currentProject?.id);
|
const importer = useReportImport(currentProject?.id);
|
||||||
const [selectedFileName, setSelectedFileName] = React.useState<string | undefined>(undefined);
|
|
||||||
const [fileError, setFileError] = React.useState<string>('');
|
|
||||||
const isChecking = status.kind === 'checking';
|
|
||||||
// Пока идёт запись, новый файл не выбрать: иначе результат записи потеряется с экрана.
|
|
||||||
const isBusy = isChecking || importer.status.kind === 'saving';
|
|
||||||
|
|
||||||
const selectFile = (file: File): void => {
|
|
||||||
importer.reset();
|
|
||||||
if (!isExcelFileName(file.name)) {
|
|
||||||
reset();
|
|
||||||
setSelectedFileName(undefined);
|
|
||||||
setFileError(REPORT_IMPORT_TEXTS.wrongExtension);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedFileName(file.name);
|
|
||||||
setFileError('');
|
|
||||||
checkFile(file).catch(() => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderContent = (): React.ReactNode => {
|
const renderContent = (): React.ReactNode => {
|
||||||
if (!currentProject) return <Alert variant="info">{REPORT_IMPORT_TEXTS.noProject}</Alert>;
|
if (!currentProject) return <Alert variant="info">{REPORT_IMPORT_TEXTS.noProject}</Alert>;
|
||||||
@@ -48,26 +27,21 @@ export const ReportImportPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className={styles.intro}>{REPORT_IMPORT_TEXTS.intro}</p>
|
<p className={styles.intro}>{REPORT_IMPORT_TEXTS.intro}</p>
|
||||||
<ReportFileField selectedFileName={selectedFileName} disabled={isBusy} onSelect={selectFile} />
|
<ExcelImportSection
|
||||||
{fileError && <Alert variant="err">{fileError}</Alert>}
|
projectId={currentProject.id}
|
||||||
{isChecking && <Spinner size="small" label={REPORT_IMPORT_TEXTS.checking} />}
|
checkUrl={reportImportUrl}
|
||||||
{status.kind === 'error' && <Alert variant="err">{status.message}</Alert>}
|
isLocked={importer.status.kind === 'saving'}
|
||||||
{status.kind === 'done' && (
|
onFileChange={importer.reset}
|
||||||
<ReportImportResult
|
renderImport={(check) => (
|
||||||
check={status.check}
|
|
||||||
actions={
|
|
||||||
canImportFile(status.check) && (
|
|
||||||
<ReportImportStep
|
<ReportImportStep
|
||||||
check={status.check}
|
check={check}
|
||||||
projectId={currentProject.id}
|
projectId={currentProject.id}
|
||||||
projectTitle={currentProject.title}
|
projectTitle={currentProject.title}
|
||||||
status={importer.status}
|
status={importer.status}
|
||||||
onImport={importer.importReports}
|
onImport={importer.importReports}
|
||||||
/>
|
/>
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Alert } from '@tnsu/ui-kit';
|
import { Alert } from '@tnsu/ui-kit';
|
||||||
import { IReportImportCheck } from './reportImportTypes';
|
import { IExcelImportCheck } from '../_excelImport/excelImportTypes';
|
||||||
import { IImportedReport, tryBuildImportedReports } from './reportImportBuilder';
|
import { IImportedReport, tryBuildImportedReports } from './reportImportBuilder';
|
||||||
import { ReportImportStatus } from './useReportImport';
|
import { ReportImportStatus } from './useReportImport';
|
||||||
import { ReportImportActions } from './ReportImportActions';
|
import { ReportImportActions } from './ReportImportActions';
|
||||||
import { ReportImportOutcome } from './ReportImportOutcome';
|
import { ReportImportOutcome } from './ReportImportOutcome';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
check: IReportImportCheck;
|
check: IExcelImportCheck;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
projectTitle: string;
|
projectTitle: string;
|
||||||
status: ReportImportStatus;
|
status: ReportImportStatus;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildImportedReports, toIsoDate, toNumber } from './reportImportBuilder';
|
import { buildImportedReports, toIsoDate } from './reportImportBuilder';
|
||||||
import { IReportImportCheck, IReportImportRow } from './reportImportTypes';
|
import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes';
|
||||||
|
|
||||||
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ function makeRow(
|
|||||||
row: number,
|
row: number,
|
||||||
values: Record<string, string | null>,
|
values: Record<string, string | null>,
|
||||||
ids: Record<string, unknown>
|
ids: Record<string, unknown>
|
||||||
): IReportImportRow {
|
): IExcelImportRow {
|
||||||
return {
|
return {
|
||||||
row,
|
row,
|
||||||
valid: true,
|
valid: true,
|
||||||
@@ -37,7 +37,7 @@ function makeRow(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCheck(rows: IReportImportRow[]): IReportImportCheck {
|
function makeCheck(rows: IExcelImportRow[]): IExcelImportCheck {
|
||||||
return {
|
return {
|
||||||
kind: 'reports',
|
kind: 'reports',
|
||||||
file_name: 'test.xlsx',
|
file_name: 'test.xlsx',
|
||||||
@@ -154,10 +154,4 @@ describe('разбор значений Excel', () => {
|
|||||||
expect(toIsoDate(null)).toBeUndefined();
|
expect(toIsoDate(null)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('число: запятая как разделитель, пусто → undefined', () => {
|
|
||||||
expect(toNumber('5,5')).toEqual(5.5);
|
|
||||||
expect(toNumber('11')).toEqual(11);
|
|
||||||
expect(toNumber('')).toBeUndefined();
|
|
||||||
expect(toNumber(null)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IReportDataFields, IReportDataNode, IReportPayload } from '../report/reportTypes';
|
import { IReportDataFields, IReportDataNode, IReportPayload } from '../report/reportTypes';
|
||||||
import { HOURS_TYPES } from '../report/reportConstants';
|
import { HOURS_TYPES } from '../report/reportConstants';
|
||||||
import { IReportImportCheck, IReportImportRow } from './reportImportTypes';
|
import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes';
|
||||||
|
import { deepestObjectId, idNumber, textValue, toNumber } from '../_excelImport/excelImportValues';
|
||||||
import { IMPORT_COLUMNS as COL } from './reportImportColumns';
|
import { IMPORT_COLUMNS as COL } from './reportImportColumns';
|
||||||
import { REPORT_IMPORT_TEXTS } from './texts';
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
@@ -21,33 +22,11 @@ export function toIsoDate(value: string | null): string | undefined {
|
|||||||
return match ? `${match[3]}-${match[2]}-${match[1]}` : undefined;
|
return match ? `${match[3]}-${match[2]}-${match[1]}` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** «5», «5,5» → число; пусто или не число → undefined. */
|
function buildPayload(row: IExcelImportRow, projectId: number): IImportedReport['payload'] {
|
||||||
export function toNumber(value: string | null | undefined): number | undefined {
|
|
||||||
if (value === null || value === undefined || value.trim() === '') return undefined;
|
|
||||||
const result = Number(value.replace(',', '.'));
|
|
||||||
return isNaN(result) ? undefined : result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function idNumber(row: IReportImportRow, column: string): number | undefined {
|
|
||||||
const id = row.ids[column];
|
|
||||||
return typeof id === 'number' ? id : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function textValue(row: IReportImportRow, column: string): string | undefined {
|
|
||||||
return row.values[column] || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Объект отчёта — самый глубокий заполненный уровень (Объект 3 → 2 → 1). */
|
|
||||||
function deepestObjectId(row: IReportImportRow): number | undefined {
|
|
||||||
const ids = COL.objects.map((column) => idNumber(row, column)).filter((id) => id !== undefined);
|
|
||||||
return ids[ids.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPayload(row: IReportImportRow, projectId: number): IImportedReport['payload'] {
|
|
||||||
const reportDate = toIsoDate(row.values[COL.date]);
|
const reportDate = toIsoDate(row.values[COL.date]);
|
||||||
const companyId = idNumber(row, COL.company);
|
const companyId = idNumber(row, COL.company);
|
||||||
const shiftId = idNumber(row, COL.shift);
|
const shiftId = idNumber(row, COL.shift);
|
||||||
const objectId = deepestObjectId(row);
|
const objectId = deepestObjectId(row, COL.objects);
|
||||||
// Сервер уже проверил строку; если поля всё же нет — не пишем отчёт без шапки.
|
// Сервер уже проверил строку; если поля всё же нет — не пишем отчёт без шапки.
|
||||||
if (!reportDate || !companyId || !shiftId || !objectId) {
|
if (!reportDate || !companyId || !shiftId || !objectId) {
|
||||||
throw new Error(REPORT_IMPORT_TEXTS.build.missingHeader(row.row));
|
throw new Error(REPORT_IMPORT_TEXTS.build.missingHeader(row.row));
|
||||||
@@ -63,7 +42,7 @@ function buildPayload(row: IReportImportRow, projectId: number): IImportedReport
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Персонал и техника строки (порядок как в форме: сначала персонал). */
|
/** Персонал и техника строки (порядок как в форме: сначала персонал). */
|
||||||
function buildResources(row: IReportImportRow, hoursType: string): IReportDataFields[] {
|
function buildResources(row: IExcelImportRow, hoursType: string): IReportDataFields[] {
|
||||||
const common = { cree9_count: toNumber(row.values[COL.count]), cree9_hours: toNumber(row.values[COL.hours]) };
|
const common = { cree9_count: toNumber(row.values[COL.count]), cree9_hours: toNumber(row.values[COL.hours]) };
|
||||||
const resources: IReportDataFields[] = [];
|
const resources: IReportDataFields[] = [];
|
||||||
const professionId = idNumber(row, COL.profession);
|
const professionId = idNumber(row, COL.profession);
|
||||||
@@ -80,7 +59,7 @@ function buildResources(row: IReportImportRow, hoursType: string): IReportDataFi
|
|||||||
* и комментарий строки — ей: в форме это причина простоя. Пустой комментарий импорт не блокирует —
|
* и комментарий строки — ей: в форме это причина простоя. Пустой комментарий импорт не блокирует —
|
||||||
* сервер такой файл принимает; обязательность из формы сработает, только когда отчёт будут править.
|
* сервер такой файл принимает; обязательность из формы сработает, только когда отчёт будут править.
|
||||||
*/
|
*/
|
||||||
function buildRowNodes(row: IReportImportRow): { work: IReportDataNode; general: IReportDataFields[] } {
|
function buildRowNodes(row: IExcelImportRow): { work: IReportDataNode; general: IReportDataFields[] } {
|
||||||
const jobId = row.ids[COL.job];
|
const jobId = row.ids[COL.job];
|
||||||
if (typeof jobId !== 'string') throw new Error(REPORT_IMPORT_TEXTS.build.missingJob(row.row));
|
if (typeof jobId !== 'string') throw new Error(REPORT_IMPORT_TEXTS.build.missingJob(row.row));
|
||||||
|
|
||||||
@@ -103,7 +82,7 @@ function buildRowNodes(row: IReportImportRow): { work: IReportDataNode; general:
|
|||||||
export type ImportBuildResult = { kind: 'ok'; reports: IImportedReport[] } | { kind: 'error'; message: string };
|
export type ImportBuildResult = { kind: 'ok'; reports: IImportedReport[] } | { kind: 'error'; message: string };
|
||||||
|
|
||||||
/** Сборка без исключений — для показа на экране до записи. */
|
/** Сборка без исключений — для показа на экране до записи. */
|
||||||
export function tryBuildImportedReports(check: IReportImportCheck, projectId: number): ImportBuildResult {
|
export function tryBuildImportedReports(check: IExcelImportCheck, projectId: number): ImportBuildResult {
|
||||||
try {
|
try {
|
||||||
return { kind: 'ok', reports: buildImportedReports(check, projectId) };
|
return { kind: 'ok', reports: buildImportedReports(check, projectId) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -125,7 +104,7 @@ interface IReportDraft {
|
|||||||
* Строки с одинаковыми датой, компанией, сменой и объектом — один отчёт.
|
* Строки с одинаковыми датой, компанией, сменой и объектом — один отчёт.
|
||||||
* Порядок узлов как в форме: работы, затем общий персонал, затем общая техника.
|
* Порядок узлов как в форме: работы, затем общий персонал, затем общая техника.
|
||||||
*/
|
*/
|
||||||
export function buildImportedReports(check: IReportImportCheck, projectId: number): IImportedReport[] {
|
export function buildImportedReports(check: IExcelImportCheck, projectId: number): IImportedReport[] {
|
||||||
const drafts = new Map<string, IReportDraft>();
|
const drafts = new Map<string, IReportDraft>();
|
||||||
check.rows.forEach((row) => {
|
check.rows.forEach((row) => {
|
||||||
const payload = buildPayload(row, projectId);
|
const payload = buildPayload(row, projectId);
|
||||||
|
|||||||
@@ -1,31 +1,7 @@
|
|||||||
import { reportImportUrl, checkReportFile } from './reportImportRepository';
|
import { reportImportUrl } from './reportImportRepository';
|
||||||
import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
|
|
||||||
|
|
||||||
const API = 'https://api.tnsu.kz';
|
describe('импорт отчётов: адрес проверки', () => {
|
||||||
|
it('как в Swagger', () => {
|
||||||
describe('проверка файла отчётов', () => {
|
expect(reportImportUrl('https://api.tnsu.kz', 51)).toEqual('https://api.tnsu.kz/Import/StroyBot/Reports?projectId=51');
|
||||||
it('адрес — как в Swagger', () => {
|
|
||||||
expect(reportImportUrl(API, 51)).toEqual('https://api.tnsu.kz/Import/StroyBot/Reports?projectId=51');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('POST multipart: файл в поле file, Content-Type не задаём (boundary ставит браузер)', async () => {
|
|
||||||
const body = { total_rows: 0, valid_rows: 0, invalid_rows: 0, rows: [], notes: [] };
|
|
||||||
const client = createFakeHttpClient([{ match: '/Import/StroyBot/Reports', response: { body } }]);
|
|
||||||
const file = new File(['xlsx'], 'отчёт.xlsx');
|
|
||||||
|
|
||||||
expect(await checkReportFile(client, API, 51, file)).toEqual(body);
|
|
||||||
|
|
||||||
const sent = client.requests[0].request;
|
|
||||||
expect(sent.method).toEqual('POST');
|
|
||||||
expect(sent.headers).toEqual({ accept: '*/*' });
|
|
||||||
expect(sent.body).toBeInstanceOf(FormData);
|
|
||||||
expect(((sent.body as FormData).get('file') as File).name).toEqual('отчёт.xlsx');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ошибка сервера пробрасывается с текстом ответа', async () => {
|
|
||||||
const client = createFakeHttpClient([
|
|
||||||
{ match: '/Import/StroyBot/Reports', response: { status: 400, body: 'неверный формат' } }
|
|
||||||
]);
|
|
||||||
await expect(checkReportFile(client, API, 51, new File(['x'], 'a.xlsx'))).rejects.toThrow('неверный формат');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,3 @@
|
|||||||
import { ensureOk, IHttpClient } from '../../services/http/IHttpClient';
|
|
||||||
import { IReportImportCheck } from './reportImportTypes';
|
|
||||||
|
|
||||||
/** Имя поля формы с файлом — как в Swagger эндпоинта. */
|
|
||||||
const FILE_FIELD = 'file';
|
|
||||||
|
|
||||||
export function reportImportUrl(apiBaseUrl: string, projectId: number): string {
|
export function reportImportUrl(apiBaseUrl: string, projectId: number): string {
|
||||||
return `${apiBaseUrl}/Import/StroyBot/Reports?projectId=${projectId}`;
|
return `${apiBaseUrl}/Import/StroyBot/Reports?projectId=${projectId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверка Excel-файла отчётов. Эндпоинт ничего не сохраняет: разбирает строки, сопоставляет значения
|
|
||||||
* со справочниками (ids) и возвращает ошибки по строкам. Отчёты по результату записывает фронт.
|
|
||||||
* Content-Type не задаём: для FormData браузер сам ставит multipart/form-data с boundary.
|
|
||||||
*/
|
|
||||||
export async function checkReportFile(
|
|
||||||
api: IHttpClient,
|
|
||||||
apiBaseUrl: string,
|
|
||||||
projectId: number,
|
|
||||||
file: File
|
|
||||||
): Promise<IReportImportCheck> {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append(FILE_FIELD, file, file.name);
|
|
||||||
const response = await ensureOk(
|
|
||||||
await api.fetch(reportImportUrl(apiBaseUrl, projectId), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { accept: '*/*' },
|
|
||||||
body: form
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return (await response.json()) as IReportImportCheck;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ export const REPORT_IMPORT_TEXTS = {
|
|||||||
intro:
|
intro:
|
||||||
'Загрузите заполненную форму ежедневного отчёта (.xlsx) — файл сразу проверится. ' +
|
'Загрузите заполненную форму ежедневного отчёта (.xlsx) — файл сразу проверится. ' +
|
||||||
'Ошибки подсветятся в таблице; если ошибок нет, отчёты можно будет импортировать.',
|
'Ошибки подсветятся в таблице; если ошибок нет, отчёты можно будет импортировать.',
|
||||||
chooseFile: 'Выбрать файл',
|
|
||||||
chooseAnotherFile: 'Выбрать другой файл',
|
|
||||||
selectedFile: (name: string): string => `Выбран файл: ${name}`,
|
|
||||||
checking: 'Проверяем файл…',
|
|
||||||
wrongExtension: 'Нужен файл Excel в формате .xlsx',
|
|
||||||
checkError: (details: string): string => `Не удалось проверить файл: ${details}`,
|
|
||||||
import: {
|
import: {
|
||||||
button: (reportCount: number): string => `Импортировать (отчётов: ${reportCount})`,
|
button: (reportCount: number): string => `Импортировать (отчётов: ${reportCount})`,
|
||||||
saving: (savedCount: number, total: number): string => `Сохраняем отчёты… ${savedCount} из ${total}`,
|
saving: (savedCount: number, total: number): string => `Сохраняем отчёты… ${savedCount} из ${total}`,
|
||||||
@@ -35,21 +29,5 @@ export const REPORT_IMPORT_TEXTS = {
|
|||||||
build: {
|
build: {
|
||||||
missingHeader: (row: number): string => `Строка ${row}: не определены дата, компания, смена или объект.`,
|
missingHeader: (row: number): string => `Строка ${row}: не определены дата, компания, смена или объект.`,
|
||||||
missingJob: (row: number): string => `Строка ${row}: не определён вид работы.`
|
missingJob: (row: number): string => `Строка ${row}: не определён вид работы.`
|
||||||
},
|
}
|
||||||
summary: {
|
|
||||||
file: (name: string): string => `Файл: ${name}`,
|
|
||||||
total: (count: number): string => `Строк: ${count}`,
|
|
||||||
valid: (count: number): string => `Без ошибок: ${count}`,
|
|
||||||
invalid: (count: number): string => `С ошибками: ${count}`
|
|
||||||
},
|
|
||||||
emptyFile: 'В файле нет строк с данными.',
|
|
||||||
readyToImport: 'Ошибок нет — файл готов к импорту.',
|
|
||||||
fixInExcel: 'Исправьте ошибки в Excel и загрузите файл снова.',
|
|
||||||
onlyInvalid: 'Только строки с ошибками',
|
|
||||||
columns: {
|
|
||||||
row: 'Строка',
|
|
||||||
issues: 'Ошибки'
|
|
||||||
},
|
|
||||||
notesTitle: 'Примечания',
|
|
||||||
empty: '—'
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user