diff --git a/src/webparts/requestPortal/forms/ServiceRequest/README.md b/src/webparts/requestPortal/forms/ServiceRequest/README.md index 8e7270a..3da64ab 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/README.md +++ b/src/webparts/requestPortal/forms/ServiceRequest/README.md @@ -12,7 +12,7 @@ ## Особое поведение - Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`). - Табличная часть «Приложения» (`Annexes`) — документы заявки (чертежи, ТЗ, ведомости объёмов) с чекбоксом «подтверждено»; не путать с блоком «Вложения» (реальные файлы) — это ОТДЕЛЬНЫЙ учёт, как в бумажной форме. -- Табличная часть «Смета» (`Costs`) — сумма по строке и итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в самой строке. `CostTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно. +- Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать. `CostTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно. - Обе табличные части сериализуются в JSON-колонки (`AnnexesJson`, `CostsJson`) — тот же приём, что в `TmcTransfer` (`ItemsJson`). - Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется. - Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей. diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 2fe9b3d..371503f 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -45,6 +45,7 @@ import { dataListTitleForListId } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { hasAnyCostItem } from './cost'; import { saveServiceRequest, serviceRequestApprovalService } from './submit'; import { loadServiceRequest } from './load'; import { HeaderSection } from './sections/HeaderSection'; @@ -179,7 +180,7 @@ const ServiceRequestForm: React.FC = ({ approval.getJobTitle(login) ); - const hasCosts = data.Costs.some((c) => c.description.trim().length > 0); + const hasCosts = hasAnyCostItem(data.Costs); const isValid = !!data.RequesterEmail && data.ProjectName.trim().length > 0 && diff --git a/src/webparts/requestPortal/forms/ServiceRequest/cost.ts b/src/webparts/requestPortal/forms/ServiceRequest/cost.ts index 40ccf6d..87c3d78 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/cost.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/cost.ts @@ -1,4 +1,4 @@ -import { ICostRow } from './model'; +import { ICostGroup, ICostRow } from './model'; /** * Число из поля ввода: пустое/нечисловое — 0. Пробелы убираем (их вставляет @@ -16,9 +16,19 @@ export function rowAmount(row: ICostRow): number { return toNumber(row.qty) * toNumber(row.rate); } -/** Итог по смете без НДС. */ -export function costTotal(rows: ICostRow[]): number { - return rows.reduce((sum, row) => sum + rowAmount(row), 0); +/** Сумма по группе сметы без НДС. */ +export function groupAmount(group: ICostGroup): number { + return group.items.reduce((sum, row) => sum + rowAmount(row), 0); +} + +/** Итог по всей смете без НДС (сумма по всем группам). */ +export function costTotal(groups: ICostGroup[]): number { + return groups.reduce((sum, group) => sum + groupAmount(group), 0); +} + +/** Есть хотя бы одна заполненная позиция хоть в одной группе — минимум для отправки. */ +export function hasAnyCostItem(groups: ICostGroup[]): boolean { + return groups.some((group) => group.items.some((row) => row.description.trim().length > 0)); } /** diff --git a/src/webparts/requestPortal/forms/ServiceRequest/load.ts b/src/webparts/requestPortal/forms/ServiceRequest/load.ts index 4efe51b..ab6bea8 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, ICostRow } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, IAnnexRow, ICostGroup } from './model'; export interface ILoadedRequest { data: IFormData; @@ -94,7 +94,7 @@ export async function loadServiceRequest(itemId: number, listId?: string): Promi EstimatedFinishDate: toDateInput(item.EstimatedFinishDate), GeneralRemarks: (item.GeneralRemarks as string) || '', Annexes: parseRows(item.AnnexesJson), - Costs: parseRows(item.CostsJson) + Costs: parseRows(item.CostsJson) }; return { diff --git a/src/webparts/requestPortal/forms/ServiceRequest/model.ts b/src/webparts/requestPortal/forms/ServiceRequest/model.ts index 17fa761..6cd68ac 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/model.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/model.ts @@ -29,7 +29,7 @@ export interface IAnnexRow { * (см. cost.ts): иначе при правке количества сумма молча расходилась бы. */ export interface ICostRow { - /** Наименование работ / услуг. */ + /** Наименование позиции. */ description: string; /** Единица измерения. */ unit: string; @@ -39,9 +39,24 @@ export interface ICostRow { rate: string; } +/** + * Группа сметы — раздел работ (напр. «Бетонная подготовка», «Монолитные + * фундаменты»), внутри которого свои позиции. У бумажной формы такого деления + * нет (там одна плоская таблица), но реальные сметы почти всегда идут по + * разделам — группировка нужна, чтобы длинная смета читалась. Название группы + * необязательно (можно оставить одну безымянную «Группа 1» на всю смету). + */ +export interface ICostGroup { + /** Название раздела сметы; '' — группа без названия. */ + name: string; + items: ICostRow[]; +} + export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', pages: '', confirmed: false }; -/** Пустая строка сметы: «компл. × 1» — самый частый случай бумажной формы. */ -export const EMPTY_COST_ROW: ICostRow = { description: '', unit: 'компл.', qty: '1', rate: '' }; +/** Пустая строка сметы — количество/цена вводятся руками, без значений по умолчанию. */ +export const EMPTY_COST_ROW: ICostRow = { description: '', unit: '', qty: '', rate: '' }; +/** Новая группа сметы — сразу с одной пустой позицией, чтобы не жать «Добавить позицию» отдельно. */ +export const EMPTY_COST_GROUP: ICostGroup = { name: '', items: [{ ...EMPTY_COST_ROW }] }; /** Предмет заявки (чем закрывается потребность). */ export const CONTRACT_KIND_OPTIONS: ISelectOption[] = [ @@ -117,8 +132,8 @@ export interface IFormData { // --- Табличные части --- /** Приложения к заявке (сериализуются в AnnexesJson). */ Annexes: IAnnexRow[]; - /** Сметная стоимость (сериализуется в CostsJson). */ - Costs: ICostRow[]; + /** Сметная стоимость по группам (сериализуется в CostsJson). */ + Costs: ICostGroup[]; } /** Обновление одного поля формы с сохранением типа значения. */ @@ -165,5 +180,8 @@ export const EMPTY_DATA: IFormData = { EstimatedFinishDate: '', GeneralRemarks: '', Annexes: [], - Costs: [] + // Новая заявка сразу показывает одну пустую группу сметы (не «пустое место с + // кнопкой») — так понятнее, что заполнять; загруженная заявка эти значения + // всё равно перезапишет (см. load.ts). + Costs: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }] }; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss index c41b0d0..a847e10 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss @@ -1,81 +1,101 @@ @import '../../../../../styles/design-tokens'; -// Тело CollapseSection — грид 1fr 1fr; таблица должна занимать обе колонки. +// Тело CollapseSection — грид 1fr 1fr; смета должна занимать обе колонки. .full { grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: $sp-4; } -.tableWrap { - width: 100%; - overflow-x: auto; +.group { + border: 1px solid $gray-200; + border-radius: $r-md; + background: $gray-50; + padding: $sp-4; } -.table { - width: 100%; - border-collapse: collapse; +.groupHead { + display: flex; + align-items: center; + gap: $sp-3; + margin-bottom: $sp-3; +} + +.groupLabel { + flex-shrink: 0; font-family: $font-sans; font-size: $fs-sm; - color: $gray-900; - - th, - td { - border: 1px solid $gray-200; - padding: 0; - text-align: left; - vertical-align: middle; - } - - th { - padding: 7px 9px; - background: $navy-50; - font-weight: 600; - font-size: $fs-caption; - color: $gray-700; - white-space: nowrap; - } - - td { - padding: 4px 6px; - } + font-weight: 600; + color: $gray-700; } -.num { - width: 40px; - text-align: center; +.groupHead input { + flex: 1; + min-width: 0; } -.unit { - width: 90px; - text-align: center; -} - -.qty { - width: 84px; - text-align: right; -} - -.rate { - width: 150px; - text-align: right; -} - -.amount { - width: 160px; - text-align: right; - padding: 6px 9px !important; - white-space: nowrap; -} - -.act { - width: 40px; - text-align: center; -} - -.remove { +.removeGroup { display: inline-flex; align-items: center; justify-content: center; - padding: 5px; + flex-shrink: 0; + padding: 6px; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + transition: .18s; + + &:hover { + background: $red-50; + color: $red-500; + } +} + +// Строка = наименование / ед.изм / кол-во / цена / сумма (+ кнопка удаления). +.row { + display: grid; + grid-template-columns: 1fr 96px 110px 140px 140px 32px; + gap: $sp-2; + align-items: center; + + & + & { + margin-top: $sp-2; + } +} + +.colHead { + font-family: $font-sans; + font-size: 11px; + font-weight: 600; + letter-spacing: .03em; + text-transform: uppercase; + color: $gray-500; +} + +.cellStatic { + padding: 9px 12px; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-900; +} + +.cellAmount { + padding: 9px 12px; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-500; + text-align: right; + white-space: nowrap; +} + +.removeItem { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; border: 0; border-radius: $r-sm; background: transparent; @@ -90,20 +110,33 @@ } .empty { - padding: 14px !important; - text-align: center; + padding: $sp-3 0; + font-family: $font-sans; + font-size: $fs-sm; color: $gray-500; + text-align: center; } -.totalLabel { - text-align: right; - font-weight: 600; - background: $navy-50; -} - -.toolbar { +.groupFoot { display: flex; align-items: center; - gap: $sp-2; + justify-content: space-between; + gap: $sp-3; margin-top: $sp-3; + flex-wrap: wrap; +} + +.groupTotal { + font-family: $font-sans; + font-size: $fs-sm; + font-weight: 600; + color: $gray-700; +} + +.total { + align-self: flex-end; + font-family: $font-sans; + font-size: $fs-body; + font-weight: 700; + color: $navy-700; } diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx index f4af2e1..bd63181 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx @@ -1,130 +1,182 @@ import * as React from 'react'; import styles from './CostsSection.module.scss'; import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui'; -import { costTotal, formatAmount, rowAmount } from '../cost'; -import { EMPTY_COST_ROW, ICostRow, ISectionProps } from '../model'; +import { costTotal, formatAmount, groupAmount, rowAmount } from '../cost'; +import { EMPTY_COST_GROUP, EMPTY_COST_ROW, ICostGroup, ICostRow, 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))} +
+
+); /** - * Сметная стоимость услуг/работ. Сумма по строке и итог — производные - * (qty × rate), считаются на лету и НЕ хранятся в самой строке: правка - * количества/цены не может рассинхронизировать сумму (см. cost.ts). - * CostTotal в SharePoint дублируется отдельной колонкой при сохранении - * (см. submit.ts) — только для отчётов, форма её сама не читает. + * Сметная стоимость услуг-работ по разделам (группам). Сумма по позиции, + * по группе и общий итог — все производные (qty × цена), считаются на лету + * и НЕ хранятся построчно (см. cost.ts): правка количества/цены не может + * рассинхронизировать сумму. CostTotal в SharePoint дублируется отдельной + * числовой колонкой при сохранении (см. submit.ts) — только для отчётов, + * форма её сама не читает. */ export const CostsSection: React.FC = ({ data, set }) => { const readOnly = useFormReadOnly(); - const setRow = (index: number, patch: Partial): void => { + const setGroup = (index: number, patch: Partial): void => { set( 'Costs', - data.Costs.map((row, i) => (i === index ? { ...row, ...patch } : row)) + data.Costs.map((group, i) => (i === index ? { ...group, ...patch } : group)) ); }; - const addRow = (): void => set('Costs', [...data.Costs, { ...EMPTY_COST_ROW }]); - const removeRow = (index: number): void => set('Costs', data.Costs.filter((_, i) => i !== index)); + 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 (
-
- - - - - - - - - - {!readOnly && - - - {data.Costs.length === 0 ? ( - - - - ) : ( - data.Costs.map((row, i) => - readOnly ? ( - - - - - - - - - ) : ( - - - - - - - - - - ) - ) - )} - - {data.Costs.length > 0 && ( - - - - - {!readOnly && - - )} -
№Наименование работ / услугЕд.изм.Кол-воЦена за ед. (KZT)Сумма без НДС (KZT)} -
- Позиции не добавлены -
{i + 1}{row.description}{row.unit}{row.qty}{formatAmount(Number(row.rate.replace(',', '.')) || 0)}{formatAmount(rowAmount(row))}
{i + 1} - setRow(i, { description: v })} - ariaLabel="Наименование работ / услуг" - borderless - /> - - setRow(i, { unit: v })} - ariaLabel="Единица измерения" - borderless - /> - - setRow(i, { qty: v })} - ariaLabel="Количество" - borderless - /> - - setRow(i, { rate: v })} - ariaLabel="Цена за единицу" - borderless - /> - {formatAmount(rowAmount(row))} - -
- Итого без НДС - {formatAmount(costTotal(data.Costs))}} -
-
+ {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))}
)}