From 40561cbe60b29ec4147f737f8fefdba49c85ed49 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 1 Sep 2026 12:08:20 +0500 Subject: [PATCH] =?UTF-8?q?feat(ServiceRequest):=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BA=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B5=20=C2=AB=D0=9F=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каждую строку таблицы приложений теперь можно связать с реальным файлом — он хранится как встроенное вложение элемента списка (тот же механизм, что у общего блока «Вложения»), новых списков/библиотек в SharePoint под это заводить не нужно. В IAnnexRow добавлено fileName; выбранный, но ещё не сохранённый файл держится в памяти формы и грузится вместе с общими вложениями при save() (нужен id элемента). Удаление строки/замена файла открепляет уже загруженное вложение, чтобы не оставалось висящих файлов. Co-Authored-By: Claude Sonnet 5 --- .../ServiceRequest/ServiceRequestForm.tsx | 77 ++++++++++++++- .../ServiceRequest/ServiceRequestView.tsx | 12 ++- .../forms/ServiceRequest/load.ts | 6 +- .../forms/ServiceRequest/model.ts | 30 +++++- .../sections/AnnexesSection.module.scss | 81 ++++++++++++++++ .../sections/AnnexesSection.tsx | 96 +++++++++++++++++-- 6 files changed, 288 insertions(+), 14 deletions(-) diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 371503f..0be0a90 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -65,6 +65,10 @@ 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); @@ -215,6 +219,56 @@ 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 этого через границу @@ -230,9 +284,18 @@ const ServiceRequestForm: React.FC = ({ if (code) { setRequestCode(code); } - if (files.length > 0) { - await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); + // Файлы строк «Приложения» — только те, чья строка ещё существует (строку + // могли удалить, не открепляя файл явно — см. 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); setFiles([]); + setAnnexFiles({}); setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); } if (onSaved) { @@ -264,7 +327,15 @@ 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 }; + const sectionProps = { + data, + set, + context, + onRequesterChange: handleRequesterChange, + attachmentUrls, + onPickAnnexFile: handlePickAnnexFile, + onRemoveAnnexFile: handleRemoveAnnexFile + }; return (
diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx index 70a7c9c..43883ac 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -121,7 +121,17 @@ const ServiceRequestView: React.FC = ({ context, itemI ); } - const sectionProps = { data, set, context, printOrgIndex }; + // Ссылки на скачивание для файлов строк «Приложения» (см. 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 notify = (): void => { if (!requestTypeId) return; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/load.ts b/src/webparts/requestPortal/forms/ServiceRequest/load.ts index 0cfff58..4ec76a9 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, IAnnexRow, ICostGroup } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, EMPTY_ANNEX_ROW, IAnnexRow, ICostGroup } from './model'; export interface ILoadedRequest { data: IFormData; @@ -93,7 +93,9 @@ export async function loadServiceRequest(itemId: number, listId?: string): Promi EstimatedStartDate: toDateInput(item.EstimatedStartDate), EstimatedFinishDate: toDateInput(item.EstimatedFinishDate), GeneralRemarks: (item.GeneralRemarks as string) || '', - Annexes: parseRows(item.AnnexesJson), + // fileName — через spread с дефолтом: заявки, сохранённые до появления + // прикреплённых файлов у строки приложения, не имеют его в JSON вовсе. + Annexes: parseRows(item.AnnexesJson).map((row) => ({ ...EMPTY_ANNEX_ROW, ...row })), Costs: parseRows(item.EstimateJson) }; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/model.ts b/src/webparts/requestPortal/forms/ServiceRequest/model.ts index 8753149..98e04a6 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/model.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/model.ts @@ -22,6 +22,14 @@ export interface IAnnexRow { pages: string; /** Документ реально приложен (галочка «Подтверждение» бумажной формы). */ confirmed: boolean; + /** + * Имя реально прикреплённого файла — привязывает строку к одному из вложений + * элемента списка (см. shared/attachments). '' — файл к этой строке не + * прикреплён (только галочка «Подтверждение», как раньше). Отдельного списка/ + * библиотеки под это не заводим: вложения — встроенная возможность любого + * списка SharePoint, здесь используется та же, что и у общего блока «Вложения». + */ + fileName: string; } /** @@ -52,7 +60,7 @@ export interface ICostGroup { items: ICostRow[]; } -export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', pages: '', confirmed: false }; +export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', pages: '', confirmed: false, fileName: '' }; /** Пустая строка сметы — количество/цена вводятся руками, без значений по умолчанию. */ export const EMPTY_COST_ROW: ICostRow = { description: '', unit: '', qty: '', rate: '' }; /** Новая группа сметы — сразу с одной пустой позицией, чтобы не жать «Добавить позицию» отдельно. */ @@ -151,6 +159,26 @@ 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 9f47d7a..0211918 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss @@ -53,6 +53,10 @@ text-align: center; } +.file { + width: 200px; +} + .confirmed { width: 110px; text-align: center; @@ -63,6 +67,83 @@ 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 7bcd164..754c0ca 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx @@ -3,15 +3,76 @@ import styles from './AnnexesSection.module.scss'; import { Button, Checkbox, 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 }) => { +export const AnnexesSection: React.FC = ({ + data, + set, + attachmentUrls, + onPickAnnexFile, + onRemoveAnnexFile +}) => { const readOnly = useFormReadOnly(); const setRow = (index: number, patch: Partial): void => { @@ -22,7 +83,15 @@ export const AnnexesSection: React.FC = ({ data, set }) => { }; const addRow = (): void => set('Annexes', [...data.Annexes, { ...EMPTY_ANNEX_ROW }]); - const removeRow = (index: number): void => set('Annexes', data.Annexes.filter((_, i) => i !== index)); + const removeRow = (index: number): void => { + // Строка с файлом (уже загруженным или ещё нет) — сначала открепляем через + // родителя (см. ServiceRequestForm.handleRemoveAnnexFile), иначе загруженное + // вложение останется висеть на элементе без ссылающейся на него строки. + if (data.Annexes[index]?.fileName) { + onRemoveAnnexFile?.(index); + } + set('Annexes', data.Annexes.filter((_, i) => i !== index)); + }; return ( @@ -35,6 +104,7 @@ export const AnnexesSection: React.FC = ({ data, set }) => { Описание документа Приложение № Кол-во стр. + Файл Подтверждение {!readOnly && } @@ -42,7 +112,7 @@ export const AnnexesSection: React.FC = ({ data, set }) => { {data.Annexes.length === 0 ? ( - + Приложения не добавлены @@ -54,6 +124,9 @@ export const AnnexesSection: React.FC = ({ data, set }) => { {row.description} {row.annexNo} {row.pages} + + + undefined} readOnly ariaLabel="Подтверждено" /> @@ -85,6 +158,15 @@ export const AnnexesSection: React.FC = ({ data, set }) => { borderless /> + + onPickAnnexFile(i, file) : undefined} + onRemove={onRemoveAnnexFile ? () => onRemoveAnnexFile(i) : undefined} + /> +