refactor(ServiceRequest): смета по группам вместо плоской таблицы
Смета теперь — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции/группе/общий итог по-прежнему считаются на лету, не хранятся построчно. UI — карточка на группу с полями без рамки таблицы, по макету коллеги. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
## Особое поведение
|
## Особое поведение
|
||||||
- Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`).
|
- Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`).
|
||||||
- Табличная часть «Приложения» (`Annexes`) — документы заявки (чертежи, ТЗ, ведомости объёмов) с чекбоксом «подтверждено»; не путать с блоком «Вложения» (реальные файлы) — это ОТДЕЛЬНЫЙ учёт, как в бумажной форме.
|
- Табличная часть «Приложения» (`Annexes`) — документы заявки (чертежи, ТЗ, ведомости объёмов) с чекбоксом «подтверждено»; не путать с блоком «Вложения» (реальные файлы) — это ОТДЕЛЬНЫЙ учёт, как в бумажной форме.
|
||||||
- Табличная часть «Смета» (`Costs`) — сумма по строке и итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в самой строке. `CostTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно.
|
- Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать. `CostTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно.
|
||||||
- Обе табличные части сериализуются в JSON-колонки (`AnnexesJson`, `CostsJson`) — тот же приём, что в `TmcTransfer` (`ItemsJson`).
|
- Обе табличные части сериализуются в JSON-колонки (`AnnexesJson`, `CostsJson`) — тот же приём, что в `TmcTransfer` (`ItemsJson`).
|
||||||
- Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется.
|
- Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется.
|
||||||
- Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей.
|
- Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей.
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
|
import { hasAnyCostItem } from './cost';
|
||||||
import { saveServiceRequest, serviceRequestApprovalService } from './submit';
|
import { saveServiceRequest, serviceRequestApprovalService } from './submit';
|
||||||
import { loadServiceRequest } from './load';
|
import { loadServiceRequest } from './load';
|
||||||
import { HeaderSection } from './sections/HeaderSection';
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
@@ -179,7 +180,7 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
approval.getJobTitle(login)
|
approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasCosts = data.Costs.some((c) => c.description.trim().length > 0);
|
const hasCosts = hasAnyCostItem(data.Costs);
|
||||||
const isValid =
|
const isValid =
|
||||||
!!data.RequesterEmail &&
|
!!data.RequesterEmail &&
|
||||||
data.ProjectName.trim().length > 0 &&
|
data.ProjectName.trim().length > 0 &&
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ICostRow } from './model';
|
import { ICostGroup, ICostRow } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Число из поля ввода: пустое/нечисловое — 0. Пробелы убираем (их вставляет
|
* Число из поля ввода: пустое/нечисловое — 0. Пробелы убираем (их вставляет
|
||||||
@@ -16,9 +16,19 @@ export function rowAmount(row: ICostRow): number {
|
|||||||
return toNumber(row.qty) * toNumber(row.rate);
|
return toNumber(row.qty) * toNumber(row.rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Итог по смете без НДС. */
|
/** Сумма по группе сметы без НДС. */
|
||||||
export function costTotal(rows: ICostRow[]): number {
|
export function groupAmount(group: ICostGroup): number {
|
||||||
return rows.reduce((sum, row) => sum + rowAmount(row), 0);
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { dataListTitle } from '../../shared/listTitles';
|
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 {
|
export interface ILoadedRequest {
|
||||||
data: IFormData;
|
data: IFormData;
|
||||||
@@ -94,7 +94,7 @@ export async function loadServiceRequest(itemId: number, listId?: string): Promi
|
|||||||
EstimatedFinishDate: toDateInput(item.EstimatedFinishDate),
|
EstimatedFinishDate: toDateInput(item.EstimatedFinishDate),
|
||||||
GeneralRemarks: (item.GeneralRemarks as string) || '',
|
GeneralRemarks: (item.GeneralRemarks as string) || '',
|
||||||
Annexes: parseRows<IAnnexRow>(item.AnnexesJson),
|
Annexes: parseRows<IAnnexRow>(item.AnnexesJson),
|
||||||
Costs: parseRows<ICostRow>(item.CostsJson)
|
Costs: parseRows<ICostGroup>(item.CostsJson)
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface IAnnexRow {
|
|||||||
* (см. cost.ts): иначе при правке количества сумма молча расходилась бы.
|
* (см. cost.ts): иначе при правке количества сумма молча расходилась бы.
|
||||||
*/
|
*/
|
||||||
export interface ICostRow {
|
export interface ICostRow {
|
||||||
/** Наименование работ / услуг. */
|
/** Наименование позиции. */
|
||||||
description: string;
|
description: string;
|
||||||
/** Единица измерения. */
|
/** Единица измерения. */
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -39,9 +39,24 @@ export interface ICostRow {
|
|||||||
rate: string;
|
rate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группа сметы — раздел работ (напр. «Бетонная подготовка», «Монолитные
|
||||||
|
* фундаменты»), внутри которого свои позиции. У бумажной формы такого деления
|
||||||
|
* нет (там одна плоская таблица), но реальные сметы почти всегда идут по
|
||||||
|
* разделам — группировка нужна, чтобы длинная смета читалась. Название группы
|
||||||
|
* необязательно (можно оставить одну безымянную «Группа 1» на всю смету).
|
||||||
|
*/
|
||||||
|
export interface ICostGroup {
|
||||||
|
/** Название раздела сметы; '' — группа без названия. */
|
||||||
|
name: string;
|
||||||
|
items: ICostRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', pages: '', confirmed: false };
|
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[] = [
|
export const CONTRACT_KIND_OPTIONS: ISelectOption[] = [
|
||||||
@@ -117,8 +132,8 @@ export interface IFormData {
|
|||||||
// --- Табличные части ---
|
// --- Табличные части ---
|
||||||
/** Приложения к заявке (сериализуются в AnnexesJson). */
|
/** Приложения к заявке (сериализуются в AnnexesJson). */
|
||||||
Annexes: IAnnexRow[];
|
Annexes: IAnnexRow[];
|
||||||
/** Сметная стоимость (сериализуется в CostsJson). */
|
/** Сметная стоимость по группам (сериализуется в CostsJson). */
|
||||||
Costs: ICostRow[];
|
Costs: ICostGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
@@ -165,5 +180,8 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
EstimatedFinishDate: '',
|
EstimatedFinishDate: '',
|
||||||
GeneralRemarks: '',
|
GeneralRemarks: '',
|
||||||
Annexes: [],
|
Annexes: [],
|
||||||
Costs: []
|
// Новая заявка сразу показывает одну пустую группу сметы (не «пустое место с
|
||||||
|
// кнопкой») — так понятнее, что заполнять; загруженная заявка эти значения
|
||||||
|
// всё равно перезапишет (см. load.ts).
|
||||||
|
Costs: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }]
|
||||||
};
|
};
|
||||||
|
|||||||
+102
-69
@@ -1,81 +1,101 @@
|
|||||||
@import '../../../../../styles/design-tokens';
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
// Тело CollapseSection — грид 1fr 1fr; таблица должна занимать обе колонки.
|
// Тело CollapseSection — грид 1fr 1fr; смета должна занимать обе колонки.
|
||||||
.full {
|
.full {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrap {
|
.group {
|
||||||
width: 100%;
|
border: 1px solid $gray-200;
|
||||||
overflow-x: auto;
|
border-radius: $r-md;
|
||||||
|
background: $gray-50;
|
||||||
|
padding: $sp-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.groupHead {
|
||||||
width: 100%;
|
display: flex;
|
||||||
border-collapse: collapse;
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel {
|
||||||
|
flex-shrink: 0;
|
||||||
font-family: $font-sans;
|
font-family: $font-sans;
|
||||||
font-size: $fs-sm;
|
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-weight: 600;
|
||||||
font-size: $fs-caption;
|
|
||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 4px 6px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.num {
|
.groupHead input {
|
||||||
width: 40px;
|
flex: 1;
|
||||||
text-align: center;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit {
|
.removeGroup {
|
||||||
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 {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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: 0;
|
||||||
border-radius: $r-sm;
|
border-radius: $r-sm;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -90,20 +110,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
padding: 14px !important;
|
padding: $sp-3 0;
|
||||||
text-align: center;
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.totalLabel {
|
.groupFoot {
|
||||||
text-align: right;
|
|
||||||
font-weight: 600;
|
|
||||||
background: $navy-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $sp-2;
|
justify-content: space-between;
|
||||||
|
gap: $sp-3;
|
||||||
margin-top: $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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +1,182 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './CostsSection.module.scss';
|
import styles from './CostsSection.module.scss';
|
||||||
import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
|
import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
|
||||||
import { costTotal, formatAmount, rowAmount } from '../cost';
|
import { costTotal, formatAmount, groupAmount, rowAmount } from '../cost';
|
||||||
import { EMPTY_COST_ROW, ICostRow, ISectionProps } from '../model';
|
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<ICostRow>) => void;
|
||||||
|
onAddItem: () => void;
|
||||||
|
onRemoveItem: (itemIndex: number) => void;
|
||||||
|
onRemoveGroup: () => void;
|
||||||
|
canRemoveGroup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один раздел сметы: название группы + таблица позиций внутри неё. */
|
||||||
|
const CostGroup: React.FC<IGroupProps> = ({
|
||||||
|
group,
|
||||||
|
index,
|
||||||
|
readOnly,
|
||||||
|
onChangeName,
|
||||||
|
onChangeItem,
|
||||||
|
onAddItem,
|
||||||
|
onRemoveItem,
|
||||||
|
onRemoveGroup,
|
||||||
|
canRemoveGroup
|
||||||
|
}) => (
|
||||||
|
<div className={styles.group}>
|
||||||
|
<div className={styles.groupHead}>
|
||||||
|
<span className={styles.groupLabel}>Группа {index + 1}</span>
|
||||||
|
<TextInput
|
||||||
|
value={group.name}
|
||||||
|
onChange={onChangeName}
|
||||||
|
placeholder="Наименование группы"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
{!readOnly && canRemoveGroup && (
|
||||||
|
<button type="button" className={styles.removeGroup} title="Удалить группу" onClick={onRemoveGroup}>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.row} role="row">
|
||||||
|
<span className={styles.colHead}>Наименование позиции</span>
|
||||||
|
<span className={styles.colHead}>Ед. изм.</span>
|
||||||
|
<span className={styles.colHead}>Количество</span>
|
||||||
|
<span className={styles.colHead}>Цена (смета)</span>
|
||||||
|
<span className={styles.colHead}>Сумма (смета)</span>
|
||||||
|
{!readOnly && <span className={styles.colHead} aria-hidden />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.items.length === 0 ? (
|
||||||
|
<div className={styles.empty}>Позиции не добавлены</div>
|
||||||
|
) : (
|
||||||
|
group.items.map((item, i) =>
|
||||||
|
readOnly ? (
|
||||||
|
<div className={styles.row} key={i} role="row">
|
||||||
|
<span className={styles.cellStatic}>{item.description || '—'}</span>
|
||||||
|
<span className={styles.cellStatic}>{item.unit || '—'}</span>
|
||||||
|
<span className={styles.cellStatic}>{item.qty || '—'}</span>
|
||||||
|
<span className={styles.cellStatic}>{item.rate || '—'}</span>
|
||||||
|
<span className={styles.cellAmount}>{formatAmount(rowAmount(item))}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.row} key={i} role="row">
|
||||||
|
<TextInput
|
||||||
|
value={item.description}
|
||||||
|
onChange={(v) => onChangeItem(i, { description: v })}
|
||||||
|
placeholder="Наименование"
|
||||||
|
ariaLabel="Наименование позиции"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={item.unit}
|
||||||
|
onChange={(v) => onChangeItem(i, { unit: v })}
|
||||||
|
placeholder="шт."
|
||||||
|
ariaLabel="Единица измерения"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={item.qty}
|
||||||
|
onChange={(v) => onChangeItem(i, { qty: v })}
|
||||||
|
placeholder="0"
|
||||||
|
ariaLabel="Количество"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={item.rate}
|
||||||
|
onChange={(v) => onChangeItem(i, { rate: v })}
|
||||||
|
placeholder="0"
|
||||||
|
ariaLabel="Цена за единицу"
|
||||||
|
/>
|
||||||
|
<span className={styles.cellAmount}>{item.qty || item.rate ? formatAmount(rowAmount(item)) : 'авто'}</span>
|
||||||
|
<button type="button" className={styles.removeItem} title="Удалить позицию" onClick={() => onRemoveItem(i)}>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.groupFoot}>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={onAddItem}>
|
||||||
|
<Icon name="plus" size="sm" />
|
||||||
|
Добавить позицию
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className={styles.groupTotal}>Сумма по группе: {formatAmount(groupAmount(group))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сметная стоимость услуг/работ. Сумма по строке и итог — производные
|
* Сметная стоимость услуг-работ по разделам (группам). Сумма по позиции,
|
||||||
* (qty × rate), считаются на лету и НЕ хранятся в самой строке: правка
|
* по группе и общий итог — все производные (qty × цена), считаются на лету
|
||||||
* количества/цены не может рассинхронизировать сумму (см. cost.ts).
|
* и НЕ хранятся построчно (см. cost.ts): правка количества/цены не может
|
||||||
* CostTotal в SharePoint дублируется отдельной колонкой при сохранении
|
* рассинхронизировать сумму. CostTotal в SharePoint дублируется отдельной
|
||||||
* (см. submit.ts) — только для отчётов, форма её сама не читает.
|
* числовой колонкой при сохранении (см. submit.ts) — только для отчётов,
|
||||||
|
* форма её сама не читает.
|
||||||
*/
|
*/
|
||||||
export const CostsSection: React.FC<ISectionProps> = ({ data, set }) => {
|
export const CostsSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||||
const readOnly = useFormReadOnly();
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
const setRow = (index: number, patch: Partial<ICostRow>): void => {
|
const setGroup = (index: number, patch: Partial<ICostGroup>): void => {
|
||||||
set(
|
set(
|
||||||
'Costs',
|
'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 setItem = (groupIndex: number, itemIndex: number, patch: Partial<ICostRow>): void => {
|
||||||
const removeRow = (index: number): void => set('Costs', data.Costs.filter((_, i) => i !== index));
|
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 (
|
return (
|
||||||
<CollapseSection title="Сметная стоимость услуг-работ" icon="calculator">
|
<CollapseSection title="Сметная стоимость услуг-работ" icon="calculator">
|
||||||
<div className={styles.full}>
|
<div className={styles.full}>
|
||||||
<div className={styles.tableWrap}>
|
{data.Costs.map((group, i) => (
|
||||||
<table className={styles.table}>
|
<CostGroup
|
||||||
<thead>
|
key={i}
|
||||||
<tr>
|
group={group}
|
||||||
<th className={styles.num}>№</th>
|
index={i}
|
||||||
<th>Наименование работ / услуг</th>
|
readOnly={readOnly}
|
||||||
<th className={styles.unit}>Ед.изм.</th>
|
onChangeName={(name) => setGroup(i, { name })}
|
||||||
<th className={styles.qty}>Кол-во</th>
|
onChangeItem={(itemIndex, patch) => setItem(i, itemIndex, patch)}
|
||||||
<th className={styles.rate}>Цена за ед. (KZT)</th>
|
onAddItem={() => addItem(i)}
|
||||||
<th className={styles.amount}>Сумма без НДС (KZT)</th>
|
onRemoveItem={(itemIndex) => removeItem(i, itemIndex)}
|
||||||
{!readOnly && <th className={styles.act} aria-label="Действия" />}
|
onRemoveGroup={() => removeGroup(i)}
|
||||||
</tr>
|
canRemoveGroup={data.Costs.length > 1}
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.Costs.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={readOnly ? 6 : 7} className={styles.empty}>
|
|
||||||
Позиции не добавлены
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
data.Costs.map((row, i) =>
|
|
||||||
readOnly ? (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className={styles.num}>{i + 1}</td>
|
|
||||||
<td>{row.description}</td>
|
|
||||||
<td className={styles.unit}>{row.unit}</td>
|
|
||||||
<td className={styles.qty}>{row.qty}</td>
|
|
||||||
<td className={styles.rate}>{formatAmount(Number(row.rate.replace(',', '.')) || 0)}</td>
|
|
||||||
<td className={styles.amount}>{formatAmount(rowAmount(row))}</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className={styles.num}>{i + 1}</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
value={row.description}
|
|
||||||
onChange={(v) => setRow(i, { description: v })}
|
|
||||||
ariaLabel="Наименование работ / услуг"
|
|
||||||
borderless
|
|
||||||
/>
|
/>
|
||||||
</td>
|
))}
|
||||||
<td className={styles.unit}>
|
|
||||||
<TextInput
|
{data.Costs.length === 0 && readOnly && <div className={styles.empty}>Смета не заполнена</div>}
|
||||||
value={row.unit}
|
|
||||||
onChange={(v) => setRow(i, { unit: v })}
|
|
||||||
ariaLabel="Единица измерения"
|
|
||||||
borderless
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className={styles.qty}>
|
|
||||||
<TextInput
|
|
||||||
value={row.qty}
|
|
||||||
onChange={(v) => setRow(i, { qty: v })}
|
|
||||||
ariaLabel="Количество"
|
|
||||||
borderless
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className={styles.rate}>
|
|
||||||
<TextInput
|
|
||||||
value={row.rate}
|
|
||||||
onChange={(v) => setRow(i, { rate: v })}
|
|
||||||
ariaLabel="Цена за единицу"
|
|
||||||
borderless
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className={styles.amount}>{formatAmount(rowAmount(row))}</td>
|
|
||||||
<td className={styles.act}>
|
|
||||||
<button type="button" className={styles.remove} title="Удалить строку" onClick={() => removeRow(i)}>
|
|
||||||
<Icon name="trash" size="sm" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
{data.Costs.length > 0 && (
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className={styles.totalLabel}>
|
|
||||||
Итого без НДС
|
|
||||||
</td>
|
|
||||||
<td className={styles.amount}>{formatAmount(costTotal(data.Costs))}</td>
|
|
||||||
{!readOnly && <td className={styles.act} />}
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
)}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className={styles.toolbar}>
|
<Button variant="secondary" size="sm" onClick={addGroup}>
|
||||||
<Button variant="secondary" size="sm" onClick={addRow}>
|
|
||||||
<Icon name="plus" size="sm" />
|
<Icon name="plus" size="sm" />
|
||||||
Добавить строку
|
Добавить группу
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{data.Costs.length > 0 && (
|
||||||
|
<div className={styles.total}>Итого без НДС: {formatAmount(costTotal(data.Costs))}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
|||||||
Reference in New Issue
Block a user