Files
webapp/app/components/orders/OrdersCalendarPanel.vue
2026-04-11 08:31:34 +07:00

268 lines
8.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
type CalendarCheckpoint = {
code: string
name: string
plannedDate?: string | null
actualDate?: string | null
completed: boolean
current: boolean
}
type CalendarOrder = {
id: string
status: string
quotationId?: string | null
totalAmount: number
currency: string
createdAt: string
pickupDate?: string | null
fromAddress: {
city: string
country: string
}
toAddress: {
city: string
country: string
}
currentCheckpoint?: {
code: string
name: string
plannedDate?: string | null
completed: boolean
current: boolean
} | null
checkpoints: CalendarCheckpoint[]
}
const props = defineProps<{
orders: CalendarOrder[]
}>()
const emit = defineEmits<{
select: [orderId: string]
}>()
const weekdayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
function startOfMonthUtc(value: Date) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1))
}
function addMonthsUtc(value: Date, diff: number) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth() + diff, 1))
}
function formatDateKey(value: Date) {
return value.toISOString().slice(0, 10)
}
function toDateOnlyDate(value?: string | null) {
if (!value) return null
const normalized = String(value).trim()
if (!normalized) return null
const date = new Date(`${normalized}T00:00:00.000Z`)
if (Number.isNaN(date.getTime())) return null
return date
}
function orderAnchorCheckpoint(order: CalendarOrder) {
const plannedCurrent = order.checkpoints.find(checkpoint => checkpoint.current && checkpoint.plannedDate)
if (plannedCurrent) return plannedCurrent
const firstPending = order.checkpoints.find(checkpoint => !checkpoint.completed && checkpoint.plannedDate)
if (firstPending) return firstPending
const lastPlanned = [...order.checkpoints].reverse().find(checkpoint => checkpoint.plannedDate)
if (lastPlanned) return lastPlanned
return order.currentCheckpoint || null
}
function orderCalendarDate(order: CalendarOrder) {
const checkpointDate = orderAnchorCheckpoint(order)?.plannedDate
if (checkpointDate) return checkpointDate
if (order.pickupDate) return order.pickupDate
return order.createdAt.slice(0, 10)
}
function orderCalendarLabel(order: CalendarOrder) {
const checkpoint = orderAnchorCheckpoint(order)
if (checkpoint?.name) return checkpoint.name
return 'Дата уточняется'
}
function orderPersonLabel(order: CalendarOrder) {
if (order.quotationId) {
return `Клиент ${order.quotationId.slice(-6).toUpperCase()}`
}
return `Клиент ${order.id.slice(-6).toUpperCase()}`
}
function orderAvatarSeed(order: CalendarOrder) {
return order.quotationId || order.id
}
const initialMonth = computed(() => {
const firstOrderDate = props.orders
.map(order => toDateOnlyDate(orderCalendarDate(order)))
.find(Boolean)
return startOfMonthUtc(firstOrderDate || new Date())
})
const visibleMonth = ref(startOfMonthUtc(initialMonth.value))
watch(initialMonth, (nextValue) => {
visibleMonth.value = startOfMonthUtc(nextValue)
})
const monthLabel = computed(() => {
return new Intl.DateTimeFormat('ru-RU', {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}).format(visibleMonth.value)
})
const calendarOrdersByDay = computed(() => {
return props.orders.reduce<Record<string, CalendarOrder[]>>((acc, order) => {
const dateKey = orderCalendarDate(order)
if (!dateKey) return acc
if (!acc[dateKey]) acc[dateKey] = []
acc[dateKey].push(order)
return acc
}, {})
})
const monthCells = computed(() => {
const firstDay = visibleMonth.value
const firstWeekday = (firstDay.getUTCDay() + 6) % 7
const gridStart = new Date(firstDay.getTime())
gridStart.setUTCDate(gridStart.getUTCDate() - firstWeekday)
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(gridStart.getTime())
date.setUTCDate(gridStart.getUTCDate() + index)
const dateKey = formatDateKey(date)
return {
key: dateKey,
date,
dateKey,
inCurrentMonth: date.getUTCMonth() === visibleMonth.value.getUTCMonth(),
isToday: dateKey === formatDateKey(new Date()),
orders: calendarOrdersByDay.value[dateKey] || [],
}
})
})
function previousMonth() {
visibleMonth.value = addMonthsUtc(visibleMonth.value, -1)
}
function nextMonth() {
visibleMonth.value = addMonthsUtc(visibleMonth.value, 1)
}
function openOrder(orderId: string) {
emit('select', orderId)
}
</script>
<template>
<section class="rounded-[28px] bg-white p-4 md:p-5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Orders calendar</p>
<p class="mt-1 text-lg font-black capitalize text-[#2f2418]">{{ monthLabel }}</p>
</div>
<div class="inline-flex items-center rounded-full bg-[#f6f1ea] p-1">
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-full text-[#5f4b33] transition hover:bg-white"
aria-label="Previous month"
@click="previousMonth"
>
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-full text-[#5f4b33] transition hover:bg-white"
aria-label="Next month"
@click="nextMonth"
>
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 6 6 6-6 6" />
</svg>
</button>
</div>
</div>
<div class="mt-4 grid grid-cols-7 gap-2">
<div
v-for="weekday in weekdayLabels"
:key="weekday"
class="px-1 text-center text-[11px] font-bold uppercase tracking-[0.12em] text-[#8c7b67]"
>
{{ weekday }}
</div>
<article
v-for="cell in monthCells"
:key="cell.key"
class="flex min-h-[132px] flex-col rounded-[22px] border border-[#e6ddd1] bg-[#fbf8f4] p-2.5"
:class="[
cell.inCurrentMonth ? 'opacity-100' : 'opacity-45',
cell.isToday ? 'ring-2 ring-[#8bc7f2]/70' : '',
]"
>
<div class="mb-2 flex items-center justify-between gap-2">
<span class="text-sm font-black text-[#2f2418]">{{ cell.date.getUTCDate() }}</span>
<span v-if="cell.orders.length" class="rounded-full bg-white px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.1em] text-[#5f4b33]">
{{ cell.orders.length }}
</span>
</div>
<div class="flex flex-1 flex-col gap-1.5">
<button
v-for="order in cell.orders.slice(0, 3)"
:key="order.id"
type="button"
class="rounded-[16px] bg-white px-2.5 py-2 text-left transition hover:shadow-[0_12px_30px_rgba(38,29,18,0.14)]"
@click="openOrder(order.id)"
>
<div class="flex items-start gap-2">
<UserAvatar
:seed="orderAvatarSeed(order)"
:label="orderPersonLabel(order)"
:size="26"
/>
<div class="min-w-0">
<p class="truncate text-[11px] font-black text-[#2f2418]">
{{ orderPersonLabel(order) }}
</p>
<p class="truncate text-xs font-black text-[#2f2418]">
{{ order.fromAddress.city }} {{ order.toAddress.city }}
</p>
<p class="mt-0.5 truncate text-[11px] text-[#7c6d5d]">
{{ orderCalendarLabel(order) }}
</p>
</div>
</div>
</button>
<div
v-if="cell.orders.length > 3"
class="rounded-[16px] bg-white/70 px-2.5 py-2 text-[11px] font-semibold text-[#7c6d5d]"
>
Ещё {{ cell.orders.length - 3 }}
</div>
</div>
</article>
</div>
</section>
</template>