268 lines
8.1 KiB
Vue
268 lines
8.1 KiB
Vue
<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>
|