Files
web-frontend/app/components/orders/OrderItemsTable.vue
2026-04-06 11:12:10 +07:00

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>