Adopt logistics visual system across webapp
This commit is contained in:
267
app/components/orders/OrdersCalendarPanel.vue
Normal file
267
app/components/orders/OrdersCalendarPanel.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user