Add unified order summary card
This commit is contained in:
177
app/components/orders/OrderSummaryCard.vue
Normal file
177
app/components/orders/OrderSummaryCard.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||||
|
import { formatPrice } from '~/composables/useOrderDetailPresentation';
|
||||||
|
|
||||||
|
type OrderCardItem = {
|
||||||
|
id: string;
|
||||||
|
productName: string;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomerCardMeta = {
|
||||||
|
name?: string | null;
|
||||||
|
avatarSrc?: string | null;
|
||||||
|
initials?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
to: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
totalPrice?: number | null;
|
||||||
|
items: OrderCardItem[];
|
||||||
|
customer?: CustomerCardMeta | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const DATE_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const coverPresets = [
|
||||||
|
['#d9f5e6', '#9ce8c1', '#6fd09d'],
|
||||||
|
['#eaf9ef', '#b3e8cb', '#76c89f'],
|
||||||
|
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatCreatedAt(value: string | Date) {
|
||||||
|
return DATE_FORMATTER.format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitials(value?: string | null) {
|
||||||
|
const parts = String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
|
if (!parts.length) {
|
||||||
|
return 'FR';
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.map((part) => part.charAt(0).toUpperCase()).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemCountLabel(count: number) {
|
||||||
|
const mod10 = count % 10;
|
||||||
|
const mod100 = count % 100;
|
||||||
|
|
||||||
|
if (mod10 === 1 && mod100 !== 11) {
|
||||||
|
return `${count} позиция`;
|
||||||
|
}
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) {
|
||||||
|
return `${count} позиции`;
|
||||||
|
}
|
||||||
|
return `${count} позиций`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 88 88">
|
||||||
|
<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="88" height="88" fill="url(#g)" rx="24" />
|
||||||
|
<g opacity="0.16">
|
||||||
|
<circle cx="72" cy="18" r="20" fill="#0f7a49" />
|
||||||
|
<circle cx="14" cy="80" r="22" fill="#0f7a49" />
|
||||||
|
</g>
|
||||||
|
<text x="50%" y="56%" text-anchor="middle" fill="#11412c" font-family="Manrope, sans-serif" font-size="34" font-weight="700">${firstLetter}</text>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleItems = computed(() => props.items.slice(0, 4));
|
||||||
|
const hiddenCount = computed(() => Math.max(props.items.length - visibleItems.value.length, 0));
|
||||||
|
const totalPriceLabel = computed(() => formatPrice(props.totalPrice) ?? 'Сумма уточняется');
|
||||||
|
const customerName = computed(() => String(props.customer?.name ?? '').trim());
|
||||||
|
const customerInitials = computed(() => (
|
||||||
|
String(props.customer?.initials ?? '').trim() || buildInitials(customerName.value)
|
||||||
|
));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
:to="to"
|
||||||
|
class="surface-card group block rounded-[30px] border border-[#dbece2] bg-white px-4 py-4 transition hover:-translate-y-0.5 hover:shadow-[0_18px_40px_rgba(18,56,36,0.08)] md:px-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div v-if="customer" class="shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="customer?.avatarSrc"
|
||||||
|
:src="customer.avatarSrc"
|
||||||
|
:alt="customerName || code"
|
||||||
|
class="h-14 w-14 rounded-[20px] object-cover"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-14 w-14 items-center justify-center rounded-[20px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-base font-black text-[#123824]"
|
||||||
|
>
|
||||||
|
{{ customerInitials }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<h2 class="text-lg font-bold text-[#123824]">{{ code }}</h2>
|
||||||
|
<p class="text-sm text-[#688676]">{{ formatCreatedAt(createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="customerName" class="mt-1 truncate text-sm font-semibold text-[#355947]">
|
||||||
|
{{ customerName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3 lg:justify-end">
|
||||||
|
<div class="text-left lg:text-right">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Сумма</p>
|
||||||
|
<p class="text-sm font-bold text-[#123824]">{{ totalPriceLabel }}</p>
|
||||||
|
</div>
|
||||||
|
<OrderStatusBadge :status="status" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<div class="flex -space-x-3">
|
||||||
|
<div
|
||||||
|
v-for="item in visibleItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="h-11 w-11 overflow-hidden rounded-[16px] border-2 border-white bg-[#edf8f1] shadow-[0_6px_16px_rgba(18,56,36,0.08)]"
|
||||||
|
:title="`${item.productName} × ${item.quantity}`"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="createProductCover(item.productName, item.id)"
|
||||||
|
:alt="item.productName"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="hiddenCount > 0"
|
||||||
|
class="flex h-11 w-11 items-center justify-center rounded-[16px] border-2 border-white bg-[#123824] text-xs font-bold text-white shadow-[0_6px_16px_rgba(18,56,36,0.14)]"
|
||||||
|
>
|
||||||
|
+{{ hiddenCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-[#5c7b69]">
|
||||||
|
{{ itemCountLabel(items.length) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
@@ -3,23 +3,27 @@ import { useQuery } from '@vue/apollo-composable';
|
|||||||
import FullCalendar from '@fullcalendar/vue3';
|
import FullCalendar from '@fullcalendar/vue3';
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
import ruLocale from '@fullcalendar/core/locales/ru';
|
import ruLocale from '@fullcalendar/core/locales/ru';
|
||||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
|
||||||
import {
|
import {
|
||||||
ManagerOrdersDocument,
|
ManagerOrdersDocument,
|
||||||
|
ManagerUsersDocument,
|
||||||
type ManagerOrdersQuery,
|
type ManagerOrdersQuery,
|
||||||
|
type ManagerUsersQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
|
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
|
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
|
||||||
|
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
||||||
type StatusFilter = 'ALL' | 'NEW' | 'PRICED' | 'IN_PROGRESS' | 'CLOSED';
|
type StatusFilter = 'ALL' | 'NEW' | 'PRICED' | 'IN_PROGRESS' | 'CLOSED';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const ordersQuery = useQuery(ManagerOrdersDocument, { status: null });
|
const ordersQuery = useQuery(ManagerOrdersDocument, { status: null });
|
||||||
|
const usersQuery = useQuery(ManagerUsersDocument);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const statusFilter = ref<StatusFilter>('ALL');
|
const statusFilter = ref<StatusFilter>('ALL');
|
||||||
|
|
||||||
@@ -40,6 +44,32 @@ function setViewMode(view: 'cards' | 'calendar' | 'kanban') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usersById = computed<Record<string, ManagerUserItem>>(() => Object.fromEntries(
|
||||||
|
(usersQuery.result.value?.managerUsers ?? []).map((user) => [user.id, user]),
|
||||||
|
));
|
||||||
|
|
||||||
|
function customerCardMeta(customerId: string) {
|
||||||
|
const customer = usersById.value[customerId];
|
||||||
|
if (!customer) {
|
||||||
|
return {
|
||||||
|
name: customerId,
|
||||||
|
avatarSrc: '',
|
||||||
|
initials: customerId.slice(0, 2).toUpperCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: customer.fullName,
|
||||||
|
avatarSrc: messengerConnectionAvatarSrc(customer.telegramConnection),
|
||||||
|
initials: customer.fullName
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase())
|
||||||
|
.join(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getOrderGroup(order: ManagerOrderItem) {
|
function getOrderGroup(order: ManagerOrderItem) {
|
||||||
if (order.status === 'NEW' || order.status === 'MANAGER_PROCESSING') {
|
if (order.status === 'NEW' || order.status === 'MANAGER_PROCESSING') {
|
||||||
return 'NEW';
|
return 'NEW';
|
||||||
@@ -68,6 +98,7 @@ const searchedOrders = computed<ManagerOrderItem[]>(() => {
|
|||||||
const text = [
|
const text = [
|
||||||
order.code,
|
order.code,
|
||||||
order.customerId,
|
order.customerId,
|
||||||
|
customerCardMeta(order.customerId).name,
|
||||||
order.deliveryAddress || '',
|
order.deliveryAddress || '',
|
||||||
...order.items.map((item) => item.productName),
|
...order.items.map((item) => item.productName),
|
||||||
]
|
]
|
||||||
@@ -248,68 +279,33 @@ const calendarOptions = computed(() => ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mt-4 space-y-3">
|
<div v-else class="mt-4 space-y-3">
|
||||||
<NuxtLink
|
<OrdersOrderSummaryCard
|
||||||
v-for="order in column.orders"
|
v-for="order in column.orders"
|
||||||
:key="order.id"
|
:key="order.id"
|
||||||
:to="`/client-orders/${order.id}`"
|
:to="`/client-orders/${order.id}`"
|
||||||
class="block rounded-[28px] border border-[#dbece2] bg-white p-4 shadow-[0_18px_40px_rgba(18,56,36,0.08)] transition hover:-translate-y-0.5"
|
:code="order.code"
|
||||||
>
|
:status="order.status"
|
||||||
<div class="flex items-start justify-between gap-3">
|
:created-at="order.createdAt"
|
||||||
<div class="space-y-1">
|
:total-price="order.totalPrice"
|
||||||
<h3 class="text-base font-bold text-[#123824]">{{ order.code }}</h3>
|
:items="order.items"
|
||||||
<p class="text-sm text-[#5c7b69]">Клиент: {{ order.customerId }}</p>
|
:customer="customerCardMeta(order.customerId)"
|
||||||
</div>
|
/>
|
||||||
<OrderStatusBadge :status="order.status" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-3 text-sm text-[#5c7b69]">Создан: {{ new Date(order.createdAt).toLocaleString() }}</p>
|
|
||||||
<p v-if="order.deliveryAddress" class="mt-1 text-sm text-[#5c7b69]">{{ order.deliveryAddress }}</p>
|
|
||||||
|
|
||||||
<ul class="mt-3 space-y-2 text-sm text-[#214735]">
|
|
||||||
<li
|
|
||||||
v-for="item in order.items.slice(0, 3)"
|
|
||||||
:key="item.id"
|
|
||||||
class="rounded-2xl border border-[#e2efe7] bg-[#f8fcf9] px-3 py-2"
|
|
||||||
>
|
|
||||||
{{ item.productName }} × {{ item.quantity }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p v-if="order.items.length > 3" class="mt-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">
|
|
||||||
ещё {{ order.items.length - 3 }} поз.
|
|
||||||
</p>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<NuxtLink
|
<OrdersOrderSummaryCard
|
||||||
v-for="order in filteredOrders"
|
v-for="order in filteredOrders"
|
||||||
:key="order.id"
|
:key="order.id"
|
||||||
:to="`/client-orders/${order.id}`"
|
:to="`/client-orders/${order.id}`"
|
||||||
class="surface-card block rounded-3xl p-5"
|
:code="order.code"
|
||||||
>
|
:status="order.status"
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
:created-at="order.createdAt"
|
||||||
<div class="space-y-1">
|
:total-price="order.totalPrice"
|
||||||
<h2 class="text-lg font-bold text-[#123824]">{{ order.code }}</h2>
|
:items="order.items"
|
||||||
<p class="text-sm text-[#5c7b69]">Клиент: {{ order.customerId }}</p>
|
:customer="customerCardMeta(order.customerId)"
|
||||||
<p class="text-sm text-[#5c7b69]">Создан: {{ new Date(order.createdAt).toLocaleString() }}</p>
|
/>
|
||||||
<p v-if="order.deliveryAddress" class="text-sm text-[#5c7b69]">Адрес: {{ order.deliveryAddress }}</p>
|
|
||||||
</div>
|
|
||||||
<OrderStatusBadge :status="order.status" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="mt-4 grid gap-2 text-sm text-[#214735]">
|
|
||||||
<li
|
|
||||||
v-for="item in order.items"
|
|
||||||
:key="item.id"
|
|
||||||
class="rounded-2xl border border-[#d6ebde] bg-white px-4 py-3"
|
|
||||||
>
|
|
||||||
{{ item.productName }} × {{ item.quantity }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -178,21 +178,21 @@ async function rejectRequest() {
|
|||||||
У пользователя пока нет заказов.
|
У пользователя пока нет заказов.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="mt-4 space-y-3">
|
<div v-else class="mt-4 space-y-3">
|
||||||
<NuxtLink
|
<OrdersOrderSummaryCard
|
||||||
v-for="order in currentUserOrders"
|
v-for="order in currentUserOrders"
|
||||||
:key="order.id"
|
:key="order.id"
|
||||||
:to="`/client-orders/${order.id}`"
|
:to="`/client-orders/${order.id}`"
|
||||||
class="manager-mini-card block"
|
:code="order.code"
|
||||||
>
|
:status="order.status"
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
:created-at="order.createdAt"
|
||||||
<div class="space-y-1">
|
:total-price="order.totalPrice"
|
||||||
<p class="text-sm font-semibold text-[#123824]">{{ order.code }}</p>
|
:items="order.items"
|
||||||
<p class="text-sm text-[#355947]">{{ new Date(order.createdAt).toLocaleString() }}</p>
|
:customer="{
|
||||||
<p v-if="order.deliveryAddress" class="text-sm text-[#355947]">{{ order.deliveryAddress }}</p>
|
name: currentUser.fullName,
|
||||||
</div>
|
avatarSrc: messengerConnectionAvatarSrc(currentUser.telegramConnection),
|
||||||
<p class="text-sm font-semibold text-[#466653]">{{ order.status }}</p>
|
initials: userInitials(currentUser.fullName),
|
||||||
</div>
|
}"
|
||||||
</NuxtLink>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from '@vue/apollo-composable';
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
|
||||||
import {
|
import {
|
||||||
MyOrdersDocument,
|
MyOrdersDocument,
|
||||||
type MyOrdersQuery,
|
type MyOrdersQuery,
|
||||||
@@ -69,34 +68,16 @@ const filteredOrders = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<NuxtLink
|
<OrdersOrderSummaryCard
|
||||||
v-for="order in filteredOrders"
|
v-for="order in filteredOrders"
|
||||||
:key="order.id"
|
:key="order.id"
|
||||||
:to="`/orders/${order.id}`"
|
:to="`/orders/${order.id}`"
|
||||||
class="surface-card block rounded-3xl p-4 md:p-5"
|
:code="order.code"
|
||||||
>
|
:status="order.status"
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
:created-at="order.createdAt"
|
||||||
<div class="space-y-1">
|
:total-price="order.totalPrice"
|
||||||
<h2 class="text-lg font-bold text-[#123824]">{{ order.code }}</h2>
|
:items="order.items"
|
||||||
<p class="text-sm text-[#355947]">Создан: {{ new Date(order.createdAt).toLocaleString() }}</p>
|
/>
|
||||||
<p v-if="order.deliveryAddress" class="text-sm text-[#355947]">Адрес: {{ order.deliveryAddress }}</p>
|
|
||||||
<p v-if="order.deliveryTerms" class="text-sm text-[#355947]">Доставка: {{ order.deliveryTerms }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-end gap-2">
|
|
||||||
<OrderStatusBadge :status="order.status" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="mt-4 grid gap-2 text-sm text-[#214735]">
|
|
||||||
<li
|
|
||||||
v-for="item in order.items"
|
|
||||||
:key="item.id"
|
|
||||||
class="rounded-2xl border border-[#d6ebde] bg-white px-4 py-3"
|
|
||||||
>
|
|
||||||
{{ item.productName }} × {{ item.quantity }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user