diff --git a/src/webparts/requestPortal/forms/BusinessTrip/README.md b/src/webparts/requestPortal/forms/BusinessTrip/README.md index 41daebd..4312453 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/README.md +++ b/src/webparts/requestPortal/forms/BusinessTrip/README.md @@ -88,6 +88,11 @@ ночь. Цена необязательна (в точке могут жить на объекте). **Лимит** — 15 000 ₸ за ночь по Казахстану; за рубежом `MAX_NIGHT_PRICE.abroad` пока `undefined` (без лимита) — вписать, когда кадры назовут сумму. Кнопки «+ Ещё гостиница» и ввода «суммой» у проживания больше нет. + - **Дни/ночи можно поправить вручную** (с 10-23, `AutoQuantityField`): карандаш у поля + открывает ввод, число любое (форс-мажор — задержался в точке). Хранится в + `quantityManual` (`''` — по датам); под полем «вручную · по датам: N · вернуть». Даты + потом поменяли — ручное число остаётся, расхождение видно по подписи. В расчёте для + просмотра/печати/колонки SP — пометка «(вручную)». - Статьи: основные Суточные / Проезд / Проживание (переключатель «Требуется / Не требуется», выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход» со своим названием (сколько угодно) — кнопками «+», удаляются корзиной. diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AccommodationFields.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AccommodationFields.tsx index f6046c6..90c2835 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AccommodationFields.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AccommodationFields.tsx @@ -4,21 +4,25 @@ import { FormField, MoneyInput } from '../../../../shared/ui'; import { IEstimateItem, IRouteStop } from '../../model'; import { formatAmount, itemAmount, nightPriceLimit } from '../../estimate'; import { stopCityLabel } from '../../route'; +import { AutoQuantityField } from './AutoQuantityField'; import { fieldKey } from '../../validation'; interface Props { item: IEstimateItem; /** Точка маршрута позиции; проживание строится по точкам (см. syncRouteItems). */ stop: IRouteStop | undefined; + /** Дни/ночи по датам точки (routeItemAutoQuantity) — рядом с ручным значением. */ + autoQuantity: string; onChange: (patch: Partial) => void; } /** - * Проживание в одной точке маршрута: ночи — из дат точки, человек вводит только - * цену за ночь (не выше лимита — см. nightPriceLimit). Цену можно не вводить — - * в точке могут жить на объекте, тогда позиция просто 0. + * Проживание в одной точке маршрута: ночи — из дат точки (или вручную — карандаш, + * см. AutoQuantityField), человек вводит цену за ночь (не выше лимита — см. + * nightPriceLimit). Цену можно не вводить — в точке могут жить на объекте, тогда + * позиция просто 0. */ -export const AccommodationFields: React.FC = ({ item, stop, onChange }) => { +export const AccommodationFields: React.FC = ({ item, stop, autoQuantity, onChange }) => { const hasNights = item.quantity !== ''; const limit = nightPriceLimit(stop); const isOverLimit = limit !== undefined && Number(item.price) > limit; @@ -31,11 +35,13 @@ export const AccommodationFields: React.FC = ({ item, stop, onChange }) =
- -
- {hasNights ? item.quantity : '—'} -
-
+ onChange({ quantityManual: v })} + /> × diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AutoQuantityField.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AutoQuantityField.tsx new file mode 100644 index 0000000..5b1dc52 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AutoQuantityField.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import styles from './StopItemFields.module.scss'; +import { FormField, Icon, MoneyInput, useFormReadOnly } from '../../../../shared/ui'; + +interface Props { + id: string; + /** «Дней» / «Ночей». */ + label: string; + /** Посчитано по датам точки; '' — даты не заданы. */ + autoValue: string; + /** Введено вручную; '' — берём autoValue. */ + manualValue: string; + onManualChange: (value: string) => void; +} + +/** + * Дни (суточные) или ночи (проживание): по умолчанию считаются по датам точки и + * не редактируются; карандаш открывает ручной ввод — на случай форс-мажора + * (задержался в точке). Ручное число любое; рядом видно, сколько по датам, и + * его можно сбросить обратно к расчёту. + */ +export const AutoQuantityField: React.FC = ({ id, label, autoValue, manualValue, onManualChange }) => { + const readOnly = useFormReadOnly(); + // Отдельно от manualValue: пока человек стёр число и печатает новое, поле не должно схлопываться. + const [editing, setEditing] = useState(false); + const isManual = manualValue !== ''; + const showInput = !readOnly && (editing || isManual); + + // Карандаш нажали — сразу ставим курсор в поле. + useEffect(() => { + if (editing) document.getElementById(id)?.focus(); + }, [editing, id]); + + const startEditing = (): void => { + onManualChange(autoValue); + setEditing(true); + }; + const resetToAuto = (): void => { + onManualChange(''); + setEditing(false); + }; + + return ( + + {showInput ? ( +
!isManual && setEditing(false)}> + +
+ ) : ( +
+
+ {(isManual ? manualValue : autoValue) || '—'} +
+ {!readOnly && ( + + )} +
+ )} + {isManual && ( + + вручную · по датам: {autoValue || '—'} + {!readOnly && ( + + )} + + )} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx index 03d39df..a26321b 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx @@ -3,7 +3,7 @@ import styles from './EstimateItemRow.module.scss'; import { FormField, Icon, Select, TextInput } from '../../../../shared/ui'; import type { ISelectOption } from '../../../../shared/ui'; import { ESTIMATE_KINDS, EstimateKind, IEstimateItem, ITripRoute } from '../../model'; -import { linkOptions, resolveLinkId } from '../../estimate'; +import { linkOptions, resolveLinkId, routeItemAutoQuantity } from '../../estimate'; import { fieldKey } from '../../validation'; import { AmountFields } from './AmountFields'; import { PerDiemFields } from './PerDiemFields'; @@ -30,6 +30,7 @@ export const EstimateItemRow: React.FC = ({ kind, item, route, onChange, const showLink = !info.itemsFromRoute && options.length > 1; const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута'; const stop = route.stops.find((s) => s.id === item.linkId); + const autoQuantity = routeItemAutoQuantity(kind, route, item.linkId); return (
@@ -55,8 +56,8 @@ export const EstimateItemRow: React.FC = ({ kind, item, route, onChange, )}
)} - {kind === 'perDiem' && } - {kind === 'accommodation' && } + {kind === 'perDiem' && } + {kind === 'accommodation' && } {!info.itemsFromRoute && } ) => void; } /** - * Суточные одной точки маршрута: город и проект — из точки, дни — из её дат, - * человек вводит только количество МРП в день (не больше лимита — см. mrpLimit). - * Итог = дни × МРП × MRP_VALUE. + * Суточные одной точки маршрута: город и проект — из точки, дни — из её дат (или + * вручную — карандаш, см. AutoQuantityField), человек вводит количество МРП в + * день (не больше лимита — см. mrpLimit). Итог = дни × МРП × MRP_VALUE. */ -export const PerDiemFields: React.FC = ({ item, stop, onChange }) => { +export const PerDiemFields: React.FC = ({ item, stop, autoQuantity, onChange }) => { const hasDays = item.quantity !== ''; const project = stop?.project.trim(); const limit = mrpLimit(stop); @@ -32,11 +35,13 @@ export const PerDiemFields: React.FC = ({ item, stop, onChange }) => {
- -
- {hasDays ? item.quantity : '—'} -
-
+ onChange({ quantityManual: v })} + /> × diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/StopItemFields.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/StopItemFields.module.scss index e5f5313..b3cac22 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/StopItemFields.module.scss +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/StopItemFields.module.scss @@ -130,3 +130,64 @@ $calc-inline-breakpoint: 640px; grid-template-columns: minmax(0, 0.6fr) auto minmax(0, 1fr) auto minmax(120px, auto); } } + +// Дни/ночи по датам + карандаш «изменить вручную» (AutoQuantityField). +.autoValue { + display: flex; + align-items: center; + gap: $sp-1; + + .readonly { + flex: 1; + min-width: 0; + } +} + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 6px; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + transition: .18s; + + &:hover { + background: $gray-100; + color: $navy-900; + } + + &:focus-visible { + outline: 2px solid $gold-500; + outline-offset: 1px; + } +} + +// «вручную · по датам: 4 · вернуть» — под полем, мелко: число правили руками. +.manualNote { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: $sp-1 $sp-2; + font-family: $font-sans; + font-size: $fs-caption; + color: $amber-700; +} + +.resetButton { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: $navy-700; + font-family: $font-sans; + font-size: $fs-caption; + text-decoration: underline; + cursor: pointer; +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts index 4ffd0c4..6411be4 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts @@ -21,6 +21,7 @@ import { parseEstimate, projectShares, resolveLinkId, + routeItemAutoQuantity, serializeEstimate, stopTotals, syncRouteItems @@ -48,6 +49,7 @@ const item = (patch: Partial): IEstimateItem => ({ price: '', comment: '', mrpCount: '', + quantityManual: '', ...patch }); @@ -175,6 +177,17 @@ describe('суточные по маршруту', () => { expect(synced.items[0]).toMatchObject({ id: 'h1', mode: 'calc', comment: 'Ренессанс' }); }); + it('дни/ночи вручную — любое число поверх расчёта по датам; пусто — снова по датам', () => { + const manual = { ...perDiem, items: [{ ...perDiem.items[0], quantityManual: '7' }] }; + const [synced] = syncRouteItems([manual], ROUTE); + expect(synced.items[0].quantity).toBe('7'); + expect(articleAmount({ ...synced, items: [synced.items[0]] })).toBe(7 * 6 * MRP_VALUE); + expect(itemCalculation('perDiem', synced.items[0])).toContain('7 дней (вручную)'); + const [auto] = syncRouteItems([{ ...manual, items: [{ ...manual.items[0], quantityManual: '' }] }], ROUTE); + expect(auto.items[0].quantity).toBe('4'); + expect(routeItemAutoQuantity('accommodation', ROUTE, 's1')).toBe('3'); + }); + it('лимиты зависят от страны точки', () => { const abroad = { ...ROUTE.stops[0], abroad: true }; expect([mrpLimit(ROUTE.stops[0]), mrpLimit(abroad)]).toEqual([MAX_MRP_PER_DAY.domestic, MAX_MRP_PER_DAY.abroad]); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts b/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts index a0326ba..de51639 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts @@ -57,10 +57,15 @@ function itemMrpValue(item: IEstimateItem): number { } /** Расчёт суточных: «4 дня × 6 МРП × 4 325 ₸»; дни или МРП не заданы — ''. */ +/** Пометка у дней/ночей, введённых вручную, — согласующему важно видеть, что их правили. */ +function manualMark(item: IEstimateItem): string { + return item.quantityManual ? ' (вручную)' : ''; +} + function perDiemCalculation(item: IEstimateItem): string { if (!item.quantity || !toNumber(item.mrpCount)) return ''; const days = plural(toNumber(item.quantity), ['день', 'дня', 'дней']); - return `${days} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`; + return `${days}${manualMark(item)} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`; } /** @@ -71,7 +76,7 @@ function perDiemCalculation(item: IEstimateItem): string { export function itemCalculation(kind: EstimateKind, item: IEstimateItem): string { if (kind === 'perDiem') return perDiemCalculation(item); if (item.mode !== 'calc' || !item.quantity || !item.price) return ''; - return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`; + return `${formatAmount(toNumber(item.quantity))}${manualMark(item)} × ${formatAmount(toNumber(item.price))} ₸`; } // --------------------------------------------------------------------------- @@ -119,7 +124,8 @@ export function createItem(kind: EstimateKind, linkId: string): IEstimateItem { quantity: '', price: '', comment: '', - mrpCount: '' + mrpCount: '', + quantityManual: '' }; } @@ -158,13 +164,22 @@ function perDiemRate(mrpCount: string): string { } /** - * Количество и цена позиции «по маршруту»: суточные — дни точки (день переезда - * один раз) × кол-во МРП × MRP_VALUE; проживание — ночи точки × цена за ночь, - * которую ввёл человек. + * Дни/ночи позиции «по маршруту» по датам точки: суточные — дни (день переезда + * один раз), проживание — ночи. '' — даты точки не заданы или точки нет. */ -function routeItemFigures(kind: EstimateKind, base: IEstimateItem, route: ITripRoute, index: number): Partial { - if (kind === 'accommodation') return { quantity: countText(stopNights(route.stops[index])), price: base.price }; - return { quantity: countText(stopPerDiemDays(route, index)), price: perDiemRate(base.mrpCount) }; +export function routeItemAutoQuantity(kind: EstimateKind, route: ITripRoute, stopId: string): string { + const index = route.stops.findIndex((s) => s.id === stopId); + if (index === -1) return ''; + return kind === 'accommodation' ? countText(stopNights(route.stops[index])) : countText(stopPerDiemDays(route, index)); +} + +/** + * Количество и цена позиции «по маршруту»: дни/ночи — введённые вручную, иначе по + * датам точки; цена — суточные: кол-во МРП × MRP_VALUE, проживание: что ввёл человек. + */ +function routeItemFigures(kind: EstimateKind, base: IEstimateItem, route: ITripRoute, stopId: string): Partial { + const quantity = base.quantityManual || routeItemAutoQuantity(kind, route, stopId); + return { quantity, price: kind === 'accommodation' ? base.price : perDiemRate(base.mrpCount) }; } /** @@ -177,11 +192,11 @@ function routeItemFigures(kind: EstimateKind, base: IEstimateItem, route: ITripR export function syncRouteItems(articles: IEstimateArticle[], route: ITripRoute): IEstimateArticle[] { return articles.map((article) => { if (!ESTIMATE_KINDS[article.kind].itemsFromRoute) return article; - const items = route.stops.map((stop, index): IEstimateItem => { + const items = route.stops.map((stop): IEstimateItem => { const existing = article.items.find((item) => item.linkId === stop.id); // id от точки, а не случайный: иначе строка пересоздавалась бы на каждом рендере. const base = existing || { ...createItem(article.kind, stop.id), id: `${article.kind}-${stop.id}` }; - return { ...base, mode: 'calc', ...routeItemFigures(article.kind, base, route, index) }; + return { ...base, mode: 'calc', ...routeItemFigures(article.kind, base, route, stop.id) }; }); return { ...article, items }; }); @@ -334,7 +349,8 @@ function toItem(value: unknown): IEstimateItem | undefined { quantity: text(raw.quantity), price: text(raw.price), comment: text(raw.comment), - mrpCount: text(raw.mrpCount) + mrpCount: text(raw.mrpCount), + quantityManual: text(raw.quantityManual) }; } diff --git a/src/webparts/requestPortal/forms/BusinessTrip/model.ts b/src/webparts/requestPortal/forms/BusinessTrip/model.ts index 6d4b730..e8560da 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/model.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/model.ts @@ -140,6 +140,12 @@ export interface IEstimateItem { * estimate.ts/syncRouteItems. У остальных статей — ''. */ mrpCount: string; + /** + * Только у позиций «по маршруту» (суточные, проживание): дни/ночи, введённые + * вручную поверх расчёта по датам — бывает форс-мажор (задержался в точке). + * Любое число, без ограничений. '' — считать по датам точки. + */ + quantityManual: string; } /** Статья сметы — заголовок с позициями внутри. */ @@ -375,7 +381,8 @@ function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem { quantity: '', price: '', comment: '', - mrpCount: '' + mrpCount: '', + quantityManual: '' }; }