feat: дни суточных и ночи проживания можно поправить вручную
По умолчанию считаются по датам точки; карандаш открывает ручной ввод — любое число (форс-мажор, задержался). Под полем видно, сколько по датам, и можно вернуть расчёт. В просмотре, печати и колонке SP ручное значение помечено «(вручную)». Хранится в TripEstimateJson (quantityManual), колонок нет. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,11 @@
|
||||
ночь. Цена необязательна (в точке могут жить на объекте). **Лимит** — 15 000 ₸ за ночь по
|
||||
Казахстану; за рубежом `MAX_NIGHT_PRICE.abroad` пока `undefined` (без лимита) — вписать,
|
||||
когда кадры назовут сумму. Кнопки «+ Ещё гостиница» и ввода «суммой» у проживания больше нет.
|
||||
- **Дни/ночи можно поправить вручную** (с 10-23, `AutoQuantityField`): карандаш у поля
|
||||
открывает ввод, число любое (форс-мажор — задержался в точке). Хранится в
|
||||
`quantityManual` (`''` — по датам); под полем «вручную · по датам: N · вернуть». Даты
|
||||
потом поменяли — ручное число остаётся, расхождение видно по подписи. В расчёте для
|
||||
просмотра/печати/колонки SP — пометка «(вручную)».
|
||||
- Статьи: основные Суточные / Проезд / Проживание (переключатель «Требуется / Не требуется»,
|
||||
выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход»
|
||||
со своим названием (сколько угодно) — кнопками «+», удаляются корзиной.
|
||||
|
||||
+15
-9
@@ -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<IEstimateItem>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проживание в одной точке маршрута: ночи — из дат точки, человек вводит только
|
||||
* цену за ночь (не выше лимита — см. nightPriceLimit). Цену можно не вводить —
|
||||
* в точке могут жить на объекте, тогда позиция просто 0.
|
||||
* Проживание в одной точке маршрута: ночи — из дат точки (или вручную — карандаш,
|
||||
* см. AutoQuantityField), человек вводит цену за ночь (не выше лимита — см.
|
||||
* nightPriceLimit). Цену можно не вводить — в точке могут жить на объекте, тогда
|
||||
* позиция просто 0.
|
||||
*/
|
||||
export const AccommodationFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
||||
export const AccommodationFields: React.FC<Props> = ({ 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<Props> = ({ item, stop, onChange }) =
|
||||
</div>
|
||||
|
||||
<div className={`${styles.calcRow} ${styles.calcRowShort}`}>
|
||||
<FormField label="Ночей">
|
||||
<div className={styles.readonly} title="Считается по датам точки в маршруте">
|
||||
{hasNights ? item.quantity : '—'}
|
||||
</div>
|
||||
</FormField>
|
||||
<AutoQuantityField
|
||||
id={fieldKey.itemQuantity(item)}
|
||||
label="Ночей"
|
||||
autoValue={autoQuantity}
|
||||
manualValue={item.quantityManual}
|
||||
onManualChange={(v) => onChange({ quantityManual: v })}
|
||||
/>
|
||||
<span className={styles.sign} aria-hidden>
|
||||
×
|
||||
</span>
|
||||
|
||||
+75
@@ -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<Props> = ({ 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 (
|
||||
<FormField label={label} htmlFor={id}>
|
||||
{showInput ? (
|
||||
<div className={styles.number} onBlur={() => !isManual && setEditing(false)}>
|
||||
<MoneyInput id={id} value={manualValue} onChange={onManualChange} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.autoValue}>
|
||||
<div className={styles.readonly} title="Считается по датам точки в маршруте">
|
||||
{(isManual ? manualValue : autoValue) || '—'}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button type="button" className={styles.iconButton} title="Изменить вручную" onClick={startEditing}>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isManual && (
|
||||
<span className={styles.manualNote}>
|
||||
вручную · по датам: {autoValue || '—'}
|
||||
{!readOnly && (
|
||||
<button type="button" className={styles.resetButton} onClick={resetToAuto}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
вернуть
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
+4
-3
@@ -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<Props> = ({ 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 (
|
||||
<div className={styles.row}>
|
||||
@@ -55,8 +56,8 @@ export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{kind === 'perDiem' && <PerDiemFields item={item} stop={stop} onChange={onChange} />}
|
||||
{kind === 'accommodation' && <AccommodationFields item={item} stop={stop} onChange={onChange} />}
|
||||
{kind === 'perDiem' && <PerDiemFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
|
||||
{kind === 'accommodation' && <AccommodationFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
|
||||
{!info.itemsFromRoute && <AmountFields kind={kind} item={item} onChange={onChange} />}
|
||||
<TextInput
|
||||
value={item.comment}
|
||||
|
||||
+14
-9
@@ -4,21 +4,24 @@ import { FormField, MoneyInput } from '../../../../shared/ui';
|
||||
import { IEstimateItem, IRouteStop, MRP_VALUE, MRP_YEAR } from '../../model';
|
||||
import { formatAmount, itemAmount, mrpLimit } 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<IEstimateItem>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат,
|
||||
* человек вводит только количество МРП в день (не больше лимита — см. mrpLimit).
|
||||
* Итог = дни × МРП × MRP_VALUE.
|
||||
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат (или
|
||||
* вручную — карандаш, см. AutoQuantityField), человек вводит количество МРП в
|
||||
* день (не больше лимита — см. mrpLimit). Итог = дни × МРП × MRP_VALUE.
|
||||
*/
|
||||
export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
||||
export const PerDiemFields: React.FC<Props> = ({ 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<Props> = ({ item, stop, onChange }) => {
|
||||
</div>
|
||||
|
||||
<div className={styles.calcRow}>
|
||||
<FormField label="Дней">
|
||||
<div className={styles.readonly} title="Считается по датам точки в маршруте">
|
||||
{hasDays ? item.quantity : '—'}
|
||||
</div>
|
||||
</FormField>
|
||||
<AutoQuantityField
|
||||
id={fieldKey.itemQuantity(item)}
|
||||
label="Дней"
|
||||
autoValue={autoQuantity}
|
||||
manualValue={item.quantityManual}
|
||||
onManualChange={(v) => onChange({ quantityManual: v })}
|
||||
/>
|
||||
<span className={styles.sign} aria-hidden>
|
||||
×
|
||||
</span>
|
||||
|
||||
+61
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
parseEstimate,
|
||||
projectShares,
|
||||
resolveLinkId,
|
||||
routeItemAutoQuantity,
|
||||
serializeEstimate,
|
||||
stopTotals,
|
||||
syncRouteItems
|
||||
@@ -48,6 +49,7 @@ const item = (patch: Partial<IEstimateItem>): 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]);
|
||||
|
||||
@@ -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<IEstimateItem> {
|
||||
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<IEstimateItem> {
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: ''
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user