diff --git a/src/webparts/requestPortal/forms/ServiceRequest/README.md b/src/webparts/requestPortal/forms/ServiceRequest/README.md index 4bc4c44..4a19dad 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/README.md +++ b/src/webparts/requestPortal/forms/ServiceRequest/README.md @@ -13,7 +13,8 @@ - Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`). - Табличная часть «Приложения» (`Annexes`) — описание документа + номер приложения + сам файл, прикреплённый прямо к строке (`fileName`, хранится как встроенное вложение элемента списка — см. shared/attachments). Отдельного общего блока «Вложения» у формы нет: каждый файл привязан к своей строке. - Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать. `EstimateTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно. -- Обе табличные части сериализуются в JSON-колонки (`AnnexesJson`, `EstimateJson`) — тот же приём, что в `TmcTransfer` (`ItemsJson`). +- Под «Объём работ» (текстовое поле) — детализация по позициям в ТОМ ЖЕ формате, что и смета: группы/позиции/цена/сумма (`WorkVolumes`, см. `CostGroupsEditor.tsx` — общий редактор, переиспользуется и в CostsSection, и здесь). Дополняет текст итоговыми цифрами, не заменяет его. `WorkVolumesTotal` дублируется числовой колонкой аналогично `EstimateTotal`. +- Табличные части сериализуются в JSON-колонки (`AnnexesJson`, `WorkVolumesJson`, `EstimateJson`) — тот же приём, что в `TmcTransfer` (`ItemsJson`). - Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется. - Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей. diff --git a/src/webparts/requestPortal/forms/ServiceRequest/load.ts b/src/webparts/requestPortal/forms/ServiceRequest/load.ts index 4ec76a9..64eb37d 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/load.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/load.ts @@ -34,6 +34,7 @@ const SELECT_FIELDS = [ 'EstimatedFinishDate', 'GeneralRemarks', 'AnnexesJson', + 'WorkVolumesJson', 'EstimateJson', 'RequestStatus', 'RequestUniqueCode' @@ -89,6 +90,7 @@ export async function loadServiceRequest(itemId: number, listId?: string): Promi ContractKind: (item.ContractKind as string) || '', ContractAction: (item.ContractAction as string) || '', ScopeOfWork: (item.ScopeOfWork as string) || '', + WorkVolumes: parseRows(item.WorkVolumesJson), SuggestedSuppliers: (item.SuggestedSuppliers as string) || '', EstimatedStartDate: toDateInput(item.EstimatedStartDate), EstimatedFinishDate: toDateInput(item.EstimatedFinishDate), diff --git a/src/webparts/requestPortal/forms/ServiceRequest/model.ts b/src/webparts/requestPortal/forms/ServiceRequest/model.ts index 519964f..76edf35 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/model.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/model.ts @@ -124,6 +124,13 @@ export interface IFormData { // --- Описание требований --- /** Объём работ — основное содержание заявки, длинный текст. */ ScopeOfWork: string; + /** + * Детализация объёмов работ по разделам (группам) — тот же формат + * группа/позиция/цена/сумма, что и у сметы (см. CostGroupsEditor, + * сериализуется в WorkVolumesJson). Дополняет текстовое описание + * ScopeOfWork итоговыми цифрами по каждой позиции, а не заменяет его. + */ + WorkVolumes: ICostGroup[]; /** Предлагаемые поставщики / требования к субподрядчику. */ SuggestedSuppliers: string; EstimatedStartDate: string; @@ -197,13 +204,14 @@ export const EMPTY_DATA: IFormData = { ContractKind: '', ContractAction: '', ScopeOfWork: '', + // Новая заявка сразу показывает одну пустую группу (не «пустое место с + // кнопкой») — так понятнее, что заполнять; загруженная заявка эти значения + // всё равно перезапишет (см. load.ts). Тот же приём — у Costs ниже. + WorkVolumes: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }], SuggestedSuppliers: '', EstimatedStartDate: '', EstimatedFinishDate: '', GeneralRemarks: '', Annexes: [], - // Новая заявка сразу показывает одну пустую группу сметы (не «пустое место с - // кнопкой») — так понятнее, что заполнять; загруженная заявка эти значения - // всё равно перезапишет (см. load.ts). Costs: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }] }; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json b/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json index 0296388..2e44937 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json +++ b/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json @@ -23,6 +23,8 @@ { "internalName": "ContractAction", "title": "Действие с договором", "type": "Choice", "choices": ["Новый договор", "Доп. соглашение", "Возобновление договора", "Пролонгация договора", "Расторжение договора"] }, { "internalName": "ScopeOfWork", "title": "Объём работ", "type": "Note" }, + { "internalName": "WorkVolumesJson", "title": "Объёмы работ по позициям (JSON)", "type": "Note" }, + { "internalName": "WorkVolumesTotal", "title": "Итого по объёмам работ", "type": "Number", "decimals": 2 }, { "internalName": "SuggestedSuppliers", "title": "Предлагаемые поставщики", "type": "Note" }, { "internalName": "EstimatedStartDate", "title": "Предполагаемая дата начала", "type": "DateTime", "dateOnly": true }, { "internalName": "EstimatedFinishDate", "title": "Предполагаемая дата окончания", "type": "DateTime", "dateOnly": true }, diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.module.scss similarity index 100% rename from src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss rename to src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.module.scss diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.tsx b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.tsx new file mode 100644 index 0000000..2a80c18 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.tsx @@ -0,0 +1,193 @@ +import * as React from 'react'; +import styles from './CostGroupsEditor.module.scss'; +import { Button, Icon, TextInput, useFormReadOnly } from '../../../shared/ui'; +import { costTotal, formatAmount, groupAmount, rowAmount } from '../cost'; +import { EMPTY_COST_GROUP, EMPTY_COST_ROW, ICostGroup, ICostRow } from '../model'; + +interface IGroupProps { + group: ICostGroup; + index: number; + readOnly: boolean; + onChangeName: (name: string) => void; + onChangeItem: (itemIndex: number, patch: Partial) => void; + onAddItem: () => void; + onRemoveItem: (itemIndex: number) => void; + onRemoveGroup: () => void; + canRemoveGroup: boolean; +} + +/** Один раздел (группа): название + таблица позиций внутри неё. */ +const CostGroup: React.FC = ({ + group, + index, + readOnly, + onChangeName, + onChangeItem, + onAddItem, + onRemoveItem, + onRemoveGroup, + canRemoveGroup +}) => ( +
+
+ Группа {index + 1} + + {!readOnly && canRemoveGroup && ( + + )} +
+ +
+ Наименование позиции + Ед. изм. + Количество + Цена + Сумма + {!readOnly && } +
+ + {group.items.length === 0 ? ( +
Позиции не добавлены
+ ) : ( + group.items.map((item, i) => + readOnly ? ( +
+ {item.description || '—'} + {item.unit || '—'} + {item.qty || '—'} + {item.rate || '—'} + {formatAmount(rowAmount(item))} +
+ ) : ( +
+ onChangeItem(i, { description: v })} + placeholder="Наименование" + ariaLabel="Наименование позиции" + /> + onChangeItem(i, { unit: v })} + placeholder="шт." + ariaLabel="Единица измерения" + /> + onChangeItem(i, { qty: v })} + placeholder="0" + ariaLabel="Количество" + /> + onChangeItem(i, { rate: v })} + placeholder="0" + ariaLabel="Цена за единицу" + /> + {item.qty || item.rate ? formatAmount(rowAmount(item)) : 'авто'} + +
+ ) + ) + )} + +
+ {!readOnly && ( + + )} + Сумма по группе: {formatAmount(groupAmount(group))} +
+
+); + +export interface ICostGroupsEditorProps { + groups: ICostGroup[]; + onChange: (groups: ICostGroup[]) => void; + /** Подпись итоговой строки. По умолчанию — «Итого без НДС». */ + totalLabel?: string; + /** Текст при пустом списке групп в режиме просмотра. */ + emptyReadOnlyText?: string; +} + +/** + * Редактор таблицы «группы → позиции с ценой и количеством»: используется и + * для сметной стоимости (CostsSection), и для детализации объёмов работ + * (ScopeSection) — один и тот же формат, разные поля модели формы. Сумма по + * позиции, по группе и общий итог — производные (qty × цена), считаются на + * лету и НЕ хранятся построчно: правка количества/цены не может их + * рассинхронизировать (см. cost.ts). + */ +export const CostGroupsEditor: React.FC = ({ + groups, + onChange, + totalLabel = 'Итого без НДС', + emptyReadOnlyText = 'Не заполнено' +}) => { + const readOnly = useFormReadOnly(); + + const setGroup = (index: number, patch: Partial): void => { + onChange(groups.map((group, i) => (i === index ? { ...group, ...patch } : group))); + }; + + const setItem = (groupIndex: number, itemIndex: number, patch: Partial): void => { + setGroup(groupIndex, { + items: groups[groupIndex].items.map((row, i) => (i === itemIndex ? { ...row, ...patch } : row)) + }); + }; + + const addItem = (groupIndex: number): void => + setGroup(groupIndex, { items: [...groups[groupIndex].items, { ...EMPTY_COST_ROW }] }); + + const removeItem = (groupIndex: number, itemIndex: number): void => + setGroup(groupIndex, { items: groups[groupIndex].items.filter((_, i) => i !== itemIndex) }); + + const addGroup = (): void => onChange([...groups, { ...EMPTY_COST_GROUP, items: [{ ...EMPTY_COST_ROW }] }]); + + const removeGroup = (groupIndex: number): void => onChange(groups.filter((_, i) => i !== groupIndex)); + + return ( +
+ {groups.map((group, i) => ( + setGroup(i, { name })} + onChangeItem={(itemIndex, patch) => setItem(i, itemIndex, patch)} + onAddItem={() => addItem(i)} + onRemoveItem={(itemIndex) => removeItem(i, itemIndex)} + onRemoveGroup={() => removeGroup(i)} + canRemoveGroup={groups.length > 1} + /> + ))} + + {groups.length === 0 && readOnly &&
{emptyReadOnlyText}
} + + {!readOnly && ( + + )} + + {groups.length > 0 && ( +
+ {totalLabel}: {formatAmount(costTotal(groups))} +
+ )} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx index 5f54ab2..0d3934e 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx @@ -1,184 +1,15 @@ import * as React from 'react'; -import styles from './CostsSection.module.scss'; -import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui'; -import { costTotal, formatAmount, groupAmount, rowAmount } from '../cost'; -import { EMPTY_COST_GROUP, EMPTY_COST_ROW, ICostGroup, ICostRow, ISectionProps } from '../model'; +import { CollapseSection } from '../../../shared/ui'; +import { CostGroupsEditor } from './CostGroupsEditor'; +import { ISectionProps } from '../model'; -interface IGroupProps { - group: ICostGroup; - index: number; - readOnly: boolean; - onChangeName: (name: string) => void; - onChangeItem: (itemIndex: number, patch: Partial) => void; - onAddItem: () => void; - onRemoveItem: (itemIndex: number) => void; - onRemoveGroup: () => void; - canRemoveGroup: boolean; -} - -/** Один раздел сметы: название группы + таблица позиций внутри неё. */ -const CostGroup: React.FC = ({ - group, - index, - readOnly, - onChangeName, - onChangeItem, - onAddItem, - onRemoveItem, - onRemoveGroup, - canRemoveGroup -}) => ( -
-
- Группа {index + 1} - - {!readOnly && canRemoveGroup && ( - - )} -
- -
- Наименование позиции - Ед. изм. - Количество - Цена (смета) - Сумма (смета) - {!readOnly && } -
- - {group.items.length === 0 ? ( -
Позиции не добавлены
- ) : ( - group.items.map((item, i) => - readOnly ? ( -
- {item.description || '—'} - {item.unit || '—'} - {item.qty || '—'} - {item.rate || '—'} - {formatAmount(rowAmount(item))} -
- ) : ( -
- onChangeItem(i, { description: v })} - placeholder="Наименование" - ariaLabel="Наименование позиции" - /> - onChangeItem(i, { unit: v })} - placeholder="шт." - ariaLabel="Единица измерения" - /> - onChangeItem(i, { qty: v })} - placeholder="0" - ariaLabel="Количество" - /> - onChangeItem(i, { rate: v })} - placeholder="0" - ariaLabel="Цена за единицу" - /> - {item.qty || item.rate ? formatAmount(rowAmount(item)) : 'авто'} - -
- ) - ) - )} - -
- {!readOnly && ( - - )} - Сумма по группе: {formatAmount(groupAmount(group))} -
-
+/** Сметная стоимость услуг-работ по разделам (группам) — см. CostGroupsEditor. */ +export const CostsSection: React.FC = ({ data, set }) => ( + + set('Costs', groups)} + emptyReadOnlyText="Смета не заполнена" + /> + ); - -/** - * Сметная стоимость услуг-работ по разделам (группам). Сумма по позиции, - * по группе и общий итог — все производные (qty × цена), считаются на лету - * и НЕ хранятся построчно (см. cost.ts): правка количества/цены не может - * рассинхронизировать сумму. EstimateTotal в SharePoint дублируется отдельной - * числовой колонкой при сохранении (см. submit.ts) — только для отчётов, - * форма её сама не читает. - */ -export const CostsSection: React.FC = ({ data, set }) => { - const readOnly = useFormReadOnly(); - - const setGroup = (index: number, patch: Partial): void => { - set( - 'Costs', - data.Costs.map((group, i) => (i === index ? { ...group, ...patch } : group)) - ); - }; - - const setItem = (groupIndex: number, itemIndex: number, patch: Partial): void => { - setGroup(groupIndex, { - items: data.Costs[groupIndex].items.map((row, i) => (i === itemIndex ? { ...row, ...patch } : row)) - }); - }; - - const addItem = (groupIndex: number): void => - setGroup(groupIndex, { items: [...data.Costs[groupIndex].items, { ...EMPTY_COST_ROW }] }); - - const removeItem = (groupIndex: number, itemIndex: number): void => - setGroup(groupIndex, { items: data.Costs[groupIndex].items.filter((_, i) => i !== itemIndex) }); - - const addGroup = (): void => set('Costs', [...data.Costs, { ...EMPTY_COST_GROUP, items: [{ ...EMPTY_COST_ROW }] }]); - - const removeGroup = (groupIndex: number): void => set('Costs', data.Costs.filter((_, i) => i !== groupIndex)); - - // Новая заявка стартует без единой группы — «Добавить группу» появляется - // сразу, первую группу создаёт пользователь так же, как и все остальные. - return ( - -
- {data.Costs.map((group, i) => ( - setGroup(i, { name })} - onChangeItem={(itemIndex, patch) => setItem(i, itemIndex, patch)} - onAddItem={() => addItem(i)} - onRemoveItem={(itemIndex) => removeItem(i, itemIndex)} - onRemoveGroup={() => removeGroup(i)} - canRemoveGroup={data.Costs.length > 1} - /> - ))} - - {data.Costs.length === 0 && readOnly &&
Смета не заполнена
} - - {!readOnly && ( - - )} - - {data.Costs.length > 0 && ( -
Итого без НДС: {formatAmount(costTotal(data.Costs))}
- )} -
-
- ); -}; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.module.scss b/src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.module.scss index aa79d70..2921bd6 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.module.scss +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.module.scss @@ -8,3 +8,19 @@ font-size: $fs-caption; color: $gray-500; } + +// Блок детализации объёмов работ (CostGroupsEditor) — своя подпись сверху +// (не через FormField: там подпись сцеплена с htmlFor одного контрола, а +// здесь под ней целый редактор групп, не один инпут). +.volumesBlock { + grid-column: 1 / -1; +} + +.volumesLabel { + display: block; + margin-bottom: $sp-2; + font-family: $font-sans; + font-size: $fs-label; + font-weight: 500; + color: $gray-700; +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.tsx b/src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.tsx index 1be5cb5..f62ba10 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import styles from './ScopeSection.module.scss'; import { CollapseSection, FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui'; +import { CostGroupsEditor } from './CostGroupsEditor'; import { ISectionProps } from '../model'; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -34,6 +35,16 @@ export const ScopeSection: React.FC = ({ data, set }) => { /> +
+ Итого объёмы работ + set('WorkVolumes', groups)} + totalLabel="Итого" + emptyReadOnlyText="Объёмы работ не заполнены" + /> +
+