diff --git a/src/webparts/requestPortal/forms/ServiceRequest/README.md b/src/webparts/requestPortal/forms/ServiceRequest/README.md index f06809a..9020c22 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/README.md +++ b/src/webparts/requestPortal/forms/ServiceRequest/README.md @@ -11,7 +11,7 @@ ## Особое поведение - Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`). -- Табличная часть «Приложения» (`Annexes`) — документы заявки (чертежи, ТЗ, ведомости объёмов) с чекбоксом «подтверждено»; не путать с блоком «Вложения» (реальные файлы) — это ОТДЕЛЬНЫЙ учёт, как в бумажной форме. +- Табличная часть «Приложения» (`Annexes`) — просто опись документов заявки (описание + номер приложения), без реальных файлов; для файлов есть общий блок «Вложения». - Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать. `EstimateTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно. - Обе табличные части сериализуются в JSON-колонки (`AnnexesJson`, `EstimateJson`) — тот же приём, что в `TmcTransfer` (`ItemsJson`). - Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется. diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 0be0a90..371503f 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -65,10 +65,6 @@ const ServiceRequestForm: React.FC = ({ }) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); - // Файлы, выбранные для строк «Приложения», ещё не загруженные на сервер (нужен - // id элемента — см. save()). Ключ — fileName (= выбранное имя строки), не индекс - // строки: индекс «плывёт» при удалении строк, а имя однозначно и переживает это. - const [annexFiles, setAnnexFiles] = useState>({}); const [existingAttachments, setExistingAttachments] = useState([]); const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); @@ -219,56 +215,6 @@ const ServiceRequestForm: React.FC = ({ .catch(() => undefined); }; - // Ссылки на скачивание для уже загруженных файлов строк «Приложения» — тот же - // пул вложений элемента, что и у общего блока «Вложения» (см. attachmentUrls). - const attachmentUrls = useMemo(() => { - const map: Record = {}; - existingAttachments.forEach((a) => { - map[a.fileName] = a.url; - }); - return map; - }, [existingAttachments]); - - // Выбрали файл для строки приложения — сохраняем в памяти до save() (нужен id - // элемента, см. saveServiceRequest). Если у строки уже был загруженный файл — - // сразу открепляем старый, иначе на элементе повиснет лишнее вложение. - const handlePickAnnexFile = (rowIndex: number, file: File): void => { - const prevFileName = data.Annexes[rowIndex]?.fileName; - if (prevFileName && requestId !== undefined && existingAttachments.some((a) => a.fileName === prevFileName)) { - deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, prevFileName) - .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== prevFileName))) - .catch(() => undefined); - } - setData((prev) => ({ - ...prev, - Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: file.name } : row)) - })); - setAnnexFiles((prev) => ({ ...prev, [file.name]: file })); - }; - - // Открепить файл строки: если он уже на сервере — удаляем вложение сразу - // (как handleRemoveAttachment), иначе просто забываем ещё не загруженный выбор. - const handleRemoveAnnexFile = (rowIndex: number): void => { - const fileName = data.Annexes[rowIndex]?.fileName; - setData((prev) => ({ - ...prev, - Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: '' } : row)) - })); - if (!fileName) { - return; - } - setAnnexFiles((prev) => { - const next = { ...prev }; - delete next[fileName]; - return next; - }); - if (requestId !== undefined && existingAttachments.some((a) => a.fileName === fileName)) { - deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName) - .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) - .catch(() => undefined); - } - }; - const save = (status: string): (() => Promise) => async (): Promise => { // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу @@ -284,18 +230,9 @@ const ServiceRequestForm: React.FC = ({ if (code) { setRequestCode(code); } - // Файлы строк «Приложения» — только те, чья строка ещё существует (строку - // могли удалить, не открепляя файл явно — см. AnnexesSection.removeRow) и - // чьё имя всё ещё совпадает (замену уже отправляет handlePickAnnexFile). - const annexFileNames = new Set(data.Annexes.map((r) => r.fileName).filter(Boolean)); - const pendingAnnexFiles = Object.entries(annexFiles) - .filter(([fileName]) => annexFileNames.has(fileName)) - .map(([, file]) => file); - const allFiles = [...files, ...pendingAnnexFiles]; - if (allFiles.length > 0) { - await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, allFiles); + if (files.length > 0) { + await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); setFiles([]); - setAnnexFiles({}); setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); } if (onSaved) { @@ -327,15 +264,7 @@ const ServiceRequestForm: React.FC = ({ const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); - const sectionProps = { - data, - set, - context, - onRequesterChange: handleRequesterChange, - attachmentUrls, - onPickAnnexFile: handlePickAnnexFile, - onRemoveAnnexFile: handleRemoveAnnexFile - }; + const sectionProps = { data, set, context, onRequesterChange: handleRequesterChange }; return (
diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx index 43883ac..70a7c9c 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -121,17 +121,7 @@ const ServiceRequestView: React.FC = ({ context, itemI ); } - // Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection) — - // из того же пула вложений элемента, что и общий блок «Вложения» ниже. - const attachmentUrls = useMemo(() => { - const map: Record = {}; - attachments.forEach((a) => { - map[a.fileName] = a.url; - }); - return map; - }, [attachments]); - - const sectionProps = { data, set, context, printOrgIndex, attachmentUrls }; + const sectionProps = { data, set, context, printOrgIndex }; const notify = (): void => { if (!requestTypeId) return; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/load.ts b/src/webparts/requestPortal/forms/ServiceRequest/load.ts index 4ec76a9..0cfff58 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/load.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/load.ts @@ -1,6 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA, EMPTY_ANNEX_ROW, IAnnexRow, ICostGroup } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, IAnnexRow, ICostGroup } from './model'; export interface ILoadedRequest { data: IFormData; @@ -93,9 +93,7 @@ export async function loadServiceRequest(itemId: number, listId?: string): Promi EstimatedStartDate: toDateInput(item.EstimatedStartDate), EstimatedFinishDate: toDateInput(item.EstimatedFinishDate), GeneralRemarks: (item.GeneralRemarks as string) || '', - // fileName — через spread с дефолтом: заявки, сохранённые до появления - // прикреплённых файлов у строки приложения, не имеют его в JSON вовсе. - Annexes: parseRows(item.AnnexesJson).map((row) => ({ ...EMPTY_ANNEX_ROW, ...row })), + Annexes: parseRows(item.AnnexesJson), Costs: parseRows(item.EstimateJson) }; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/model.ts b/src/webparts/requestPortal/forms/ServiceRequest/model.ts index 98e04a6..48c1d38 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/model.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/model.ts @@ -9,27 +9,15 @@ import { RequestTypeId } from '../../models/types'; export const FORM_ID: RequestTypeId = 'wh-service-request'; /** - * Строка таблицы приложений: документ, который заявитель прикладывает к заявке - * (чертежи, ТЗ, ведомости объёмов). Все поля — строки: «Кол-во стр.» вводится - * руками и в арифметике не участвует. + * Строка таблицы приложений: документ, упомянутый в заявке (чертежи, ТЗ, + * ведомости объёмов) — только опись, без реальных файлов (в бумажной форме + * это тоже просто перечень с номером, без вложений). */ export interface IAnnexRow { /** Описание документа. */ description: string; /** Номер приложения / шифр проекта (напр. «ASL-1812-2025-5-КЖ»). */ annexNo: string; - /** Количество страниц. */ - pages: string; - /** Документ реально приложен (галочка «Подтверждение» бумажной формы). */ - confirmed: boolean; - /** - * Имя реально прикреплённого файла — привязывает строку к одному из вложений - * элемента списка (см. shared/attachments). '' — файл к этой строке не - * прикреплён (только галочка «Подтверждение», как раньше). Отдельного списка/ - * библиотеки под это не заводим: вложения — встроенная возможность любого - * списка SharePoint, здесь используется та же, что и у общего блока «Вложения». - */ - fileName: string; } /** @@ -60,7 +48,7 @@ export interface ICostGroup { items: ICostRow[]; } -export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', pages: '', confirmed: false, fileName: '' }; +export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '' }; /** Пустая строка сметы — количество/цена вводятся руками, без значений по умолчанию. */ export const EMPTY_COST_ROW: ICostRow = { description: '', unit: '', qty: '', rate: '' }; /** Новая группа сметы — сразу с одной пустой позицией, чтобы не жать «Добавить позицию» отдельно. */ @@ -159,26 +147,6 @@ export interface ISectionProps { * поля отдельный обработчик. Не передан (режим чтения) — правка через set. */ onRequesterChange?: (login: string) => void; - /** - * Только для AnnexesSection: ссылки на скачивание уже прикреплённых файлов - * (fileName → ServerRelativeUrl), из общего пула вложений элемента (см. - * shared/attachments). Не передан — ссылки не показываются (напр. новая - * заявка до первого сохранения, там вложений ещё нет). - */ - attachmentUrls?: Record; - /** - * Только для AnnexesSection: выбрать файл для строки приложения — форма - * держит его в памяти (см. ServiceRequestForm.annexFiles) и реально - * загружает вместе с общими вложениями при сохранении (нужен id элемента). - * Не передан — прикрепление недоступно (режим просмотра). - */ - onPickAnnexFile?: (rowIndex: number, file: File) => void; - /** - * Только для AnnexesSection: открепить файл строки. Если он уже загружен на - * сервер — форма сразу удаляет вложение элемента, иначе просто забывает - * выбор (ещё не сохранённый). Не передан — открепление недоступно. - */ - onRemoveAnnexFile?: (rowIndex: number) => void; } /** Ревизия по умолчанию — как в бумажной форме («Рев:00»). */ diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss index 0211918..ebe5473 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss @@ -45,21 +45,7 @@ } .annexNo { - width: 160px; -} - -.pages { - width: 96px; - text-align: center; -} - -.file { - width: 200px; -} - -.confirmed { - width: 110px; - text-align: center; + width: 220px; } .act { @@ -67,83 +53,6 @@ text-align: center; } -// Скрытый нативный — открывается кликом по кнопке .attach. -.fileInput { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - opacity: 0; - pointer-events: none; -} - -.attach { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 5px 9px; - border: 1px dashed $gray-300; - border-radius: $r-sm; - background: transparent; - color: $gray-700; - font-family: $font-sans; - font-size: $fs-caption; - cursor: pointer; - transition: .18s; - - &:hover { - border-color: $gold-400; - color: $gold-600; - } -} - -.fileName { - display: inline-flex; - align-items: center; - gap: $sp-2; - max-width: 100%; - - a, - span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: $font-sans; - font-size: $fs-sm; - } - - a { - color: $navy-700; - - &:hover { - color: $gold-600; - } - } -} - -.removeFile { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - padding: 4px; - border: 0; - border-radius: $r-sm; - background: transparent; - color: $gray-500; - cursor: pointer; - transition: .18s; - - &:hover { - background: $red-50; - color: $red-500; - } -} - -.noFile { - color: $gray-400; -} - .remove { display: inline-flex; align-items: center; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx index 754c0ca..52ea4c9 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx @@ -1,78 +1,15 @@ import * as React from 'react'; import styles from './AnnexesSection.module.scss'; -import { Button, Checkbox, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui'; +import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui'; import { EMPTY_ANNEX_ROW, IAnnexRow, ISectionProps } from '../model'; -/** Файл строки: ссылка на скачивание, если уже загружен, иначе — прикрепить/открепить. */ -const AnnexFileCell: React.FC<{ - row: IAnnexRow; - readOnly: boolean; - url?: string; - onPick?: (file: File) => void; - onRemove?: () => void; -}> = ({ row, readOnly, url, onPick, onRemove }) => { - const inputRef = React.useRef(null); - - if (!row.fileName) { - if (readOnly || !onPick) { - return —; - } - return ( - <> - - { - const file = e.target.files?.[0]; - if (file) onPick(file); - e.target.value = ''; - }} - /> - - ); - } - - return ( - - {url ? ( - - {row.fileName} - - ) : ( - {row.fileName} - )} - {!readOnly && onRemove && ( - - )} - - ); -}; - /** - * Приложения к заявке (проектные чертежи, ТЗ, ведомости объёмов, перечень - * материалов…) — соответствует таблице «Описание документа / Приложение № - * / Кол-во стр. / Подтверждение» бумажной формы плюс колонка «Файл»: сам - * документ можно прикрепить прямо к строке (см. IAnnexRow.fileName). Файл - * физически хранится как вложение элемента списка — та же встроенная - * возможность SharePoint, что и у общего блока «Вложения» (см. - * shared/attachments), отдельная библиотека под это не нужна. «Подтверждение» - * остаётся отдельным полем: галочку можно поставить и без файла (документ - * согласован отдельно) — так вела себя бумажная форма. + * Приложения к заявке — опись документов (проектные чертежи, ТЗ, ведомости + * объёмов, перечень материалов…), без реальных файлов: соответствует + * колонкам «Описание документа» / «Приложение №» бумажной формы, остальные + * её колонки (кол-во страниц, подтверждение) в электронной версии не нужны. */ -export const AnnexesSection: React.FC = ({ - data, - set, - attachmentUrls, - onPickAnnexFile, - onRemoveAnnexFile -}) => { +export const AnnexesSection: React.FC = ({ data, set }) => { const readOnly = useFormReadOnly(); const setRow = (index: number, patch: Partial): void => { @@ -83,15 +20,7 @@ export const AnnexesSection: React.FC = ({ }; const addRow = (): void => set('Annexes', [...data.Annexes, { ...EMPTY_ANNEX_ROW }]); - const removeRow = (index: number): void => { - // Строка с файлом (уже загруженным или ещё нет) — сначала открепляем через - // родителя (см. ServiceRequestForm.handleRemoveAnnexFile), иначе загруженное - // вложение останется висеть на элементе без ссылающейся на него строки. - if (data.Annexes[index]?.fileName) { - onRemoveAnnexFile?.(index); - } - set('Annexes', data.Annexes.filter((_, i) => i !== index)); - }; + const removeRow = (index: number): void => set('Annexes', data.Annexes.filter((_, i) => i !== index)); return ( @@ -103,16 +32,13 @@ export const AnnexesSection: React.FC = ({ # Описание документа Приложение № - Кол-во стр. - Файл - Подтверждение {!readOnly && } {data.Annexes.length === 0 ? ( - + Приложения не добавлены @@ -123,13 +49,6 @@ export const AnnexesSection: React.FC = ({ {i + 1} {row.description} {row.annexNo} - {row.pages} - - - - - undefined} readOnly ariaLabel="Подтверждено" /> - ) : ( @@ -150,30 +69,6 @@ export const AnnexesSection: React.FC = ({ borderless /> - - setRow(i, { pages: v })} - ariaLabel="Кол-во страниц" - borderless - /> - - - onPickAnnexFile(i, file) : undefined} - onRemove={onRemoveAnnexFile ? () => onRemoveAnnexFile(i) : undefined} - /> - - - setRow(i, { confirmed: v })} - ariaLabel="Подтверждено" - /> -