From 8c5e95b73044a62ee8aa9c440b4fa09f1748dffb Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sat, 4 Apr 2026 11:16:16 +0700 Subject: [PATCH] Edit order pricing inline --- app/composables/graphql/generated.ts | 26 ++- app/composables/useOrderDetailPresentation.ts | 35 +++- app/pages/client-orders/[id].vue | 176 +++++++++++++++++- app/pages/orders/[id].vue | 6 +- .../operations/manager/manager-orders.graphql | 2 + .../manager/set-order-offer.graphql | 6 + graphql/operations/orders/my-orders.graphql | 3 + graphql/schema.graphql | 9 +- 8 files changed, 245 insertions(+), 18 deletions(-) diff --git a/app/composables/graphql/generated.ts b/app/composables/graphql/generated.ts index eb26275..3c4a146 100644 --- a/app/composables/graphql/generated.ts +++ b/app/composables/graphql/generated.ts @@ -458,9 +458,16 @@ export type Order = { export type OrderItem = { __typename?: 'OrderItem'; id: Scalars['ID']['output']; + lineTotal?: Maybe; productId?: Maybe; productName: Scalars['String']['output']; quantity: Scalars['Float']['output']; + unitPrice?: Maybe; +}; + +export type OrderItemPriceInput = { + itemId: Scalars['ID']['input']; + unitPrice: Scalars['Float']['input']; }; export enum OrderKind { @@ -647,8 +654,8 @@ export type RewardWithdrawalRequest = { export type SetOrderOfferInput = { deliveryFee: Scalars['Float']['input']; deliveryTerms: Scalars['String']['input']; + itemPrices: Array; orderId: Scalars['ID']['input']; - totalPrice: Scalars['Float']['input']; }; export type SubmitCalculationOrderInput = { @@ -847,7 +854,7 @@ export type ManagerOrdersQueryVariables = Exact<{ }>; -export type ManagerOrdersQuery = { __typename?: 'Query', managerOrders: Array<{ __typename?: 'Order', id: string, code: string, status: OrderStatus, kind: OrderKind, customerId: string, deliveryAddress?: string | null, deliveryTerms?: string | null, deliveryFee?: number | null, totalPrice?: number | null, createdAt: any, items: Array<{ __typename?: 'OrderItem', id: string, productName: string, quantity: number }> }> }; +export type ManagerOrdersQuery = { __typename?: 'Query', managerOrders: Array<{ __typename?: 'Order', id: string, code: string, status: OrderStatus, kind: OrderKind, customerId: string, deliveryAddress?: string | null, deliveryTerms?: string | null, deliveryFee?: number | null, totalPrice?: number | null, createdAt: any, items: Array<{ __typename?: 'OrderItem', id: string, productName: string, quantity: number, unitPrice?: number | null, lineTotal?: number | null }> }> }; export type ManagerUsersDetailQueryVariables = Exact<{ [key: string]: never; }>; @@ -897,7 +904,7 @@ export type ManagerSetOrderOfferMutationVariables = Exact<{ }>; -export type ManagerSetOrderOfferMutation = { __typename?: 'Mutation', managerSetOrderOffer: { __typename?: 'Order', id: string, code: string, status: OrderStatus, deliveryTerms?: string | null, totalPrice?: number | null } }; +export type ManagerSetOrderOfferMutation = { __typename?: 'Mutation', managerSetOrderOffer: { __typename?: 'Order', id: string, code: string, status: OrderStatus, deliveryTerms?: string | null, deliveryFee?: number | null, totalPrice?: number | null, items: Array<{ __typename?: 'OrderItem', id: string, unitPrice?: number | null, lineTotal?: number | null }> } }; export type StartOrderWorkMutationVariables = Exact<{ orderId: Scalars['ID']['input']; @@ -945,7 +952,7 @@ export type MyCurrentOrdersQuery = { __typename?: 'Query', myCurrentOrders: Arra export type MyOrdersQueryVariables = Exact<{ [key: string]: never; }>; -export type MyOrdersQuery = { __typename?: 'Query', myOrders: Array<{ __typename?: 'Order', id: string, code: string, kind: OrderKind, status: OrderStatus, deliveryAddress?: string | null, totalPrice?: number | null, deliveryTerms?: string | null, createdAt: any, items: Array<{ __typename?: 'OrderItem', id: string, productName: string, quantity: number }> }> }; +export type MyOrdersQuery = { __typename?: 'Query', myOrders: Array<{ __typename?: 'Order', id: string, code: string, kind: OrderKind, status: OrderStatus, deliveryAddress?: string | null, totalPrice?: number | null, deliveryTerms?: string | null, deliveryFee?: number | null, createdAt: any, items: Array<{ __typename?: 'OrderItem', id: string, productName: string, quantity: number, unitPrice?: number | null, lineTotal?: number | null }> }> }; export type SubmitCalculationOrderMutationVariables = Exact<{ input: SubmitCalculationOrderInput; @@ -1713,6 +1720,8 @@ export const ManagerOrdersDocument = gql` id productName quantity + unitPrice + lineTotal } } } @@ -2011,7 +2020,13 @@ export const ManagerSetOrderOfferDocument = gql` code status deliveryTerms + deliveryFee totalPrice + items { + id + unitPrice + lineTotal + } } } `; @@ -2254,11 +2269,14 @@ export const MyOrdersDocument = gql` deliveryAddress totalPrice deliveryTerms + deliveryFee createdAt items { id productName quantity + unitPrice + lineTotal } } } diff --git a/app/composables/useOrderDetailPresentation.ts b/app/composables/useOrderDetailPresentation.ts index c82c97f..a49f0fc 100644 --- a/app/composables/useOrderDetailPresentation.ts +++ b/app/composables/useOrderDetailPresentation.ts @@ -1,9 +1,34 @@ -export function orderLineStateText(totalPrice?: number | null) { - return totalPrice == null - ? 'Цена уточняется менеджером' - : 'Цена согласована менеджером'; +const PRICE_FORMATTER = new Intl.NumberFormat('ru-RU', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, +}); + +export function formatPrice(value?: number | null) { + if (value == null) { + return null; + } + + return `${PRICE_FORMATTER.format(value)} ₽`; +} + +export function orderLineStateText(unitPrice?: number | null, lineTotal?: number | null) { + if (unitPrice == null) { + return 'Цена за единицу уточняется менеджером'; + } + + const unitPriceLabel = formatPrice(unitPrice); + const lineTotalLabel = formatPrice(lineTotal); + + return lineTotalLabel + ? `Цена за единицу: ${unitPriceLabel} · Сумма позиции: ${lineTotalLabel}` + : `Цена за единицу: ${unitPriceLabel}`; } export function orderDeliveryStateText(deliveryTerms?: string | null) { - return deliveryTerms?.trim() || 'Доставка уточняется менеджером'; + return deliveryTerms?.trim() || 'Условия доставки уточняются менеджером'; +} + +export function orderLogisticsStateText(deliveryFee?: number | null) { + const deliveryFeeLabel = formatPrice(deliveryFee); + return deliveryFeeLabel || 'Стоимость логистики уточняется менеджером'; } diff --git a/app/pages/client-orders/[id].vue b/app/pages/client-orders/[id].vue index 0663526..cb65cac 100644 --- a/app/pages/client-orders/[id].vue +++ b/app/pages/client-orders/[id].vue @@ -5,9 +5,13 @@ import { CompleteOrderDocument, ManagerFinalizeOrderDocument, ManagerOrdersDocument, + ManagerSetOrderOfferDocument, StartOrderWorkDocument, + type ManagerOrdersQuery, } from '~/composables/graphql/generated'; import { + formatPrice, + orderLogisticsStateText, orderDeliveryStateText, orderLineStateText, } from '~/composables/useOrderDetailPresentation'; @@ -19,21 +23,125 @@ definePageMeta({ const route = useRoute(); const orderId = computed(() => String(route.params.id || '')); +type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number]; + const ordersQuery = useQuery(ManagerOrdersDocument, { status: null }); const finalizeMutation = useMutation(ManagerFinalizeOrderDocument); const blockMutation = useMutation(BlockOrderDocument); const startWorkMutation = useMutation(StartOrderWorkDocument); const completeWorkMutation = useMutation(CompleteOrderDocument); +const setOfferMutation = useMutation(ManagerSetOrderOfferDocument); -const currentOrder = computed(() => - (ordersQuery.result.value?.managerOrders ?? []).find((item) => item.id === orderId.value), +const itemPriceDrafts = reactive>({}); +const deliveryTermsDraft = ref(''); +const deliveryFeeDraft = ref(''); + +const currentOrder = computed(() => + (ordersQuery.result.value?.managerOrders ?? []).find((item: ManagerOrderItem) => item.id === orderId.value) ?? null, ); +watch( + currentOrder, + (order) => { + for (const key of Object.keys(itemPriceDrafts)) { + delete itemPriceDrafts[key]; + } + + if (!order) { + deliveryTermsDraft.value = ''; + deliveryFeeDraft.value = ''; + return; + } + + for (const item of order.items) { + itemPriceDrafts[item.id] = item.unitPrice == null ? '' : String(item.unitPrice); + } + + deliveryTermsDraft.value = order.deliveryTerms ?? ''; + deliveryFeeDraft.value = order.deliveryFee == null ? '' : String(order.deliveryFee); + }, + { immediate: true }, +); + +function parseMoneyDraft(value: string) { + const trimmed = String(value).replace(',', '.').trim(); + if (!trimmed) { + return null; + } + + const normalized = Number(trimmed); + if (!Number.isFinite(normalized) || normalized < 0) { + return null; + } + + return Math.round((normalized + Number.EPSILON) * 100) / 100; +} + +function draftUnitPrice(itemId: string, fallback?: number | null) { + return parseMoneyDraft(itemPriceDrafts[itemId] ?? '') ?? fallback ?? null; +} + +function draftLineTotal(item: ManagerOrderItem['items'][number]) { + const unitPrice = draftUnitPrice(item.id, item.unitPrice); + if (unitPrice == null) { + return null; + } + + return Math.round((unitPrice * item.quantity + Number.EPSILON) * 100) / 100; +} + +const draftDeliveryTerms = computed(() => deliveryTermsDraft.value.trim() || currentOrder.value?.deliveryTerms || null); +const draftDeliveryFee = computed(() => parseMoneyDraft(deliveryFeeDraft.value) ?? currentOrder.value?.deliveryFee ?? null); + +const offerReady = computed(() => { + if (!currentOrder.value) { + return false; + } + + const deliveryFee = parseMoneyDraft(deliveryFeeDraft.value); + if (deliveryFee == null) { + return false; + } + + return currentOrder.value.items.every((item) => parseMoneyDraft(itemPriceDrafts[item.id] ?? '') != null); +}); + +const offerTotal = computed(() => { + if (!currentOrder.value || !offerReady.value) { + return null; + } + + const productsTotal = currentOrder.value.items.reduce((sum, item) => ( + sum + item.quantity * (parseMoneyDraft(itemPriceDrafts[item.id] ?? '') ?? 0) + ), 0); + + return Math.round((productsTotal + (parseMoneyDraft(deliveryFeeDraft.value) ?? 0) + Number.EPSILON) * 100) / 100; +}); + async function refetchOrder() { await ordersQuery.refetch({ status: null }); } +async function saveOffer() { + if (!currentOrder.value || !offerReady.value) { + return; + } + + await setOfferMutation.mutate({ + input: { + orderId: currentOrder.value.id, + itemPrices: currentOrder.value.items.map((item) => ({ + itemId: item.id, + unitPrice: parseMoneyDraft(itemPriceDrafts[item.id] ?? '') ?? 0, + })), + deliveryTerms: deliveryTermsDraft.value.trim(), + deliveryFee: parseMoneyDraft(deliveryFeeDraft.value) ?? 0, + }, + }); + await refetchOrder(); +} + async function approveOrder() { if (!currentOrder.value) { return; @@ -112,9 +220,23 @@ async function completeOrder() {

Состав заказа

    -
  • -

    {{ item.productName }} × {{ item.quantity }}

    -

    {{ orderLineStateText(currentOrder.totalPrice) }}

    +
  • +
    +

    {{ item.productName }} × {{ item.quantity }}

    +

    {{ orderLineStateText(draftUnitPrice(item.id, item.unitPrice), draftLineTotal(item)) }}

    +
    + +
@@ -125,9 +247,49 @@ async function completeOrder() {
Адрес: {{ currentOrder.deliveryAddress || 'Адрес пока не указан' }}
-
- {{ orderDeliveryStateText(currentOrder.deliveryTerms) }} +
+

{{ orderDeliveryStateText(draftDeliveryTerms) }}

+
+
+

{{ orderLogisticsStateText(draftDeliveryFee) }}

+ +
+
+ +
+

+ {{ + offerTotal == null + ? 'Итог по заказу посчитается автоматически после заполнения всех цен.' + : `Предварительный итог: ${formatPrice(offerTotal)}` + }} +

+
diff --git a/app/pages/orders/[id].vue b/app/pages/orders/[id].vue index 16e1f3a..3cb14b1 100644 --- a/app/pages/orders/[id].vue +++ b/app/pages/orders/[id].vue @@ -5,6 +5,7 @@ import { type MyOrdersQuery, } from '~/composables/graphql/generated'; import { + orderLogisticsStateText, orderDeliveryStateText, orderLineStateText, } from '~/composables/useOrderDetailPresentation'; @@ -53,7 +54,7 @@ const currentOrder = computed(() => class="manager-mini-card space-y-2" >

{{ item.productName }} × {{ item.quantity }}

-

{{ orderLineStateText(currentOrder.totalPrice) }}

+

{{ orderLineStateText(item.unitPrice, item.lineTotal) }}

@@ -67,6 +68,9 @@ const currentOrder = computed(() =>
{{ orderDeliveryStateText(currentOrder.deliveryTerms) }}
+
+ {{ orderLogisticsStateText(currentOrder.deliveryFee) }} +
diff --git a/graphql/operations/manager/manager-orders.graphql b/graphql/operations/manager/manager-orders.graphql index 407f3c2..83ba889 100644 --- a/graphql/operations/manager/manager-orders.graphql +++ b/graphql/operations/manager/manager-orders.graphql @@ -14,6 +14,8 @@ query ManagerOrders($status: OrderStatus, $customerId: ID) { id productName quantity + unitPrice + lineTotal } } } diff --git a/graphql/operations/manager/set-order-offer.graphql b/graphql/operations/manager/set-order-offer.graphql index 47ac7fb..0b57192 100644 --- a/graphql/operations/manager/set-order-offer.graphql +++ b/graphql/operations/manager/set-order-offer.graphql @@ -4,6 +4,12 @@ mutation ManagerSetOrderOffer($input: SetOrderOfferInput!) { code status deliveryTerms + deliveryFee totalPrice + items { + id + unitPrice + lineTotal + } } } diff --git a/graphql/operations/orders/my-orders.graphql b/graphql/operations/orders/my-orders.graphql index 600e6ee..7e061bc 100644 --- a/graphql/operations/orders/my-orders.graphql +++ b/graphql/operations/orders/my-orders.graphql @@ -7,11 +7,14 @@ query MyOrders { deliveryAddress totalPrice deliveryTerms + deliveryFee createdAt items { id productName quantity + unitPrice + lineTotal } } } diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 3796511..34a1c30 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -232,6 +232,8 @@ type OrderItem { productId: ID productName: String! quantity: Float! + unitPrice: Float + lineTotal: Float } type OrderStatusEvent { @@ -428,9 +430,14 @@ input SubmitCalculationOrderInput { input SetOrderOfferInput { orderId: ID! + itemPrices: [OrderItemPriceInput!]! deliveryTerms: String! deliveryFee: Float! - totalPrice: Float! +} + +input OrderItemPriceInput { + itemId: ID! + unitPrice: Float! } input BlockOrderInput {