Unify order items layout across cart and orders
This commit is contained in:
263
app/components/orders/OrderItemsTable.vue
Normal file
263
app/components/orders/OrderItemsTable.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { formatPrice } from '~/composables/useOrderDetailPresentation';
|
||||
|
||||
type OrderItemView = {
|
||||
id: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
sku?: string | null;
|
||||
unitPrice?: number | null;
|
||||
lineTotal?: number | null;
|
||||
parameters?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type CalculationPayload = Record<string, unknown> | null | undefined;
|
||||
type ItemParameter = { label: string; value: string };
|
||||
|
||||
const props = defineProps<{
|
||||
items: OrderItemView[];
|
||||
calculationPayload?: CalculationPayload;
|
||||
mode?: 'readonly' | 'manager-pricing' | 'cart';
|
||||
editable?: boolean;
|
||||
unitPriceDrafts?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:unit-price': [payload: { itemId: string; value: string }];
|
||||
increment: [itemId: string];
|
||||
decrement: [itemId: string];
|
||||
remove: [itemId: string];
|
||||
}>();
|
||||
|
||||
const coverPresets = [
|
||||
['#d9f5e6', '#9ce8c1', '#6fd09d'],
|
||||
['#eaf9ef', '#b3e8cb', '#76c89f'],
|
||||
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
||||
];
|
||||
|
||||
function createProductCover(name: string, seedKey: string) {
|
||||
const seed = `${name}${seedKey}`.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
const [start, middle, finish] = coverPresets[seed % coverPresets.length];
|
||||
const firstLetter = name.trim().charAt(0).toUpperCase() || 'P';
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${start}" />
|
||||
<stop offset="55%" stop-color="${middle}" />
|
||||
<stop offset="100%" stop-color="${finish}" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="120" height="120" fill="url(#g)" rx="28" />
|
||||
<g opacity="0.15">
|
||||
<circle cx="96" cy="22" r="28" fill="#0f7a49" />
|
||||
<circle cx="18" cy="106" r="30" fill="#0f7a49" />
|
||||
</g>
|
||||
<text x="50%" y="57%" text-anchor="middle" fill="#11412c" font-family="Manrope, sans-serif" font-size="44" font-weight="700">${firstLetter}</text>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
function parseMoneyDraft(value: string | undefined, fallback?: number | null) {
|
||||
const trimmed = String(value ?? '').replace(',', '.').trim();
|
||||
if (!trimmed) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
const normalized = Number(trimmed);
|
||||
if (!Number.isFinite(normalized) || normalized < 0) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
return Math.round((normalized + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function currentUnitPrice(item: OrderItemView) {
|
||||
if (!isPricingMode.value) {
|
||||
return item.unitPrice ?? null;
|
||||
}
|
||||
|
||||
return parseMoneyDraft(props.unitPriceDrafts?.[item.id], item.unitPrice);
|
||||
}
|
||||
|
||||
function currentLineTotal(item: OrderItemView) {
|
||||
const unitPrice = currentUnitPrice(item);
|
||||
if (unitPrice == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round((unitPrice * item.quantity + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function updateUnitPrice(itemId: string, event: Event) {
|
||||
emit('update:unit-price', {
|
||||
itemId,
|
||||
value: (event.target as HTMLInputElement).value,
|
||||
});
|
||||
}
|
||||
|
||||
function incrementItem(itemId: string) {
|
||||
emit('increment', itemId);
|
||||
}
|
||||
|
||||
function decrementItem(itemId: string) {
|
||||
emit('decrement', itemId);
|
||||
}
|
||||
|
||||
function removeItem(itemId: string) {
|
||||
emit('remove', itemId);
|
||||
}
|
||||
|
||||
function formatParameterValue(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
const mode = computed(() => props.mode ?? (props.editable ? 'manager-pricing' : 'readonly'));
|
||||
const isPricingMode = computed(() => mode.value === 'manager-pricing');
|
||||
const isCartMode = computed(() => mode.value === 'cart');
|
||||
|
||||
function mapParameterEntries(source: Record<string, unknown> | null | undefined): ItemParameter[] {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const variants = [
|
||||
{ key: 'width', label: 'Ширина', suffix: ' мм' },
|
||||
{ key: 'thickness', label: 'Толщина', suffix: ' мкм' },
|
||||
{ key: 'color', label: 'Цвет', suffix: '' },
|
||||
];
|
||||
|
||||
return variants
|
||||
.map((variant) => {
|
||||
const rawValue = source[variant.key];
|
||||
const value = formatParameterValue(rawValue);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: variant.label,
|
||||
value: `${value}${variant.suffix}`,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as ItemParameter[];
|
||||
}
|
||||
|
||||
function itemParameters(item: OrderItemView) {
|
||||
if (item.parameters && typeof item.parameters === 'object') {
|
||||
return mapParameterEntries(item.parameters);
|
||||
}
|
||||
|
||||
if (props.items.length !== 1 || !props.calculationPayload || typeof props.calculationPayload !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mapParameterEntries(props.calculationPayload);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div class="hidden border-b border-[#deebe4] pb-3 md:grid md:grid-cols-[minmax(0,1.8fr)_140px_160px_140px] md:gap-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Позиция</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Цена</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Количество</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Итог</p>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 md:mt-4">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="rounded-[28px] border border-[#dceae2] bg-[linear-gradient(180deg,#ffffff_0%,#f8fcf9_100%)] p-4 md:grid md:grid-cols-[minmax(0,1.8fr)_140px_160px_140px] md:gap-4 md:p-5"
|
||||
>
|
||||
<div class="flex min-w-0 gap-4">
|
||||
<img
|
||||
:src="createProductCover(item.productName, item.id)"
|
||||
:alt="item.productName"
|
||||
class="h-20 w-20 shrink-0 rounded-[24px] object-cover"
|
||||
>
|
||||
|
||||
<div class="min-w-0 space-y-2">
|
||||
<p class="text-base font-bold text-[#123824]">{{ item.productName }}</p>
|
||||
<p v-if="item.sku" class="text-xs font-semibold uppercase tracking-[0.14em] text-[#6a8a76]">
|
||||
SKU: {{ item.sku }}
|
||||
</p>
|
||||
|
||||
<div v-if="itemParameters(item).length > 0" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="parameter in itemParameters(item)"
|
||||
:key="parameter.label"
|
||||
class="rounded-full bg-[#edf7f1] px-3 py-1 text-xs font-semibold text-[#355947]"
|
||||
>
|
||||
{{ parameter.label }}: {{ parameter.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76] md:hidden">Цена</p>
|
||||
<input
|
||||
v-if="isPricingMode"
|
||||
:value="unitPriceDrafts?.[item.id] ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Например, 125.50"
|
||||
class="w-full rounded-2xl border border-[#d7e9de] bg-white px-4 py-3 text-sm text-[#123824] outline-none transition focus:border-[#139957] focus:shadow-[0_0_0_3px_rgba(19,153,87,0.12)] disabled:cursor-not-allowed disabled:bg-[#f4f8f5]"
|
||||
:disabled="disabled"
|
||||
@input="updateUnitPrice(item.id, $event)"
|
||||
>
|
||||
<p v-else class="text-sm font-semibold text-[#123824]">
|
||||
{{ formatPrice(currentUnitPrice(item)) || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76] md:hidden">Количество</p>
|
||||
<div v-if="isCartMode" class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full border border-[#d7e9de] bg-white text-lg font-semibold text-[#123824] transition hover:border-[#139957] hover:text-[#139957]"
|
||||
@click="decrementItem(item.id)"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span class="min-w-8 text-center text-sm font-semibold text-[#123824]">{{ item.quantity }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full border border-[#d7e9de] bg-white text-lg font-semibold text-[#123824] transition hover:border-[#139957] hover:text-[#139957]"
|
||||
@click="incrementItem(item.id)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 text-sm font-semibold text-[#d94b55] transition hover:text-[#b73742]"
|
||||
@click="removeItem(item.id)"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-sm font-semibold text-[#123824]">{{ item.quantity }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76] md:hidden">Итог</p>
|
||||
<p class="text-sm font-bold text-[#123824]">
|
||||
{{ formatPrice(currentLineTotal(item)) || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,7 +41,6 @@ function markerClass(state: 'done' | 'current' | 'upcoming') {
|
||||
@click="isExpanded = !isExpanded"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-[#5c7b69]">Текущий статус</p>
|
||||
<h2 class="text-2xl font-black leading-tight text-[#123824]">
|
||||
{{ presentation.title }}
|
||||
</h2>
|
||||
|
||||
@@ -965,7 +965,7 @@ export type OrderDetailQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type OrderDetailQuery = { __typename?: 'Query', order?: { __typename?: 'Order', id: string, code: string, kind: OrderKind, status: OrderStatus, 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 }> } | null };
|
||||
export type OrderDetailQuery = { __typename?: 'Query', order?: { __typename?: 'Order', id: string, code: string, kind: OrderKind, status: OrderStatus, customerId: string, deliveryAddress?: string | null, deliveryTerms?: string | null, deliveryFee?: number | null, totalPrice?: number | null, calculationPayload?: any | null, createdAt: any, items: Array<{ __typename?: 'OrderItem', id: string, productName: string, quantity: number, unitPrice?: number | null, lineTotal?: number | null }> } | null };
|
||||
|
||||
export type SubmitCalculationOrderMutationVariables = Exact<{
|
||||
input: SubmitCalculationOrderInput;
|
||||
@@ -2326,6 +2326,7 @@ export const OrderDetailDocument = gql`
|
||||
deliveryTerms
|
||||
deliveryFee
|
||||
totalPrice
|
||||
calculationPayload
|
||||
createdAt
|
||||
items {
|
||||
id
|
||||
|
||||
@@ -60,15 +60,6 @@ onMounted(() => {
|
||||
void fetchCart(true);
|
||||
});
|
||||
|
||||
function lineVolume(productId: string) {
|
||||
const item = cartItems.value.find((entry) => entry.productId === productId);
|
||||
if (!item) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Number(item.quantity) * Number(item.parameters.width) * Number(item.parameters.thickness);
|
||||
}
|
||||
|
||||
function increment(productId: string) {
|
||||
success.value = '';
|
||||
errorMessage.value = '';
|
||||
@@ -197,30 +188,22 @@ async function submitCart() {
|
||||
Корзина пока пустая. Добавьте товар из каталога.
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-3">
|
||||
<li
|
||||
v-for="item in cartItems"
|
||||
:key="item.productId"
|
||||
class="surface-card flex flex-col gap-3 rounded-3xl px-4 py-4 md:flex-row md:items-center md:justify-between md:px-5 md:py-5"
|
||||
>
|
||||
<div>
|
||||
<p class="font-semibold text-[#123824]">{{ item.productName }}</p>
|
||||
<p class="text-xs opacity-70">SKU: {{ item.sku }}</p>
|
||||
<p class="text-sm opacity-80">
|
||||
Объем: {{ lineVolume(item.productId) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-square btn-sm" @click="decrement(item.productId)">-</button>
|
||||
<span class="min-w-8 text-center font-semibold">{{ item.quantity }}</span>
|
||||
<button class="btn btn-square btn-sm" @click="increment(item.productId)">+</button>
|
||||
<button class="btn btn-ghost btn-sm text-error" @click="removeFromCart(item.productId)">
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<OrdersOrderItemsTable
|
||||
v-else
|
||||
mode="cart"
|
||||
:items="cartItems.map((item) => ({
|
||||
id: item.productId,
|
||||
productName: item.productName,
|
||||
sku: item.sku,
|
||||
quantity: item.quantity,
|
||||
parameters: item.parameters,
|
||||
unitPrice: null,
|
||||
lineTotal: null,
|
||||
}))"
|
||||
@increment="increment"
|
||||
@decrement="decrement"
|
||||
@remove="removeFromCart"
|
||||
/>
|
||||
|
||||
<div class="divider my-1" />
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
formatPrice,
|
||||
orderLogisticsStateText,
|
||||
orderDeliveryStateText,
|
||||
orderLineStateText,
|
||||
} from '~/composables/useOrderDetailPresentation';
|
||||
|
||||
definePageMeta({
|
||||
@@ -76,19 +75,6 @@ function parseMoneyDraft(value: string) {
|
||||
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 canEditOffer = computed(() => (
|
||||
@@ -233,29 +219,17 @@ watch(
|
||||
audience="manager"
|
||||
/>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li v-for="item in currentOrder.items" :key="item.id" class="manager-mini-card space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold text-[#123824]">{{ item.productName }} × {{ item.quantity }}</p>
|
||||
<p class="text-sm text-[#5c7b69]">{{ orderLineStateText(draftUnitPrice(item.id, item.unitPrice), draftLineTotal(item)) }}</p>
|
||||
</div>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="mb-2 text-xs font-semibold uppercase tracking-[0.22em] text-[#6a8a76]">Цена за единицу</span>
|
||||
<input
|
||||
v-model="itemPriceDrafts[item.id]"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Например, 125.50"
|
||||
class="input input-bordered w-full rounded-2xl bg-white"
|
||||
<OrdersOrderItemsTable
|
||||
class="mt-4"
|
||||
:items="currentOrder.items"
|
||||
:calculation-payload="currentOrder.calculationPayload"
|
||||
:editable="true"
|
||||
:unit-price-drafts="itemPriceDrafts"
|
||||
:disabled="!canEditOffer"
|
||||
>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
@update:unit-price="({ itemId, value }) => { itemPriceDrafts[itemId] = value; }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
type OrderDetailQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import {
|
||||
orderLogisticsStateText,
|
||||
orderDeliveryStateText,
|
||||
orderLineStateText,
|
||||
orderLogisticsStateText,
|
||||
} from '~/composables/useOrderDetailPresentation';
|
||||
|
||||
type OrderItem = NonNullable<OrderDetailQuery['order']>;
|
||||
@@ -48,18 +47,13 @@ const currentOrder = computed<OrderItem | null>(() =>
|
||||
audience="client"
|
||||
/>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li
|
||||
v-for="item in currentOrder.items"
|
||||
:key="item.id"
|
||||
class="manager-mini-card space-y-2"
|
||||
>
|
||||
<p class="text-sm font-semibold text-[#123824]">{{ item.productName }} × {{ item.quantity }}</p>
|
||||
<p class="text-sm text-[#5c7b69]">{{ orderLineStateText(item.unitPrice, item.lineTotal) }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<OrdersOrderItemsTable
|
||||
class="mt-4"
|
||||
:items="currentOrder.items"
|
||||
:calculation-payload="currentOrder.calculationPayload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
|
||||
@@ -9,6 +9,7 @@ query OrderDetail($id: ID!) {
|
||||
deliveryTerms
|
||||
deliveryFee
|
||||
totalPrice
|
||||
calculationPayload
|
||||
createdAt
|
||||
items {
|
||||
id
|
||||
|
||||
Reference in New Issue
Block a user