feat(ServiceRequest): прикрепление файла к строке «Приложения»

Вернул возможность прикладывать файл прямо к строке «Приложения» (fileName
на IAnnexRow, хранится как встроенное вложение элемента списка) и убрал
общий блок «Вложения» — он был избыточен: файлы теперь привязаны к своим
строкам, отдельного места под них не нужно.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-09-01 15:28:47 +05:00
parent 76143e62be
commit 8fcba75f0d
7 changed files with 282 additions and 53 deletions
@@ -11,7 +11,7 @@
## Особое поведение
- Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`).
- Табличная часть «Приложения» (`Annexes`) — просто опись документов заявки (описание + номер приложения), без реальных файлов; для файлов есть общий блок «Вложения».
- Табличная часть «Приложения» (`Annexes`) — описание документа + номер приложения + сам файл, прикреплённый прямо к строке (`fileName`, хранится как встроенное вложение элемента списка — см. shared/attachments). Отдельного общего блока «Вложения» у формы нет: каждый файл привязан к своей строке.
- Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать. `EstimateTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно.
- Обе табличные части сериализуются в JSON-колонки (`AnnexesJson`, `EstimateJson`) — тот же приём, что в `TmcTransfer` (`ItemsJson`).
- Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется.
@@ -6,10 +6,6 @@ import {
FormCard,
InitiatorLine,
FormModeProvider,
FileDropzone,
AttachmentList,
CollapseSection,
FormField,
Icon,
RequestNumber,
SubmitActions,
@@ -64,7 +60,10 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]);
// Файлы, выбранные для строк «Приложения», ещё не загруженные на сервер (нужен
// id элемента — см. save()). Ключ — fileName (= выбранное имя строки), не индекс
// строки: индекс «плывёт» при удалении строк, а имя однозначно и переживает это.
const [annexFiles, setAnnexFiles] = useState<Record<string, File>>({});
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
@@ -206,13 +205,53 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
order: r.order
}));
const handleRemoveAttachment = (fileName: string): void => {
if (requestId === undefined) {
// Ссылки на скачивание для уже загруженных файлов строк «Приложения».
const attachmentUrls = useMemo(() => {
const map: Record<string, string> = {};
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<void>) => async (): Promise<void> => {
@@ -230,9 +269,16 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
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<IRequestFormProps> = ({
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => 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 (
<div>
@@ -307,19 +361,6 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
<RemarksSection {...sectionProps} />
</FormModeProvider>
{!readOnly && (
<CollapseSection title="Вложения" icon="upload">
{existingAttachments.length > 0 && (
<FormField label="Прикреплённые файлы" full>
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
</FormField>
)}
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
<FileDropzone files={files} onChange={setFiles} />
</FormField>
</CollapseSection>
)}
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
@@ -8,9 +8,6 @@ import {
FormCard,
InitiatorLine,
FormModeProvider,
CollapseSection,
FormField,
AttachmentList,
PrintSplitButton,
OrgLetterhead,
Icon
@@ -121,7 +118,16 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
);
}
const sectionProps = { data, set, context, printOrgIndex };
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
const attachmentUrls = useMemo(() => {
const map: Record<string, string> = {};
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<IServiceRequestViewProps> = ({ context, itemI
<CostsSection {...sectionProps} />
<RemarksSection {...sectionProps} />
</FormModeProvider>
<div className="rp-no-print">
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
<AttachmentList items={attachments} />
</FormField>
</CollapseSection>
</div>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
@@ -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<IAnnexRow>(item.AnnexesJson),
// fileName — через spread с дефолтом: заявки, сохранённые до появления
// прикреплённых файлов у строки приложения, не имеют его в JSON вовсе.
Annexes: parseRows<IAnnexRow>(item.AnnexesJson).map((row) => ({ ...EMPTY_ANNEX_ROW, ...row })),
Costs: parseRows<ICostGroup>(item.EstimateJson)
};
@@ -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<string, string>;
/**
* Только для AnnexesSection: выбрать файл для строки приложения — форма
* держит его в памяти (см. ServiceRequestForm.annexFiles) и реально
* загружает при сохранении (нужен id элемента). Не передан — прикрепление
* недоступно (режим просмотра).
*/
onPickAnnexFile?: (rowIndex: number, file: File) => void;
/**
* Только для AnnexesSection: открепить файл строки. Если он уже загружен на
* сервер — форма сразу удаляет вложение элемента, иначе просто забывает
* выбор (ещё не сохранённый). Не передан — открепление недоступно.
*/
onRemoveAnnexFile?: (rowIndex: number) => void;
}
/** Ревизия по умолчанию — как в бумажной форме («Рев:00»). */
@@ -45,6 +45,10 @@
}
.annexNo {
width: 180px;
}
.file {
width: 220px;
}
@@ -53,6 +57,83 @@
text-align: center;
}
// Скрытый нативный <input type=file> — открывается кликом по кнопке .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;
@@ -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<HTMLInputElement>(null);
if (!row.fileName) {
if (readOnly || !onPick) {
return <span className={styles.noFile}>—</span>;
}
return (
<>
<button type="button" className={styles.attach} onClick={() => inputRef.current?.click()}>
<Icon name="upload" size="sm" />
Прикрепить
</button>
<input
ref={inputRef}
type="file"
className={styles.fileInput}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onPick(file);
e.target.value = '';
}}
/>
</>
);
}
return (
<span className={styles.fileName}>
{url ? (
<a href={url} target="_blank" rel="noopener noreferrer" title={row.fileName}>
{row.fileName}
</a>
) : (
<span title={row.fileName}>{row.fileName}</span>
)}
{!readOnly && onRemove && (
<button type="button" className={styles.removeFile} title="Открепить файл" onClick={onRemove}>
<Icon name="close" size="sm" />
</button>
)}
</span>
);
};
/**
* Приложения к заявке — опись документов (проектные чертежи, ТЗ, ведомости
* объёмов, перечень материалов…), без реальных файлов: соответствует
* колонкам «Описание документа» / «Приложение №» бумажной формы, остальные
* её колонки (кол-во страниц, подтверждение) в электронной версии не нужны.
* Приложения к заявке (проектные чертежи, ТЗ, ведомости объёмов, перечень
* материалов…) — описание документа, номер приложения и сам файл. Файл
* физически хранится как вложение элемента списка (та же встроенная
* возможность SharePoint, что раньше использовал общий блок «Вложения» —
* его убрали, каждый файл теперь привязан к своей строке напрямую, отдельная
* библиотека под это не нужна).
*/
export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => {
export const AnnexesSection: React.FC<ISectionProps> = ({
data,
set,
attachmentUrls,
onPickAnnexFile,
onRemoveAnnexFile
}) => {
const readOnly = useFormReadOnly();
const setRow = (index: number, patch: Partial<IAnnexRow>): void => {
@@ -20,7 +80,15 @@ export const AnnexesSection: React.FC<ISectionProps> = ({ 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 (
<CollapseSection title="Приложения" icon="document">
@@ -32,13 +100,14 @@ export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => {
<th className={styles.num}>#</th>
<th>Описание документа</th>
<th className={styles.annexNo}>Приложение №</th>
<th className={styles.file}>Файл</th>
{!readOnly && <th className={styles.act} aria-label="Действия" />}
</tr>
</thead>
<tbody>
{data.Annexes.length === 0 ? (
<tr>
<td colSpan={readOnly ? 3 : 4} className={styles.empty}>
<td colSpan={readOnly ? 4 : 5} className={styles.empty}>
Приложения не добавлены
</td>
</tr>
@@ -49,6 +118,9 @@ export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => {
<td className={styles.num}>{i + 1}</td>
<td>{row.description}</td>
<td className={styles.annexNo}>{row.annexNo}</td>
<td className={styles.file}>
<AnnexFileCell row={row} readOnly url={attachmentUrls?.[row.fileName]} />
</td>
</tr>
) : (
<tr key={i}>
@@ -69,6 +141,15 @@ export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => {
borderless
/>
</td>
<td className={styles.file}>
<AnnexFileCell
row={row}
readOnly={false}
url={attachmentUrls?.[row.fileName]}
onPick={onPickAnnexFile ? (file) => onPickAnnexFile(i, file) : undefined}
onRemove={onRemoveAnnexFile ? () => onRemoveAnnexFile(i) : undefined}
/>
</td>
<td className={styles.act}>
<button type="button" className={styles.remove} title="Удалить строку" onClick={() => removeRow(i)}>
<Icon name="trash" size="sm" />