From 8fcba75f0df9a82c7bb076a48a6fe43ae94d30c3 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 1 Sep 2026 15:28:47 +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 Вернул возможность прикладывать файл прямо к строке «Приложения» (fileName на IAnnexRow, хранится как встроенное вложение элемента списка) и убрал общий блок «Вложения» — он был избыточен: файлы теперь привязаны к своим строкам, отдельного места под них не нужно. Co-Authored-By: Claude Sonnet 5 --- .../forms/ServiceRequest/README.md | 2 +- .../ServiceRequest/ServiceRequestForm.tsx | 95 +++++++++++++------ .../ServiceRequest/ServiceRequestView.tsx | 22 ++--- .../forms/ServiceRequest/load.ts | 6 +- .../forms/ServiceRequest/model.ts | 34 ++++++- .../sections/AnnexesSection.module.scss | 81 ++++++++++++++++ .../sections/AnnexesSection.tsx | 95 +++++++++++++++++-- 7 files changed, 282 insertions(+), 53 deletions(-) diff --git a/src/webparts/requestPortal/forms/ServiceRequest/README.md b/src/webparts/requestPortal/forms/ServiceRequest/README.md index 9020c22..4bc4c44 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`) — описание документа + номер приложения + сам файл, прикреплённый прямо к строке (`fileName`, хранится как встроенное вложение элемента списка — см. shared/attachments). Отдельного общего блока «Вложения» у формы нет: каждый файл привязан к своей строке. - Табличная часть «Смета» (`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 371503f..a4e1e73 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -6,10 +6,6 @@ import { FormCard, InitiatorLine, FormModeProvider, - FileDropzone, - AttachmentList, - CollapseSection, - FormField, Icon, RequestNumber, SubmitActions, @@ -64,7 +60,10 @@ const ServiceRequestForm: React.FC = ({ onCodeLoaded }) => { 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); @@ -206,13 +205,53 @@ const ServiceRequestForm: React.FC = ({ order: r.order })); - const handleRemoveAttachment = (fileName: string): void => { - if (requestId === undefined) { + // Ссылки на скачивание для уже загруженных файлов строк «Приложения». + 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 })); + }; + + // Открепить файл строки: если он уже на сервере — удаляем вложение сразу, + // иначе просто забываем ещё не загруженный выбор. + 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; } - deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName) - .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) - .catch(() => undefined); + 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 => { @@ -230,9 +269,16 @@ const ServiceRequestForm: React.FC = ({ if (code) { setRequestCode(code); } - if (files.length > 0) { - await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); - setFiles([]); + // Файлы строк «Приложения» — только те, чья строка ещё существует (строку + // могли удалить, не открепляя файл явно — см. 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); + if (pendingAnnexFiles.length > 0) { + await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, pendingAnnexFiles); + setAnnexFiles({}); setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); } if (onSaved) { @@ -264,7 +310,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 (
@@ -307,19 +361,6 @@ const ServiceRequestForm: React.FC = ({ - {!readOnly && ( - - {existingAttachments.length > 0 && ( - - - - )} - 0 ? 'Добавить файлы' : 'Файлы'} full> - - - - )} - approval.searchUsers(q)} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx index 70a7c9c..6e09061 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -8,9 +8,6 @@ import { FormCard, InitiatorLine, FormModeProvider, - CollapseSection, - FormField, - AttachmentList, PrintSplitButton, OrgLetterhead, Icon @@ -121,7 +118,16 @@ 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; @@ -167,14 +173,6 @@ const ServiceRequestView: React.FC = ({ context, itemI - -
- - - - - -
approval.searchUsers(q)} /> 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 48c1d38..519964f 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/model.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/model.ts @@ -9,15 +9,22 @@ import { RequestTypeId } from '../../models/types'; export const FORM_ID: RequestTypeId = 'wh-service-request'; /** - * Строка таблицы приложений: документ, упомянутый в заявке (чертежи, ТЗ, - * ведомости объёмов) — только опись, без реальных файлов (в бумажной форме - * это тоже просто перечень с номером, без вложений). + * Строка таблицы приложений: документ, который заявитель прикладывает к + * заявке (чертежи, ТЗ, ведомости объёмов, перечень материалов…). */ export interface IAnnexRow { /** Описание документа. */ description: string; /** Номер приложения / шифр проекта (напр. «ASL-1812-2025-5-КЖ»). */ annexNo: string; + /** + * Имя реально прикреплённого файла — привязывает строку к одному из вложений + * элемента списка (см. shared/attachments). '' — файл ещё не выбран. + * Отдельного списка/библиотеки под это не заводим: вложения — встроенная + * возможность любого списка SharePoint, форма использует её напрямую, без + * отдельного общего блока «Вложения» — каждый файл привязан к своей строке. + */ + fileName: string; } /** @@ -48,7 +55,7 @@ export interface ICostGroup { items: ICostRow[]; } -export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '' }; +export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', fileName: '' }; /** Пустая строка сметы — количество/цена вводятся руками, без значений по умолчанию. */ export const EMPTY_COST_ROW: ICostRow = { description: '', unit: '', qty: '', rate: '' }; /** Новая группа сметы — сразу с одной пустой позицией, чтобы не жать «Добавить позицию» отдельно. */ @@ -147,6 +154,25 @@ export interface ISectionProps { * поля отдельный обработчик. Не передан (режим чтения) — правка через set. */ onRequesterChange?: (login: string) => void; + /** + * Только для AnnexesSection: ссылки на скачивание уже прикреплённых файлов + * (fileName → ServerRelativeUrl). Не передан — ссылки не показываются + * (напр. новая заявка до первого сохранения, там вложений ещё нет). + */ + 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 ebe5473..843525e 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss @@ -45,6 +45,10 @@ } .annexNo { + width: 180px; +} + +.file { width: 220px; } @@ -53,6 +57,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 52ea4c9..2145dfa 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx @@ -3,13 +3,73 @@ import styles from './AnnexesSection.module.scss'; 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 && ( + + )} + + ); +}; + /** - * Приложения к заявке — опись документов (проектные чертежи, ТЗ, ведомости - * объёмов, перечень материалов…), без реальных файлов: соответствует - * колонкам «Описание документа» / «Приложение №» бумажной формы, остальные - * её колонки (кол-во страниц, подтверждение) в электронной версии не нужны. + * Приложения к заявке (проектные чертежи, ТЗ, ведомости объёмов, перечень + * материалов…) — описание документа, номер приложения и сам файл. Файл + * физически хранится как вложение элемента списка (та же встроенная + * возможность SharePoint, что раньше использовал общий блок «Вложения» — + * его убрали, каждый файл теперь привязан к своей строке напрямую, отдельная + * библиотека под это не нужна). */ -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 => { @@ -20,7 +80,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 ( @@ -32,13 +100,14 @@ export const AnnexesSection: React.FC = ({ data, set }) => { # Описание документа Приложение № + Файл {!readOnly && } {data.Annexes.length === 0 ? ( - + Приложения не добавлены @@ -49,6 +118,9 @@ export const AnnexesSection: React.FC = ({ data, set }) => { {i + 1} {row.description} {row.annexNo} + + + ) : ( @@ -69,6 +141,15 @@ export const AnnexesSection: React.FC = ({ data, set }) => { borderless /> + + onPickAnnexFile(i, file) : undefined} + onRemove={onRemoveAnnexFile ? () => onRemoveAnnexFile(i) : undefined} + /> +