diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportResult.tsx b/src/webparts/stroyBot/pages/_excelImport/ExcelCheckResult.tsx similarity index 55% rename from src/webparts/stroyBot/pages/reportImport/ReportImportResult.tsx rename to src/webparts/stroyBot/pages/_excelImport/ExcelCheckResult.tsx index ed9ea23..6b51d02 100644 --- a/src/webparts/stroyBot/pages/reportImport/ReportImportResult.tsx +++ b/src/webparts/stroyBot/pages/_excelImport/ExcelCheckResult.tsx @@ -1,27 +1,27 @@ import * as React from 'react'; -import { IReportImportCheck } from './reportImportTypes'; -import { ReportImportSummary } from './ReportImportSummary'; -import { ReportImportTable } from './ReportImportTable'; -import { REPORT_IMPORT_TEXTS } from './texts'; -import styles from './ReportImportPage.module.scss'; +import { IExcelImportCheck } from './excelImportTypes'; +import { ExcelCheckSummary } from './ExcelCheckSummary'; +import { ExcelCheckTable } from './ExcelCheckTable'; +import { EXCEL_IMPORT_TEXTS } from './texts'; +import styles from './ExcelImport.module.scss'; interface Props { - check: IReportImportCheck; + check: IExcelImportCheck; /** Импорт — сразу под итогом проверки, чтобы кнопка не терялась под длинной таблицей. */ actions?: React.ReactNode; } /** Результат проверки файла: итог, импорт, таблица строк и примечания сервера. */ -export const ReportImportResult: React.FC = ({ check, actions }) => { +export const ExcelCheckResult: React.FC = ({ check, actions }) => { const notes = check.notes || []; return ( <> - + {actions} - + {notes.length > 0 && (
-

{REPORT_IMPORT_TEXTS.notesTitle}

+

{EXCEL_IMPORT_TEXTS.notesTitle}

    {notes.map((note, index) => (
  • {note}
  • diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportRow.tsx b/src/webparts/stroyBot/pages/_excelImport/ExcelCheckRow.tsx similarity index 71% rename from src/webparts/stroyBot/pages/reportImport/ReportImportRow.tsx rename to src/webparts/stroyBot/pages/_excelImport/ExcelCheckRow.tsx index 2ff7970..5cd60dc 100644 --- a/src/webparts/stroyBot/pages/reportImport/ReportImportRow.tsx +++ b/src/webparts/stroyBot/pages/_excelImport/ExcelCheckRow.tsx @@ -1,16 +1,16 @@ import * as React from 'react'; -import { issuesByColumn } from './reportImportRules'; -import { IReportImportRow } from './reportImportTypes'; -import { REPORT_IMPORT_TEXTS } from './texts'; -import styles from './ReportImportPage.module.scss'; +import { issuesByColumn } from './excelImportRules'; +import { IExcelImportRow } from './excelImportTypes'; +import { EXCEL_IMPORT_TEXTS } from './texts'; +import styles from './ExcelImport.module.scss'; interface Props { - row: IReportImportRow; + row: IExcelImportRow; columns: string[]; } /** Строка файла: ячейки с ошибкой подсвечены (сообщение — во всплывающей подсказке), справа — список ошибок. */ -export const ReportImportRow: React.FC = ({ row, columns }) => { +export const ExcelCheckRow: React.FC = ({ row, columns }) => { const issues = issuesByColumn(row); return ( @@ -20,7 +20,7 @@ export const ReportImportRow: React.FC = ({ row, columns }) => { const value = row.values?.[column]; return ( - {value || {REPORT_IMPORT_TEXTS.empty}} + {value || {EXCEL_IMPORT_TEXTS.empty}} ); })} diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportSummary.tsx b/src/webparts/stroyBot/pages/_excelImport/ExcelCheckSummary.tsx similarity index 57% rename from src/webparts/stroyBot/pages/reportImport/ReportImportSummary.tsx rename to src/webparts/stroyBot/pages/_excelImport/ExcelCheckSummary.tsx index c9fec60..d6d6a24 100644 --- a/src/webparts/stroyBot/pages/reportImport/ReportImportSummary.tsx +++ b/src/webparts/stroyBot/pages/_excelImport/ExcelCheckSummary.tsx @@ -1,18 +1,18 @@ import * as React from 'react'; import { Alert, Badge } from '@tnsu/ui-kit'; -import { canImportFile } from './reportImportRules'; -import { IReportImportCheck } from './reportImportTypes'; -import { REPORT_IMPORT_TEXTS } from './texts'; -import styles from './ReportImportPage.module.scss'; +import { canImportFile } from './excelImportRules'; +import { IExcelImportCheck } from './excelImportTypes'; +import { EXCEL_IMPORT_TEXTS } from './texts'; +import styles from './ExcelImport.module.scss'; -const TEXTS = REPORT_IMPORT_TEXTS.summary; +const TEXTS = EXCEL_IMPORT_TEXTS.summary; interface Props { - check: IReportImportCheck; + check: IExcelImportCheck; } /** Итог проверки: имя файла, счётчики строк и что делать дальше. */ -export const ReportImportSummary: React.FC = ({ check }) => { +export const ExcelCheckSummary: React.FC = ({ check }) => { const isEmpty = check.total_rows === 0; return (
    @@ -22,9 +22,9 @@ export const ReportImportSummary: React.FC = ({ check }) => { {TEXTS.valid(check.valid_rows)} {check.invalid_rows > 0 && {TEXTS.invalid(check.invalid_rows)}}
    - {isEmpty && {REPORT_IMPORT_TEXTS.emptyFile}} - {!isEmpty && canImportFile(check) && {REPORT_IMPORT_TEXTS.readyToImport}} - {check.invalid_rows > 0 && {REPORT_IMPORT_TEXTS.fixInExcel}} + {isEmpty && {EXCEL_IMPORT_TEXTS.emptyFile}} + {!isEmpty && canImportFile(check) && {EXCEL_IMPORT_TEXTS.readyToImport}} + {check.invalid_rows > 0 && {EXCEL_IMPORT_TEXTS.fixInExcel}} ); }; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportTable.tsx b/src/webparts/stroyBot/pages/_excelImport/ExcelCheckTable.tsx similarity index 62% rename from src/webparts/stroyBot/pages/reportImport/ReportImportTable.tsx rename to src/webparts/stroyBot/pages/_excelImport/ExcelCheckTable.tsx index 68edd9f..70d008e 100644 --- a/src/webparts/stroyBot/pages/reportImport/ReportImportTable.tsx +++ b/src/webparts/stroyBot/pages/_excelImport/ExcelCheckTable.tsx @@ -1,19 +1,19 @@ import * as React from 'react'; import { Checkbox } from '@tnsu/ui-kit'; -import { columnsOf, visibleRows } from './reportImportRules'; -import { IReportImportCheck } from './reportImportTypes'; -import { ReportImportRow } from './ReportImportRow'; -import { REPORT_IMPORT_TEXTS } from './texts'; -import styles from './ReportImportPage.module.scss'; +import { columnsOf, visibleRows } from './excelImportRules'; +import { IExcelImportCheck } from './excelImportTypes'; +import { ExcelCheckRow } from './ExcelCheckRow'; +import { EXCEL_IMPORT_TEXTS } from './texts'; +import styles from './ExcelImport.module.scss'; -const COLUMNS = REPORT_IMPORT_TEXTS.columns; +const COLUMNS = EXCEL_IMPORT_TEXTS.columns; interface Props { - check: IReportImportCheck; + check: IExcelImportCheck; } /** Таблица строк файла с фильтром «Только строки с ошибками». */ -export const ReportImportTable: React.FC = ({ check }) => { +export const ExcelCheckTable: React.FC = ({ check }) => { const [onlyInvalid, setOnlyInvalid] = React.useState(false); const columns = columnsOf(check); const rows = visibleRows(check, onlyInvalid); @@ -23,7 +23,7 @@ export const ReportImportTable: React.FC = ({ check }) => { return (
    {check.invalid_rows > 0 && ( - + )}
    @@ -38,7 +38,7 @@ export const ReportImportTable: React.FC = ({ check }) => { {rows.map((row) => ( - + ))}
    diff --git a/src/webparts/stroyBot/pages/reportImport/ReportFileField.tsx b/src/webparts/stroyBot/pages/_excelImport/ExcelFileField.tsx similarity index 70% rename from src/webparts/stroyBot/pages/reportImport/ReportFileField.tsx rename to src/webparts/stroyBot/pages/_excelImport/ExcelFileField.tsx index 4ad6655..4009daa 100644 --- a/src/webparts/stroyBot/pages/reportImport/ReportFileField.tsx +++ b/src/webparts/stroyBot/pages/_excelImport/ExcelFileField.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { Button, Icon } from '@tnsu/ui-kit'; -import { EXCEL_ACCEPT } from './reportImportRules'; -import { REPORT_IMPORT_TEXTS } from './texts'; -import styles from './ReportImportPage.module.scss'; +import { EXCEL_ACCEPT } from './excelImportRules'; +import { EXCEL_IMPORT_TEXTS } from './texts'; +import styles from './ExcelImport.module.scss'; interface Props { selectedFileName: string | undefined; @@ -14,7 +14,7 @@ interface Props { * Выбор Excel-файла — только выбор, без отправки. Скрытый input сбрасывается, * чтобы тот же файл можно было выбрать повторно. */ -export const ReportFileField: React.FC = ({ selectedFileName, disabled, onSelect }) => { +export const ExcelFileField: React.FC = ({ selectedFileName, disabled, onSelect }) => { const inputRef = React.useRef(null); const onChange = (event: React.ChangeEvent): void => { @@ -29,11 +29,11 @@ export const ReportFileField: React.FC = ({ selectedFileName, disabled, o {selectedFileName && ( - {REPORT_IMPORT_TEXTS.selectedFile(selectedFileName)} + {EXCEL_IMPORT_TEXTS.selectedFile(selectedFileName)} )}
    ); diff --git a/src/webparts/stroyBot/pages/_excelImport/ExcelImport.module.scss b/src/webparts/stroyBot/pages/_excelImport/ExcelImport.module.scss new file mode 100644 index 0000000..8d645d7 --- /dev/null +++ b/src/webparts/stroyBot/pages/_excelImport/ExcelImport.module.scss @@ -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; +} diff --git a/src/webparts/stroyBot/pages/_excelImport/ExcelImportSection.tsx b/src/webparts/stroyBot/pages/_excelImport/ExcelImportSection.tsx new file mode 100644 index 0000000..81adb30 --- /dev/null +++ b/src/webparts/stroyBot/pages/_excelImport/ExcelImportSection.tsx @@ -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 = ({ projectId, checkUrl, isLocked, onFileChange, renderImport }) => { + const { status, checkFile, reset } = useExcelFileCheck(projectId, checkUrl); + const [selectedFileName, setSelectedFileName] = React.useState(undefined); + const [fileError, setFileError] = React.useState(''); + 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 ( + <> + + {fileError && {fileError}} + {isChecking && } + {status.kind === 'error' && {status.message}} + {status.kind === 'done' && ( + + )} + + ); +}; diff --git a/src/webparts/stroyBot/pages/_excelImport/excelImportRepository.test.ts b/src/webparts/stroyBot/pages/_excelImport/excelImportRepository.test.ts new file mode 100644 index 0000000..33db972 --- /dev/null +++ b/src/webparts/stroyBot/pages/_excelImport/excelImportRepository.test.ts @@ -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('неверный формат'); + }); +}); diff --git a/src/webparts/stroyBot/pages/_excelImport/excelImportRepository.ts b/src/webparts/stroyBot/pages/_excelImport/excelImportRepository.ts new file mode 100644 index 0000000..af29883 --- /dev/null +++ b/src/webparts/stroyBot/pages/_excelImport/excelImportRepository.ts @@ -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 { + 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; +} diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportRules.test.ts b/src/webparts/stroyBot/pages/_excelImport/excelImportRules.test.ts similarity index 88% rename from src/webparts/stroyBot/pages/reportImport/reportImportRules.test.ts rename to src/webparts/stroyBot/pages/_excelImport/excelImportRules.test.ts index d3b0f73..65d5549 100644 --- a/src/webparts/stroyBot/pages/reportImport/reportImportRules.test.ts +++ b/src/webparts/stroyBot/pages/_excelImport/excelImportRules.test.ts @@ -1,14 +1,14 @@ -import { canImportFile, columnsOf, isExcelFileName, issuesByColumn, visibleRows } from './reportImportRules'; -import { IReportImportCheck, IReportImportRow } from './reportImportTypes'; +import { canImportFile, columnsOf, isExcelFileName, issuesByColumn, visibleRows } from './excelImportRules'; +import { IExcelImportCheck, IExcelImportRow } from './excelImportTypes'; // Строки — по ответу сервера на тестовый файл проекта 51 (сокращённо). const row = ( num: number, - values: IReportImportRow['values'], - issues: IReportImportRow['issues'] = [] -): IReportImportRow => ({ row: num, valid: issues.length === 0, values, ids: {}, issues }); + values: IExcelImportRow['values'], + issues: IExcelImportRow['issues'] = [] +): IExcelImportRow => ({ row: num, valid: issues.length === 0, values, ids: {}, issues }); -const check = (rows: IReportImportRow[]): IReportImportCheck => ({ +const check = (rows: IExcelImportRow[]): IExcelImportCheck => ({ kind: 'reports', file_name: 'test.xlsx', project_id: 51, diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportRules.ts b/src/webparts/stroyBot/pages/_excelImport/excelImportRules.ts similarity index 81% rename from src/webparts/stroyBot/pages/reportImport/reportImportRules.ts rename to src/webparts/stroyBot/pages/_excelImport/excelImportRules.ts index 7726d4e..47a6132 100644 --- a/src/webparts/stroyBot/pages/reportImport/reportImportRules.ts +++ b/src/webparts/stroyBot/pages/_excelImport/excelImportRules.ts @@ -1,10 +1,10 @@ -import { IReportImportCheck, IReportImportRow } from './reportImportTypes'; +import { IExcelImportCheck, IExcelImportRow } from './excelImportTypes'; /** * Колонки таблицы — в порядке, в каком их прислал сервер (это порядок колонок файла). * Колонка, которая есть только в ошибках (её нет в values), добавляется в конец — чтобы ошибка не потерялась. */ -export function columnsOf(check: IReportImportCheck): string[] { +export function columnsOf(check: IExcelImportCheck): string[] { const columns: string[] = []; const add = (column: string): void => { if (columns.indexOf(column) === -1) columns.push(column); @@ -15,7 +15,7 @@ export function columnsOf(check: IReportImportCheck): string[] { } /** Ошибки строки по колонкам: одна колонка может иметь несколько сообщений. */ -export function issuesByColumn(row: IReportImportRow): Map { +export function issuesByColumn(row: IExcelImportRow): Map { const result = new Map(); (row.issues || []).forEach((issue) => { const messages = result.get(issue.column) ?? []; @@ -25,12 +25,12 @@ export function issuesByColumn(row: IReportImportRow): Map { } /** Строки для показа: все или только с ошибками. */ -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; } /** Импортировать можно, только если в файле есть строки и ни одной с ошибкой. */ -export function canImportFile(check: IReportImportCheck): boolean { +export function canImportFile(check: IExcelImportCheck): boolean { return check.total_rows > 0 && check.invalid_rows === 0; } diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportTypes.ts b/src/webparts/stroyBot/pages/_excelImport/excelImportTypes.ts similarity index 56% rename from src/webparts/stroyBot/pages/reportImport/reportImportTypes.ts rename to src/webparts/stroyBot/pages/_excelImport/excelImportTypes.ts index 6781943..11f3107 100644 --- a/src/webparts/stroyBot/pages/reportImport/reportImportTypes.ts +++ b/src/webparts/stroyBot/pages/_excelImport/excelImportTypes.ts @@ -1,27 +1,28 @@ -// Ответ проверки Excel-файла отчётов: POST /Import/StroyBot/Reports?projectId= (multipart, поле file). -// Эндпоинт ничего не сохраняет — только разбирает файл и сопоставляет значения со справочниками. +// Ответ проверки Excel-файла: POST /Import/StroyBot/{Reports|Works}?projectId= (multipart, поле file). +// Эндпоинты ничего не сохраняют — только разбирают файл и сопоставляют значения со справочниками. // Имена полей — как в ответе API (snake_case), поэтому не переименовываем. /* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */ -/** Ошибка в ячейке: колонка — заголовок колонки файла («Профессия», «Техника»…). */ -export interface IReportImportIssue { +/** Ошибка в ячейке: колонка — заголовок колонки файла («Профессия», «Вид работы»…). */ +export interface IExcelImportIssue { column: string; message: string; } /** Строка файла после проверки. */ -export interface IReportImportRow { +export interface IExcelImportRow { /** Номер строки в Excel (заголовок — строка 1). */ row: number; valid: boolean; /** Значения ячеек по заголовкам колонок, как их прочитал сервер. Пустая ячейка — null. */ values: Record; - /** Во что сервер сопоставил значения (id объектов, работы, компании…). Нам нужно только для отладки. */ + /** Во что сервер сопоставил значения (id объектов, работ, компаний…) — по ним фронт записывает данные. */ ids: Record; - issues: IReportImportIssue[]; + issues: IExcelImportIssue[]; } -export interface IReportImportCheck { +export interface IExcelImportCheck { + /** Что проверялось: 'reports' / 'works'. */ kind: string; file_name: string; project_id: number; @@ -29,6 +30,6 @@ export interface IReportImportCheck { total_rows: number; valid_rows: number; invalid_rows: number; - rows: IReportImportRow[]; + rows: IExcelImportRow[]; notes: string[]; } diff --git a/src/webparts/stroyBot/pages/_excelImport/excelImportValues.test.ts b/src/webparts/stroyBot/pages/_excelImport/excelImportValues.test.ts new file mode 100644 index 0000000..f90ce29 --- /dev/null +++ b/src/webparts/stroyBot/pages/_excelImport/excelImportValues.test.ts @@ -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(); + }); +}); diff --git a/src/webparts/stroyBot/pages/_excelImport/excelImportValues.ts b/src/webparts/stroyBot/pages/_excelImport/excelImportValues.ts new file mode 100644 index 0000000..e84f049 --- /dev/null +++ b/src/webparts/stroyBot/pages/_excelImport/excelImportValues.ts @@ -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]; +} diff --git a/src/webparts/stroyBot/pages/_excelImport/texts.ts b/src/webparts/stroyBot/pages/_excelImport/texts.ts new file mode 100644 index 0000000..fad5525 --- /dev/null +++ b/src/webparts/stroyBot/pages/_excelImport/texts.ts @@ -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; diff --git a/src/webparts/stroyBot/pages/reportImport/useReportFileCheck.ts b/src/webparts/stroyBot/pages/_excelImport/useExcelFileCheck.ts similarity index 57% rename from src/webparts/stroyBot/pages/reportImport/useReportFileCheck.ts rename to src/webparts/stroyBot/pages/_excelImport/useExcelFileCheck.ts index 179df77..7407817 100644 --- a/src/webparts/stroyBot/pages/reportImport/useReportFileCheck.ts +++ b/src/webparts/stroyBot/pages/_excelImport/useExcelFileCheck.ts @@ -1,26 +1,32 @@ import * as React from 'react'; import { useAppServices } from '../../app/AppServicesContext'; -import { checkReportFile } from './reportImportRepository'; -import { IReportImportCheck } from './reportImportTypes'; -import { REPORT_IMPORT_TEXTS } from './texts'; +import { checkExcelFile } from './excelImportRepository'; +import { IExcelImportCheck } from './excelImportTypes'; +import { EXCEL_IMPORT_TEXTS } from './texts'; -export type ReportFileCheckStatus = +export type ExcelFileCheckStatus = | { kind: 'idle' } | { kind: 'checking' } | { kind: 'error'; message: string } - | { kind: 'done'; check: IReportImportCheck }; + | { kind: 'done'; check: IExcelImportCheck }; + +/** Адрес эндпоинта проверки для раздела (отчёты, работы). */ +export type CheckUrlBuilder = (apiBaseUrl: string, projectId: number) => string; /** * Проверка Excel-файла на сервере. Сервер ничего не сохраняет, поэтому проверку можно * запускать сразу при выборе файла. reset — сбросить результат (файл не Excel). */ -export function useReportFileCheck(projectId: number | undefined): { - status: ReportFileCheckStatus; +export function useExcelFileCheck( + projectId: number | undefined, + checkUrl: CheckUrlBuilder +): { + status: ExcelFileCheckStatus; checkFile: (file: File) => Promise; reset: () => void; } { const services = useAppServices(); - const [status, setStatus] = React.useState({ kind: 'idle' }); + const [status, setStatus] = React.useState({ kind: 'idle' }); const isMountedRef = React.useRef(true); React.useEffect( @@ -34,12 +40,13 @@ export function useReportFileCheck(projectId: number | undefined): { if (projectId === undefined) return; setStatus({ kind: 'checking' }); 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 }); } catch (error) { - console.error('Ошибка проверки файла отчёта:', error); + console.error('Ошибка проверки Excel-файла:', 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) }); } }; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportPage.module.scss b/src/webparts/stroyBot/pages/reportImport/ReportImportPage.module.scss index 4c65db6..dc50cd2 100644 --- a/src/webparts/stroyBot/pages/reportImport/ReportImportPage.module.scss +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportPage.module.scss @@ -1,9 +1,5 @@ @import '../../../../styles/design-tokens'; -// В файле 18 колонок — таблица прокручивается внутри рамки, а не растягивает страницу. -$issues-min-width: 260px; -$cell-max-width: 280px; - .page { display: flex; flex-direction: column; @@ -17,146 +13,6 @@ $cell-max-width: 280px; 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 { margin: 0 0 $sp-2; } diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportPage.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportPage.tsx index f22e51c..8c6022b 100644 --- a/src/webparts/stroyBot/pages/reportImport/ReportImportPage.tsx +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportPage.tsx @@ -1,13 +1,11 @@ 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 { isSectionAvailable } from '../../app/project/projectRules'; import { TreePageHeader } from '../_tree/TreePageHeader'; -import { canImportFile, isExcelFileName } from './reportImportRules'; -import { useReportFileCheck } from './useReportFileCheck'; +import { ExcelImportSection } from '../_excelImport/ExcelImportSection'; +import { reportImportUrl } from './reportImportRepository'; import { useReportImport } from './useReportImport'; -import { ReportFileField } from './ReportFileField'; -import { ReportImportResult } from './ReportImportResult'; import { ReportImportStep } from './ReportImportStep'; import { REPORT_IMPORT_TEXTS } from './texts'; import styles from './ReportImportPage.module.scss'; @@ -19,26 +17,7 @@ import styles from './ReportImportPage.module.scss'; */ export const ReportImportPage: React.FC = () => { const { currentProject } = useProject(); - const { status, checkFile, reset } = useReportFileCheck(currentProject?.id); const importer = useReportImport(currentProject?.id); - const [selectedFileName, setSelectedFileName] = React.useState(undefined); - const [fileError, setFileError] = React.useState(''); - 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 => { if (!currentProject) return {REPORT_IMPORT_TEXTS.noProject}; @@ -48,26 +27,21 @@ export const ReportImportPage: React.FC = () => { return ( <>

    {REPORT_IMPORT_TEXTS.intro}

    - - {fileError && {fileError}} - {isChecking && } - {status.kind === 'error' && {status.message}} - {status.kind === 'done' && ( - - ) - } - /> - )} + ( + + )} + /> ); }; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportStep.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportStep.tsx index b1118e1..c5d9d98 100644 --- a/src/webparts/stroyBot/pages/reportImport/ReportImportStep.tsx +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportStep.tsx @@ -1,13 +1,13 @@ import * as React from 'react'; import { Alert } from '@tnsu/ui-kit'; -import { IReportImportCheck } from './reportImportTypes'; +import { IExcelImportCheck } from '../_excelImport/excelImportTypes'; import { IImportedReport, tryBuildImportedReports } from './reportImportBuilder'; import { ReportImportStatus } from './useReportImport'; import { ReportImportActions } from './ReportImportActions'; import { ReportImportOutcome } from './ReportImportOutcome'; interface Props { - check: IReportImportCheck; + check: IExcelImportCheck; projectId: number; projectTitle: string; status: ReportImportStatus; diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.test.ts b/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.test.ts index e0b6720..d20ef1d 100644 --- a/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.test.ts +++ b/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.test.ts @@ -1,16 +1,16 @@ -import { buildImportedReports, toIsoDate, toNumber } from './reportImportBuilder'; -import { IReportImportCheck, IReportImportRow } from './reportImportTypes'; +import { buildImportedReports, toIsoDate } from './reportImportBuilder'; +import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes'; /* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */ -const JOB ='f4cc373e-77cb-f111-a05c-70a8a5039971'; +const JOB = 'f4cc373e-77cb-f111-a05c-70a8a5039971'; /** Строка как в ответе сервера на «ТЕСТ импорт отчета - проект 51.xlsx»; overrides — отличия. */ function makeRow( row: number, values: Record, ids: Record -): IReportImportRow { +): IExcelImportRow { return { row, valid: true, @@ -37,7 +37,7 @@ function makeRow( }; } -function makeCheck(rows: IReportImportRow[]): IReportImportCheck { +function makeCheck(rows: IExcelImportRow[]): IExcelImportCheck { return { kind: 'reports', file_name: 'test.xlsx', @@ -154,10 +154,4 @@ describe('разбор значений Excel', () => { 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(); - }); }); diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.ts b/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.ts index 14cabec..01e16a2 100644 --- a/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.ts +++ b/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.ts @@ -1,6 +1,7 @@ import { IReportDataFields, IReportDataNode, IReportPayload } from '../report/reportTypes'; 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 { 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; } -/** «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; -} - -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'] { +function buildPayload(row: IExcelImportRow, projectId: number): IImportedReport['payload'] { const reportDate = toIsoDate(row.values[COL.date]); const companyId = idNumber(row, COL.company); const shiftId = idNumber(row, COL.shift); - const objectId = deepestObjectId(row); + const objectId = deepestObjectId(row, COL.objects); // Сервер уже проверил строку; если поля всё же нет — не пишем отчёт без шапки. if (!reportDate || !companyId || !shiftId || !objectId) { 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 resources: IReportDataFields[] = []; 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]; 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 function tryBuildImportedReports(check: IReportImportCheck, projectId: number): ImportBuildResult { +export function tryBuildImportedReports(check: IExcelImportCheck, projectId: number): ImportBuildResult { try { return { kind: 'ok', reports: buildImportedReports(check, projectId) }; } 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(); check.rows.forEach((row) => { const payload = buildPayload(row, projectId); diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportRepository.test.ts b/src/webparts/stroyBot/pages/reportImport/reportImportRepository.test.ts index 45356c7..34a0e88 100644 --- a/src/webparts/stroyBot/pages/reportImport/reportImportRepository.test.ts +++ b/src/webparts/stroyBot/pages/reportImport/reportImportRepository.test.ts @@ -1,31 +1,7 @@ -import { reportImportUrl, checkReportFile } from './reportImportRepository'; -import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient'; +import { reportImportUrl } from './reportImportRepository'; -const API = 'https://api.tnsu.kz'; - -describe('проверка файла отчётов', () => { - 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('неверный формат'); +describe('импорт отчётов: адрес проверки', () => { + it('как в Swagger', () => { + expect(reportImportUrl('https://api.tnsu.kz', 51)).toEqual('https://api.tnsu.kz/Import/StroyBot/Reports?projectId=51'); }); }); diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportRepository.ts b/src/webparts/stroyBot/pages/reportImport/reportImportRepository.ts index 035e399..843a995 100644 --- a/src/webparts/stroyBot/pages/reportImport/reportImportRepository.ts +++ b/src/webparts/stroyBot/pages/reportImport/reportImportRepository.ts @@ -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 { 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 { - 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; -} diff --git a/src/webparts/stroyBot/pages/reportImport/texts.ts b/src/webparts/stroyBot/pages/reportImport/texts.ts index d317e29..66493d7 100644 --- a/src/webparts/stroyBot/pages/reportImport/texts.ts +++ b/src/webparts/stroyBot/pages/reportImport/texts.ts @@ -7,12 +7,6 @@ export const REPORT_IMPORT_TEXTS = { intro: 'Загрузите заполненную форму ежедневного отчёта (.xlsx) — файл сразу проверится. ' + 'Ошибки подсветятся в таблице; если ошибок нет, отчёты можно будет импортировать.', - chooseFile: 'Выбрать файл', - chooseAnotherFile: 'Выбрать другой файл', - selectedFile: (name: string): string => `Выбран файл: ${name}`, - checking: 'Проверяем файл…', - wrongExtension: 'Нужен файл Excel в формате .xlsx', - checkError: (details: string): string => `Не удалось проверить файл: ${details}`, import: { button: (reportCount: number): string => `Импортировать (отчётов: ${reportCount})`, saving: (savedCount: number, total: number): string => `Сохраняем отчёты… ${savedCount} из ${total}`, @@ -35,21 +29,5 @@ export const REPORT_IMPORT_TEXTS = { build: { missingHeader: (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;