Adopt logistics visual system across webapp

This commit is contained in:
Ruslan Bakiev
2026-04-11 08:31:34 +07:00
parent ebe72907a4
commit a74e75049c
28 changed files with 1434 additions and 240 deletions

View 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>