feat(ServiceRequest): детализация объёмов работ под «Объём работ»
Под текстовым описанием объёма работ добавлена таблица групп/позиций с ценой и суммой — в том же формате, что и смета (группа → позиции → сумма по группе → общий итог). Дополняет текст итоговыми цифрами, не заменяет его. Редактор таблицы «группы → позиции» вынесен в отдельный переиспользуемый компонент CostGroupsEditor — CostsSection и новый блок в ScopeSection используют один и тот же код вместо двух копий. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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` пишет маршрут с пустым списком исполнителей.
|
||||
|
||||
|
||||
@@ -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<ICostGroup>(item.WorkVolumesJson),
|
||||
SuggestedSuppliers: (item.SuggestedSuppliers as string) || '',
|
||||
EstimatedStartDate: toDateInput(item.EstimatedStartDate),
|
||||
EstimatedFinishDate: toDateInput(item.EstimatedFinishDate),
|
||||
|
||||
@@ -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 }] }]
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<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>
|
||||
);
|
||||
|
||||
export interface ICostGroupsEditorProps {
|
||||
groups: ICostGroup[];
|
||||
onChange: (groups: ICostGroup[]) => void;
|
||||
/** Подпись итоговой строки. По умолчанию — «Итого без НДС». */
|
||||
totalLabel?: string;
|
||||
/** Текст при пустом списке групп в режиме просмотра. */
|
||||
emptyReadOnlyText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Редактор таблицы «группы → позиции с ценой и количеством»: используется и
|
||||
* для сметной стоимости (CostsSection), и для детализации объёмов работ
|
||||
* (ScopeSection) — один и тот же формат, разные поля модели формы. Сумма по
|
||||
* позиции, по группе и общий итог — производные (qty × цена), считаются на
|
||||
* лету и НЕ хранятся построчно: правка количества/цены не может их
|
||||
* рассинхронизировать (см. cost.ts).
|
||||
*/
|
||||
export const CostGroupsEditor: React.FC<ICostGroupsEditorProps> = ({
|
||||
groups,
|
||||
onChange,
|
||||
totalLabel = 'Итого без НДС',
|
||||
emptyReadOnlyText = 'Не заполнено'
|
||||
}) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
const setGroup = (index: number, patch: Partial<ICostGroup>): void => {
|
||||
onChange(groups.map((group, i) => (i === index ? { ...group, ...patch } : group)));
|
||||
};
|
||||
|
||||
const setItem = (groupIndex: number, itemIndex: number, patch: Partial<ICostRow>): 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 (
|
||||
<div className={styles.full}>
|
||||
{groups.map((group, i) => (
|
||||
<CostGroup
|
||||
key={i}
|
||||
group={group}
|
||||
index={i}
|
||||
readOnly={readOnly}
|
||||
onChangeName={(name) => 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 && <div className={styles.empty}>{emptyReadOnlyText}</div>}
|
||||
|
||||
{!readOnly && (
|
||||
<Button variant="secondary" size="sm" onClick={addGroup}>
|
||||
<Icon name="plus" size="sm" />
|
||||
Добавить группу
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div className={styles.total}>
|
||||
{totalLabel}: {formatAmount(costTotal(groups))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<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 × цена), считаются на лету
|
||||
* и НЕ хранятся построчно (см. cost.ts): правка количества/цены не может
|
||||
* рассинхронизировать сумму. EstimateTotal в SharePoint дублируется отдельной
|
||||
* числовой колонкой при сохранении (см. submit.ts) — только для отчётов,
|
||||
* форма её сама не читает.
|
||||
*/
|
||||
export const CostsSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
const setGroup = (index: number, patch: Partial<ICostGroup>): void => {
|
||||
set(
|
||||
'Costs',
|
||||
data.Costs.map((group, i) => (i === index ? { ...group, ...patch } : group))
|
||||
);
|
||||
};
|
||||
|
||||
const setItem = (groupIndex: number, itemIndex: number, patch: Partial<ICostRow>): 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 (
|
||||
/** Сметная стоимость услуг-работ по разделам (группам) — см. CostGroupsEditor. */
|
||||
export const CostsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Сметная стоимость услуг-работ" icon="calculator">
|
||||
<div className={styles.full}>
|
||||
{data.Costs.map((group, i) => (
|
||||
<CostGroup
|
||||
key={i}
|
||||
group={group}
|
||||
index={i}
|
||||
readOnly={readOnly}
|
||||
onChangeName={(name) => 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}
|
||||
<CostGroupsEditor
|
||||
groups={data.Costs}
|
||||
onChange={(groups) => set('Costs', groups)}
|
||||
emptyReadOnlyText="Смета не заполнена"
|
||||
/>
|
||||
))}
|
||||
|
||||
{data.Costs.length === 0 && readOnly && <div className={styles.empty}>Смета не заполнена</div>}
|
||||
|
||||
{!readOnly && (
|
||||
<Button variant="secondary" size="sm" onClick={addGroup}>
|
||||
<Icon name="plus" size="sm" />
|
||||
Добавить группу
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{data.Costs.length > 0 && (
|
||||
<div className={styles.total}>Итого без НДС: {formatAmount(costTotal(data.Costs))}</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapseSection>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ISectionProps> = ({ data, set }) => {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.volumesBlock}>
|
||||
<span className={styles.volumesLabel}>Итого объёмы работ</span>
|
||||
<CostGroupsEditor
|
||||
groups={data.WorkVolumes}
|
||||
onChange={(groups) => set('WorkVolumes', groups)}
|
||||
totalLabel="Итого"
|
||||
emptyReadOnlyText="Объёмы работ не заполнены"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField label="Предлагаемые поставщики" htmlFor="SuggestedSuppliers" full>
|
||||
<TextArea
|
||||
id="SuggestedSuppliers"
|
||||
|
||||
@@ -27,18 +27,22 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
|
||||
|
||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||
if (key === 'Annexes' || key === 'Costs') return; // ниже, отдельными колонками
|
||||
if (key === 'Annexes' || key === 'Costs' || key === 'WorkVolumes') return; // ниже, отдельными колонками
|
||||
const value = data[key];
|
||||
item[key] = value === '' ? null : value;
|
||||
});
|
||||
|
||||
item.AnnexesJson = data.Annexes.length > 0 ? JSON.stringify(data.Annexes) : null;
|
||||
item.WorkVolumesJson = data.WorkVolumes.length > 0 ? JSON.stringify(data.WorkVolumes) : null;
|
||||
item.EstimateJson = data.Costs.length > 0 ? JSON.stringify(data.Costs) : null;
|
||||
// Итог сметы дублируем числовой колонкой намеренно: в самой форме он считается
|
||||
// на лету (см. cost.ts), но представлениям SharePoint и отчётам нужна сумма
|
||||
// без разбора JSON — иначе любой отчёт по заявкам пришлось бы писать кодом.
|
||||
const total = costTotal(data.Costs);
|
||||
item.EstimateTotal = total > 0 ? total : null;
|
||||
// Итоги сметы и объёмов работ дублируем числовыми колонками намеренно: в
|
||||
// самой форме они считаются на лету (см. cost.ts), но представлениям
|
||||
// SharePoint и отчётам нужна сумма без разбора JSON — иначе любой отчёт по
|
||||
// заявкам пришлось бы писать кодом.
|
||||
const workVolumesTotal = costTotal(data.WorkVolumes);
|
||||
item.WorkVolumesTotal = workVolumesTotal > 0 ? workVolumesTotal : null;
|
||||
const estimateTotal = costTotal(data.Costs);
|
||||
item.EstimateTotal = estimateTotal > 0 ? estimateTotal : null;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user