251 lines
8.5 KiB
Vue
251 lines
8.5 KiB
Vue
<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;
|
|
framed?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
'update:unit-price': [payload: { itemId: string; value: string }];
|
|
increment: [itemId: string];
|
|
decrement: [itemId: string];
|
|
remove: [itemId: string];
|
|
}>();
|
|
|
|
const coverPresets = ['#edf3ef', '#f1f4ee', '#edf2f4'];
|
|
|
|
function createProductCover(name: string, seedKey: string) {
|
|
const seed = `${name}${seedKey}`.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
const background = 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">
|
|
<rect width="120" height="120" fill="${background}" rx="28" />
|
|
<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');
|
|
const isFramed = computed(() => props.framed ?? true);
|
|
|
|
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="isFramed ? 'surface-card rounded-3xl p-5' : ''">
|
|
<div class="hidden pb-1 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="surface-card rounded-[28px] 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] bg-[#edf3ef] 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-[#f3f5f4] 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 bg-[#f3f5f4] px-4 py-3 text-sm text-[#123824] outline-none transition 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 bg-[#f3f5f4] text-lg font-semibold text-[#123824] transition hover:bg-[#e8efea] 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 bg-[#f3f5f4] text-lg font-semibold text-[#123824] transition hover:bg-[#e8efea] 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>
|