Adopt logistics visual system across webapp
This commit is contained in:
10
app/app.vue
10
app/app.vue
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<NuxtLoadingIndicator :height="4" :duration="3000" color="#16a34a" />
|
||||
<NuxtLoadingIndicator :height="4" :duration="3000" color="#2f2418" />
|
||||
|
||||
<Transition name="route-loader-fade">
|
||||
<div
|
||||
v-if="routeLoading"
|
||||
class="fixed inset-0 z-[999] flex items-center justify-center bg-base-100/45 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-[999] flex items-center justify-center bg-[#f5efe7]/65 backdrop-blur-sm"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="flex items-center gap-3 rounded-full border border-base-300 bg-base-100/90 px-5 py-3 shadow-xl">
|
||||
<span class="loading loading-spinner loading-md text-primary" />
|
||||
<div class="flex items-center gap-3 rounded-full border border-[#ded2bf] bg-white/92 px-5 py-3 shadow-[0_18px_40px_rgba(47,36,24,0.12)]">
|
||||
<span class="loading loading-spinner loading-md text-[#2f2418]" />
|
||||
<span class="text-sm font-medium text-base-content">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@ const { t } = useI18n()
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
'data-theme': 'cupcake',
|
||||
'data-theme': 'silk',
|
||||
},
|
||||
script: []
|
||||
})
|
||||
|
||||
@@ -210,3 +210,12 @@
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
.manager-logistics-shell {
|
||||
min-height: 100vh;
|
||||
font-family: "Onest", "Avenir Next", "Trebuchet MS", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, #fdf8ea 0%, rgba(253, 248, 234, 0) 40%),
|
||||
radial-gradient(circle at 90% 100%, #e9f2ff 0%, rgba(233, 242, 255, 0) 35%),
|
||||
linear-gradient(135deg, #f7f5f1 0%, #f1eee8 100%);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<footer class="bg-base-200 text-base-content/80 py-6">
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8">
|
||||
<footer class="px-3 pb-8 pt-2 text-white md:px-4">
|
||||
<div class="mx-auto max-w-[1440px] rounded-[28px] border border-white/18 bg-[#10223b] px-6 py-6 shadow-[0_22px_54px_rgba(16,34,59,0.24)] [background-image:radial-gradient(circle_at_82%_18%,rgba(244,89,69,0.34),rgba(244,89,69,0)_34%),linear-gradient(130deg,#10223b_0%,#193450_100%)]">
|
||||
<div class="text-center">
|
||||
<p class="text-sm">© 2025 Optovia. {{ $t('footer.rights') }}</p>
|
||||
<p class="text-sm font-medium text-white/82">© 2025 Optovia. {{ $t('footer.rights') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,123 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { profileAvatarSeedFromValue, profileAvatarUrl } from '~/utils/profileAvatars'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
avatarSeed?: string | null
|
||||
seed?: string | null
|
||||
label?: string | null
|
||||
size?: number
|
||||
}>(), {
|
||||
avatarSeed: '',
|
||||
seed: '',
|
||||
label: '',
|
||||
size: 48,
|
||||
})
|
||||
|
||||
const normalizedSeed = computed(() => {
|
||||
const source = props.avatarSeed || props.seed || props.label || 'person'
|
||||
return profileAvatarSeedFromValue(source)
|
||||
})
|
||||
|
||||
const avatarSrc = computed(() => profileAvatarUrl(normalizedSeed.value))
|
||||
const avatarStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<!-- Avatar -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-24 h-24 rounded-full overflow-hidden border-4 border-base-300 shadow-lg"
|
||||
:class="{ 'animate-pulse bg-base-200': loading }"
|
||||
>
|
||||
<div
|
||||
v-if="!loading && avatarSvg"
|
||||
v-html="avatarSvg"
|
||||
class="w-full h-full"
|
||||
/>
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="w-full h-full bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center text-primary-content text-2xl font-bold"
|
||||
>
|
||||
{{ initials }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change avatar button -->
|
||||
<button
|
||||
@click="regenerateAvatar"
|
||||
:disabled="loading"
|
||||
class="absolute -bottom-1 -right-1 w-8 h-8 bg-primary hover:bg-primary/80 disabled:bg-base-300 text-primary-content rounded-full flex items-center justify-center shadow-lg transition-colors"
|
||||
:title="$t('profile.regenerate_avatar')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User name -->
|
||||
<div class="text-center">
|
||||
<p class="font-semibold text-base-content">{{ displayName }}</p>
|
||||
<p class="text-sm text-base-content/60" v-if="userId">ID: {{ userId }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-[34%] border border-white/80 bg-white shadow-[0_12px_28px_rgba(47,36,24,0.16)]"
|
||||
:style="avatarStyle"
|
||||
:title="label || undefined"
|
||||
role="img"
|
||||
:aria-label="label ? `Аватар: ${label}` : 'Аватар'"
|
||||
>
|
||||
<img :src="avatarSrc" :alt="label || 'Аватар'" class="h-full w-full object-cover" loading="lazy">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
userId: String,
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
avatarId: String,
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['avatar-changed'])
|
||||
|
||||
const loading = ref(false)
|
||||
const avatarSvg = ref('')
|
||||
|
||||
// Computed properties
|
||||
const displayName = computed(() => {
|
||||
const first = props.firstName || ''
|
||||
const last = props.lastName || ''
|
||||
return `${first} ${last}`.trim() || 'User'
|
||||
})
|
||||
|
||||
const initials = computed(() => {
|
||||
const first = props.firstName?.charAt(0) || ''
|
||||
const last = props.lastName?.charAt(0) || ''
|
||||
return (first + last).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
// Generate avatar via DiceBear API
|
||||
const generateAvatar = async (seed) => {
|
||||
if (!seed) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// Use DiceBear API to generate SVG avatar
|
||||
const response = await fetch(`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b6e3f4,c0aede,d1d4f9`)
|
||||
|
||||
if (response.ok) {
|
||||
avatarSvg.value = await response.text()
|
||||
} else {
|
||||
console.error('Failed to generate avatar:', response.status)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating avatar:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new random avatar ID
|
||||
const regenerateAvatar = async () => {
|
||||
if (!props.editable || loading.value) return
|
||||
|
||||
const newAvatarId = Math.random().toString(36).substring(2, 15)
|
||||
|
||||
// Update avatar locally first
|
||||
await generateAvatar(newAvatarId)
|
||||
|
||||
// Notify parent about avatar change
|
||||
emit('avatar-changed', newAvatarId)
|
||||
}
|
||||
|
||||
// Watch avatarId changes
|
||||
watch(() => props.avatarId, (newAvatarId) => {
|
||||
if (newAvatarId) {
|
||||
generateAvatar(newAvatarId)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// If no avatarId, generate deterministic one based on userId
|
||||
onMounted(async () => {
|
||||
if (!props.avatarId && props.userId) {
|
||||
// Build deterministic ID from userId
|
||||
const fallbackSeed = props.userId
|
||||
await generateAvatar(fallbackSeed)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
46
app/components/manager/ManagerListLoadMore.vue
Normal file
46
app/components/manager/ManagerListLoadMore.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
shown: number
|
||||
total?: number | null
|
||||
canLoadMore?: boolean
|
||||
loading?: boolean
|
||||
pageSize?: number
|
||||
itemLabel?: string
|
||||
}>(), {
|
||||
total: null,
|
||||
canLoadMore: false,
|
||||
loading: false,
|
||||
pageSize: 12,
|
||||
itemLabel: 'элементов',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadMore: []
|
||||
}>()
|
||||
|
||||
const summaryText = computed(() => {
|
||||
if (typeof props.total === 'number') {
|
||||
return `Показано ${props.shown} из ${props.total} ${props.itemLabel}`
|
||||
}
|
||||
|
||||
return `Загружено ${props.shown} ${props.itemLabel}`
|
||||
})
|
||||
|
||||
const buttonText = computed(() => props.loading ? 'Загружаем...' : `Загрузить ещё ${props.pageSize}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-3 rounded-[28px] bg-white/82 px-5 py-5 text-center text-sm text-[#6f6353] shadow-none">
|
||||
<p>{{ summaryText }}</p>
|
||||
<button
|
||||
v-if="canLoadMore"
|
||||
type="button"
|
||||
class="btn rounded-full border-0 bg-[#2f2418] px-6 text-white hover:bg-[#493824]"
|
||||
:disabled="loading"
|
||||
@click="emit('loadMore')"
|
||||
>
|
||||
<span v-if="loading" class="loading loading-spinner loading-sm" />
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -369,7 +369,7 @@ const props = withDefaults(defineProps<{
|
||||
userAvatarSvg?: string
|
||||
userName?: string
|
||||
userInitials?: string
|
||||
theme?: 'cupcake' | 'night'
|
||||
theme?: 'silk' | 'night'
|
||||
userData?: {
|
||||
id?: string
|
||||
activeTeam?: { name?: string; teamType?: string }
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<nav v-if="items.length > 0" class="bg-base-100 shadow-sm">
|
||||
<div class="flex items-center gap-1 py-2 px-4 lg:px-6 overflow-x-auto">
|
||||
<nav v-if="items.length > 0" class="mx-auto mt-2 w-full max-w-[2200px] px-3 md:px-4">
|
||||
<div class="flex items-center gap-1 overflow-x-auto rounded-[24px] border border-[#e2d8ca] bg-[#efe6d8]/92 px-3 py-2 shadow-[0_14px_34px_rgba(47,36,24,0.08)] backdrop-blur">
|
||||
<NuxtLink
|
||||
v-for="item in items"
|
||||
:key="item.path"
|
||||
:to="localePath(item.path)"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap text-base-content/70 hover:text-base-content hover:bg-base-200"
|
||||
:class="{ 'text-primary bg-primary/10': isActive(item.path) }"
|
||||
class="rounded-full px-4 py-2 text-sm font-bold whitespace-nowrap transition-colors text-[#5f4b33] hover:bg-[#f8f3ec]"
|
||||
:class="{ 'bg-[#2f2418] text-white shadow-[0_10px_24px_rgba(47,36,24,0.16)]': isActive(item.path) }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
@@ -49,4 +49,3 @@ const isActive = (path: string) => {
|
||||
return route.path === localePath(path) || route.path.startsWith(localePath(path) + '/')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
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>
|
||||
@@ -17,22 +17,22 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const variantMap: Record<string, string> = {
|
||||
default: 'badge-neutral',
|
||||
success: 'badge-success',
|
||||
warning: 'badge-warning',
|
||||
error: 'badge-error',
|
||||
muted: 'badge-ghost',
|
||||
primary: 'badge-primary',
|
||||
default: 'bg-[#f6f1ea] text-[#5f4b33]',
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
warning: 'bg-amber-100 text-amber-700',
|
||||
error: 'bg-rose-100 text-rose-700',
|
||||
muted: 'bg-[#efe7da] text-[#8a7761]',
|
||||
primary: 'bg-[#2f2418] text-white',
|
||||
}
|
||||
|
||||
const sizeMap: Record<string, string> = {
|
||||
xs: 'badge-xs',
|
||||
sm: 'badge-sm',
|
||||
md: 'badge-md',
|
||||
xs: 'px-2 py-1 text-[10px]',
|
||||
sm: 'px-3 py-1 text-xs',
|
||||
md: 'px-3.5 py-1.5 text-sm',
|
||||
}
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
const base = 'badge'
|
||||
const base = 'inline-flex items-center rounded-full font-semibold'
|
||||
const variantClass = variantMap[props.variant] || variantMap.default
|
||||
const sizeClass = sizeMap[props.size] || sizeMap.sm
|
||||
return [base, variantClass, sizeClass].join(' ')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<component
|
||||
:is="componentTag"
|
||||
:type="componentType"
|
||||
:class="['btn', variantClass, fullWidth ? 'w-full' : '']"
|
||||
:class="[baseClass, variantClass, fullWidth ? 'w-full' : '']"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
@@ -36,10 +36,11 @@ const componentTag = computed(() => {
|
||||
return props.as || 'button'
|
||||
})
|
||||
const componentType = computed(() => (props.as === 'button' ? props.type : undefined))
|
||||
const baseClass = 'inline-flex items-center justify-center gap-2 rounded-full border-0 px-5 py-3 text-sm font-bold transition duration-200'
|
||||
|
||||
const variantClass = computed(() => {
|
||||
if (props.variant === 'outline') return 'btn-outline btn-primary'
|
||||
if (props.variant === 'ghost') return 'btn-ghost'
|
||||
return 'btn-primary'
|
||||
if (props.variant === 'outline') return 'bg-transparent text-[#2f2418] ring-1 ring-[#cbbca6] hover:bg-[#f6f1ea]'
|
||||
if (props.variant === 'ghost') return 'bg-[#f6f1ea] text-[#5f4b33] hover:bg-[#ece2d3]'
|
||||
return 'bg-[#2f2418] text-white shadow-[0_12px_28px_rgba(47,36,24,0.16)] hover:bg-[#493824]'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -27,18 +27,18 @@ const paddingMap: Record<string, string> = {
|
||||
}
|
||||
|
||||
const toneMap: Record<string, string> = {
|
||||
default: 'bg-base-100',
|
||||
muted: 'bg-base-200',
|
||||
primary: 'bg-primary/10',
|
||||
default: 'bg-white',
|
||||
muted: 'bg-[#fbf8f4]',
|
||||
primary: 'bg-[#f6f1ea]',
|
||||
}
|
||||
|
||||
const cardClass = computed(() => {
|
||||
const paddingClass = paddingMap[props.padding] || paddingMap.medium
|
||||
const toneClass = toneMap[props.tone] || toneMap.default
|
||||
const interactiveClass = props.interactive
|
||||
? 'cursor-pointer hover:shadow-lg transition-shadow duration-200'
|
||||
? 'cursor-pointer transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]'
|
||||
: ''
|
||||
const baseClass = 'card'
|
||||
const baseClass = 'rounded-[28px] border border-[#eadfce] text-[#2f2418] shadow-none'
|
||||
return [baseClass, paddingClass, toneClass, interactiveClass].filter(Boolean).join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<label v-if="label" class="w-full space-y-1">
|
||||
<span class="text-base font-semibold text-base-content">{{ label }}</span>
|
||||
<label v-if="label" class="w-full space-y-2">
|
||||
<span class="text-sm font-bold uppercase tracking-[0.12em] text-[#8a7761]">{{ label }}</span>
|
||||
<input
|
||||
v-bind="$attrs"
|
||||
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
|
||||
class="h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 text-[#2f2418] shadow-none outline-none placeholder:text-[#9b8d79]"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
/>
|
||||
@@ -11,7 +11,7 @@
|
||||
<input
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
|
||||
class="h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 text-[#2f2418] shadow-none outline-none placeholder:text-[#9b8d79]"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl lg:text-3xl font-bold text-base-content">{{ title }}</h1>
|
||||
<p v-if="description" class="text-base-content/70">{{ description }}</p>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Workspace</p>
|
||||
<h1 class="text-2xl font-black text-[#2f2418] lg:text-3xl">{{ title }}</h1>
|
||||
<p v-if="description" class="max-w-[720px] text-sm leading-6 text-[#6f6353]">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions || actions?.length" class="flex items-center gap-2 flex-shrink-0">
|
||||
<slot name="actions">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<component
|
||||
:is="to ? NuxtLink : 'button'"
|
||||
:to="to"
|
||||
class="btn btn-sm btn-ghost gap-2"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-[#f6f1ea] px-4 py-2 text-sm font-bold text-[#5f4b33] transition hover:bg-[#ece2d3]"
|
||||
@click="!to && $emit('click')"
|
||||
>
|
||||
<Icon v-if="icon" :name="icon" size="16" />
|
||||
|
||||
@@ -21,20 +21,20 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const variantMap: Record<string, string> = {
|
||||
neutral: 'badge-neutral',
|
||||
primary: 'badge-primary',
|
||||
outline: 'badge-outline',
|
||||
inverse: 'badge-ghost bg-white/10 text-white border border-white/40',
|
||||
neutral: 'bg-[#f6f1ea] text-[#5f4b33]',
|
||||
primary: 'bg-[#2f2418] text-white',
|
||||
outline: 'bg-transparent text-[#2f2418] ring-1 ring-[#cbbca6]',
|
||||
inverse: 'bg-white/10 text-white border border-white/40',
|
||||
}
|
||||
|
||||
const toneMap: Record<string, string> = {
|
||||
default: '',
|
||||
success: 'badge-success',
|
||||
warning: 'badge-warning',
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
warning: 'bg-amber-100 text-amber-700',
|
||||
}
|
||||
|
||||
const pillClass = computed(() => {
|
||||
const base = ['badge', props.size === 'sm' ? 'badge-sm' : 'badge-md']
|
||||
const base = ['inline-flex items-center rounded-full font-semibold', props.size === 'sm' ? 'px-3 py-1 text-xs' : 'px-3.5 py-1.5 text-sm']
|
||||
const variantClass = variantMap[props.variant] || variantMap.neutral
|
||||
const toneClass = toneMap[props.tone] || ''
|
||||
return [base, variantClass, toneClass].flat().filter(Boolean).join(' ')
|
||||
|
||||
@@ -17,8 +17,8 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const variantMap: Record<string, string> = {
|
||||
default: 'bg-base-200 text-base-content',
|
||||
hero: 'bg-primary text-primary-content rounded-box overflow-hidden px-6',
|
||||
default: 'rounded-[28px] bg-white px-6 text-[#2f2418] shadow-none',
|
||||
hero: 'overflow-hidden rounded-[34px] bg-[#10223b] px-6 text-white shadow-[0_22px_54px_rgba(16,34,59,0.24)]',
|
||||
plain: '',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<select v-bind="$attrs" class="select select-bordered w-full">
|
||||
<select v-bind="$attrs" class="h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 text-[#2f2418] shadow-none outline-none">
|
||||
<slot />
|
||||
</select>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<label v-if="label" class="w-full space-y-1">
|
||||
<span class="text-base font-semibold text-base-content">{{ label }}</span>
|
||||
<label v-if="label" class="w-full space-y-2">
|
||||
<span class="text-sm font-bold uppercase tracking-[0.12em] text-[#8a7761]">{{ label }}</span>
|
||||
<textarea
|
||||
v-bind="$attrs"
|
||||
:class="fieldClass"
|
||||
@@ -39,6 +39,6 @@ const onInput = (event: Event) => {
|
||||
}
|
||||
|
||||
const fieldClass = computed(() =>
|
||||
['textarea textarea-bordered w-full min-h-[120px]', props.mono ? 'font-mono' : ''].join(' ')
|
||||
['w-full min-h-[120px] rounded-[24px] border-0 bg-[#f6f1ea] px-5 py-4 text-[#2f2418] shadow-none outline-none placeholder:text-[#9b8d79]', props.mono ? 'font-mono' : ''].join(' ')
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
export const useOrdersRestAPI = () => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const getTeamOrders = async (teamUuid) => {
|
||||
try {
|
||||
const response = await $fetch(`${config.public.odooApiUrl}/fastapi/orders/api/v1/orders/team/${teamUuid}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Error fetching team orders:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const getOrderByUuid = async (orderUuid) => {
|
||||
try {
|
||||
const response = await $fetch(`${config.public.odooApiUrl}/fastapi/orders/api/v1/orders/${orderUuid}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Error fetching order:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const getCompanies = async () => {
|
||||
try {
|
||||
const response = await $fetch(`${config.public.odooApiUrl}/fastapi/companies/api/v1/companies`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Error fetching companies:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getTeamOrders,
|
||||
getOrderByUuid,
|
||||
getCompanies
|
||||
}
|
||||
}
|
||||
88
app/layouts/manager.vue
Normal file
88
app/layouts/manager.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { signOut } = useAuth()
|
||||
|
||||
const userData = useState<{
|
||||
activeTeam?: { name?: string | null }
|
||||
firstName?: string | null
|
||||
} | null>('me', () => null)
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Orders', path: '/manager/orders', icon: 'lucide:package' },
|
||||
{ label: 'Quotations', path: '/manager/quotations', icon: 'lucide:file-text' },
|
||||
{ label: 'Tariffs', path: '/manager/tariffs', icon: 'lucide:waypoints' },
|
||||
]
|
||||
|
||||
function isActive(path: string) {
|
||||
const localized = localePath(path)
|
||||
return route.path === localized || route.path.startsWith(`${localized}/`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="manager-logistics-shell text-[#2f2418]">
|
||||
<div class="sticky top-0 z-50 px-3 pt-3 md:px-4">
|
||||
<div class="mx-auto max-w-[1440px]">
|
||||
<header class="rounded-[30px] border border-[#e1d7c7] bg-[#efe6d8]/95 px-4 py-4 shadow-[0_18px_40px_rgba(47,36,24,0.08)] backdrop-blur">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<NuxtLink
|
||||
:to="localePath('/')"
|
||||
class="flex h-12 min-w-[88px] items-center justify-center rounded-full bg-[#2f2418] px-5 text-sm font-black uppercase tracking-[0.2em] text-white"
|
||||
>
|
||||
Optovia
|
||||
</NuxtLink>
|
||||
<div>
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-[#8a7761]">Logistics manager</p>
|
||||
<h1 class="text-xl font-black leading-tight text-[#2f2418] md:text-2xl">Control tower</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-wrap items-center gap-2">
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="localePath(item.path)"
|
||||
class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold transition"
|
||||
:class="isActive(item.path) ? 'bg-[#2f2418] text-white shadow-[0_10px_24px_rgba(47,36,24,0.16)]' : 'bg-white text-[#5f4b33] hover:bg-[#f8f3ec]'"
|
||||
>
|
||||
<Icon :name="item.icon" size="16" />
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="rounded-full bg-white px-4 py-2 text-sm font-semibold text-[#5f4b33]">
|
||||
{{ userData?.activeTeam?.name || userData?.firstName || 'Active workspace' }}
|
||||
</div>
|
||||
<NuxtLink
|
||||
:to="localePath('/clientarea/orders')"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-bold text-[#5f4b33] transition hover:bg-[#f8f3ec]"
|
||||
>
|
||||
<Icon name="lucide:arrow-left" size="16" />
|
||||
<span>Client area</span>
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-[#2f2418] px-4 py-2 text-sm font-bold text-white transition hover:bg-[#493824]"
|
||||
@click="signOut()"
|
||||
>
|
||||
<Icon name="lucide:log-out" size="16" />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="px-3 pb-5 pt-3 md:px-4 md:pb-6">
|
||||
<div class="mx-auto max-w-[1440px]">
|
||||
<section class="rounded-[34px] bg-[#f3eee6] px-4 py-4 shadow-[0_24px_72px_rgba(3,8,20,0.18)] md:px-5 md:py-5 lg:px-6 lg:py-6">
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-base-300">
|
||||
<div class="min-h-screen flex flex-col manager-logistics-shell">
|
||||
<AiChatSidebar
|
||||
:open="isChatOpen"
|
||||
:width="chatWidth"
|
||||
@@ -121,7 +121,7 @@ const {
|
||||
} = useHeroScroll()
|
||||
|
||||
// Theme state
|
||||
const theme = useState<'cupcake' | 'night'>('theme', () => 'cupcake')
|
||||
const theme = useState<'silk' | 'night'>('theme', () => 'silk')
|
||||
|
||||
// User data state (shared across layouts)
|
||||
interface SelectedLocation {
|
||||
@@ -313,7 +313,7 @@ const onClickSignOut = () => {
|
||||
signOut(siteUrl)
|
||||
}
|
||||
|
||||
const applyTheme = (value: 'cupcake' | 'night') => {
|
||||
const applyTheme = (value: 'silk' | 'night') => {
|
||||
if (import.meta.client) {
|
||||
document.documentElement.setAttribute('data-theme', value)
|
||||
localStorage.setItem('theme', value)
|
||||
@@ -322,8 +322,8 @@ const applyTheme = (value: 'cupcake' | 'night') => {
|
||||
|
||||
onMounted(() => {
|
||||
const stored = import.meta.client ? localStorage.getItem('theme') : null
|
||||
if (stored === 'night' || stored === 'cupcake') {
|
||||
theme.value = stored as 'cupcake' | 'night'
|
||||
if (stored === 'night' || stored === 'silk') {
|
||||
theme.value = stored as 'silk' | 'night'
|
||||
}
|
||||
applyTheme(theme.value)
|
||||
})
|
||||
@@ -331,7 +331,7 @@ onMounted(() => {
|
||||
watch(theme, (value) => applyTheme(value))
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.value = theme.value === 'night' ? 'cupcake' : 'night'
|
||||
theme.value = theme.value === 'night' ? 'silk' : 'night'
|
||||
}
|
||||
|
||||
// Search handler for Quote mode - triggers search via shared state
|
||||
|
||||
9
app/pages/manager/index.vue
Normal file
9
app/pages/manager/index.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'manager',
|
||||
middleware: ['auth-oidc'],
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
await navigateTo(localePath('/manager/orders'), { redirectCode: 302 })
|
||||
</script>
|
||||
243
app/pages/manager/orders/[id].vue
Normal file
243
app/pages/manager/orders/[id].vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GetOrderDocument,
|
||||
type GetOrderQueryResult,
|
||||
type GetOrderQueryVariables,
|
||||
} from '~/composables/graphql/team/orders-generated'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'manager',
|
||||
middleware: ['auth-oidc'],
|
||||
})
|
||||
|
||||
type OrderRecord = NonNullable<GetOrderQueryResult['getOrder']>
|
||||
type OrderStage = NonNullable<NonNullable<OrderRecord['stages']>[number]>
|
||||
type OrderLine = NonNullable<NonNullable<OrderRecord['orderLines']>[number]>
|
||||
type StageTrip = NonNullable<NonNullable<OrderStage['trips']>[number]>
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { execute } = useGraphQL()
|
||||
const { locale } = useI18n()
|
||||
const orderId = computed(() => String(route.params.id || '').trim())
|
||||
|
||||
const {
|
||||
data,
|
||||
pending,
|
||||
error,
|
||||
} = await useAsyncData(
|
||||
() => `manager-order-${orderId.value}`,
|
||||
async () => {
|
||||
const response = await execute(GetOrderDocument, {
|
||||
orderUuid: orderId.value,
|
||||
} as GetOrderQueryVariables, 'team', 'orders')
|
||||
return response.getOrder
|
||||
},
|
||||
)
|
||||
|
||||
const order = computed(() => data.value || null)
|
||||
const stages = computed(() => ((order.value?.stages || []).filter((stage): stage is OrderStage => stage !== null)))
|
||||
const lines = computed(() => ((order.value?.orderLines || []).filter((line): line is OrderLine => line !== null)))
|
||||
const trips = computed(() => stages.value.flatMap(stage => (stage.trips || []).filter((trip): trip is StageTrip => trip !== null)))
|
||||
|
||||
function formatMoney(value?: number | null, currency?: string | null) {
|
||||
return new Intl.NumberFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(value || 0))
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return 'Дата уточняется'
|
||||
return new Intl.DateTimeFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function routeLabel() {
|
||||
return `${order.value?.sourceLocationName || 'Откуда'} - ${order.value?.destinationLocationName || 'Куда'}`
|
||||
}
|
||||
|
||||
function statusMeta(status?: string | null) {
|
||||
const normalized = String(status || '').toLowerCase()
|
||||
if (normalized === 'delivered' || normalized === 'completed') return { label: 'Завершён', className: 'bg-emerald-100 text-emerald-700' }
|
||||
if (normalized === 'cancelled' || normalized === 'canceled') return { label: 'Отменён', className: 'bg-rose-100 text-rose-700' }
|
||||
if (normalized === 'in_transit' || normalized === 'processing') return { label: 'В пути', className: 'bg-sky-100 text-sky-700' }
|
||||
return { label: 'В работе', className: 'bg-amber-100 text-amber-700' }
|
||||
}
|
||||
|
||||
function tripDateLabel(trip: StageTrip) {
|
||||
return trip.actualLoadingDate || trip.realLoadingDate || trip.plannedLoadingDate || trip.plannedUnloadingDate
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<NuxtLink :to="localePath('/manager/orders')" class="inline-flex items-center gap-2 text-sm font-bold text-[#8a7761] transition hover:text-[#2f2418]">
|
||||
<Icon name="lucide:arrow-left" size="16" />
|
||||
<span>Назад к списку</span>
|
||||
</NuxtLink>
|
||||
<p class="mt-4 text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Order detail</p>
|
||||
<h2 class="mt-1 text-3xl font-black text-[#2f2418]">{{ routeLabel() }}</h2>
|
||||
<p class="mt-2 text-sm text-[#6f6353]">Заказ {{ order?.name || order?.uuid || orderId }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="order" class="flex flex-wrap items-center gap-3">
|
||||
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold" :class="statusMeta(order.status).className">
|
||||
{{ statusMeta(order.status).label }}
|
||||
</span>
|
||||
<div class="rounded-full bg-white px-4 py-2 text-sm font-bold text-[#2f2418]">
|
||||
{{ formatMoney(order.totalAmount, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="pending" class="mt-6 rounded-[28px] bg-white p-8 text-center">
|
||||
<p class="text-sm opacity-70">Загружаем заказ…</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="error || !order" class="mt-6 rounded-[28px] bg-rose-50/92 p-6 text-rose-700">
|
||||
<p class="text-sm font-medium">Не удалось открыть заказ</p>
|
||||
<p v-if="error" class="mt-2 text-sm opacity-80">{{ error.message }}</p>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<section class="mt-6 grid gap-3 lg:grid-cols-4">
|
||||
<article class="rounded-[28px] bg-white p-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Created</p>
|
||||
<p class="mt-3 text-lg font-black text-[#2f2418]">{{ formatDate(order.createdAt) }}</p>
|
||||
</article>
|
||||
<article class="rounded-[28px] bg-white p-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Stages</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ stages.length }}</p>
|
||||
</article>
|
||||
<article class="rounded-[28px] bg-white p-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Trips</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ trips.length }}</p>
|
||||
</article>
|
||||
<article class="rounded-[28px] bg-white p-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Cargo lines</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ lines.length }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,0.9fr)]">
|
||||
<article class="rounded-[28px] bg-white p-6">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Cargo manifest</p>
|
||||
<h3 class="mt-2 text-2xl font-black text-[#2f2418]">Order lines</h3>
|
||||
</div>
|
||||
<div class="rounded-full bg-[#f6f1ea] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#8a7761]">
|
||||
{{ lines.length }} items
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 overflow-hidden rounded-[24px] border border-[#eadfce]">
|
||||
<div class="grid grid-cols-[minmax(0,1.7fr)_120px_120px] gap-3 bg-[#fbf8f4] px-4 py-3 text-xs font-bold uppercase tracking-[0.12em] text-[#8c7b67]">
|
||||
<span>Product</span>
|
||||
<span>Qty</span>
|
||||
<span>Subtotal</span>
|
||||
</div>
|
||||
<div v-if="lines.length">
|
||||
<div
|
||||
v-for="line in lines"
|
||||
:key="line.uuid"
|
||||
class="grid grid-cols-[minmax(0,1.7fr)_120px_120px] gap-3 border-t border-[#f1e7da] px-4 py-4 text-sm text-[#4f4130]"
|
||||
>
|
||||
<div>
|
||||
<p class="font-bold text-[#2f2418]">{{ line.productName || 'Cargo item' }}</p>
|
||||
<p class="mt-1 text-xs text-[#8a7761]">{{ line.uuid }}</p>
|
||||
</div>
|
||||
<span>{{ line.quantity || 0 }} {{ line.unit || '' }}</span>
|
||||
<span>{{ formatMoney(line.subtotal, order.currency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 py-6 text-sm text-[#6f6353]">
|
||||
Состав груза ещё не заполнен.
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="rounded-[28px] bg-white p-6">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Manager note</p>
|
||||
<h3 class="mt-2 text-2xl font-black text-[#2f2418]">Summary</h3>
|
||||
<p class="mt-4 text-sm leading-6 text-[#5f4b33]">
|
||||
{{ order.notes || 'Комментарий пока не добавлен. Этот экран уже перенесён в manager-паттерн logistics и теперь опирается на Optovia GraphQL.' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-3 rounded-[24px] bg-[#fbf8f4] p-4">
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="text-[#8a7761]">Source</span>
|
||||
<span class="font-bold text-[#2f2418]">{{ order.sourceLocationName || 'Не указано' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="text-[#8a7761]">Destination</span>
|
||||
<span class="font-bold text-[#2f2418]">{{ order.destinationLocationName || 'Не указано' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="text-[#8a7761]">Status</span>
|
||||
<span class="font-bold text-[#2f2418]">{{ statusMeta(order.status).label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 rounded-[28px] bg-white p-6">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Route flow</p>
|
||||
<h3 class="mt-2 text-2xl font-black text-[#2f2418]">Stages and trips</h3>
|
||||
</div>
|
||||
<div class="rounded-full bg-[#f6f1ea] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#8a7761]">
|
||||
{{ stages.length }} stages
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
<article
|
||||
v-for="stage in stages"
|
||||
:key="stage.uuid"
|
||||
class="rounded-[24px] border border-[#eadfce] bg-[#fbf8f4] p-5"
|
||||
>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.12em] text-[#8c7b67]">{{ stage.stageType || 'Stage' }}</p>
|
||||
<h4 class="mt-1 text-xl font-black text-[#2f2418]">{{ stage.name || 'Этап маршрута' }}</h4>
|
||||
<p class="mt-2 text-sm text-[#5f4b33]">
|
||||
{{ stage.sourceLocationName || 'Источник' }} → {{ stage.destinationLocationName || stage.locationName || 'Точка назначения' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-if="stage.transportType" class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#5f4b33]">
|
||||
{{ stage.transportType }}
|
||||
</span>
|
||||
<span v-if="stage.selectedCompany?.name" class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#5f4b33]">
|
||||
{{ stage.selectedCompany.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="(stage.trips || []).length" class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="trip in stage.trips"
|
||||
:key="trip?.uuid"
|
||||
class="rounded-[20px] bg-white px-4 py-4"
|
||||
>
|
||||
<p class="text-sm font-bold text-[#2f2418]">{{ trip?.name || 'Trip' }}</p>
|
||||
<p class="mt-1 text-xs text-[#8a7761]">{{ trip?.company?.name || trip?.company?.country || 'Carrier pending' }}</p>
|
||||
<p class="mt-3 text-sm text-[#5f4b33]">{{ formatDate(tripDateLabel(trip || {} as StageTrip)) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
310
app/pages/manager/orders/index.vue
Normal file
310
app/pages/manager/orders/index.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GetTeamOrdersDocument,
|
||||
type GetTeamOrdersQueryResult,
|
||||
type GetTeamOrdersQueryVariables,
|
||||
} from '~/composables/graphql/team/orders-generated'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'manager',
|
||||
middleware: ['auth-oidc'],
|
||||
})
|
||||
|
||||
type TeamOrder = NonNullable<NonNullable<GetTeamOrdersQueryResult['getTeamOrders']>[number]>
|
||||
type TeamStage = NonNullable<NonNullable<TeamOrder['stages']>[number]>
|
||||
type OrdersViewMode = 'list' | 'calendar'
|
||||
|
||||
const { execute } = useGraphQL()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
const search = ref('')
|
||||
const visibleLimit = ref(12)
|
||||
|
||||
const {
|
||||
data,
|
||||
pending,
|
||||
error,
|
||||
refresh,
|
||||
} = await useAsyncData('manager-orders-optovia', async () => {
|
||||
const response = await execute(GetTeamOrdersDocument, {} as GetTeamOrdersQueryVariables, 'team', 'orders')
|
||||
return response.getTeamOrders || []
|
||||
})
|
||||
|
||||
const orders = computed(() => (Array.isArray(data.value) ? data.value.filter((item): item is TeamOrder => item !== null) : []))
|
||||
const viewMode = computed<OrdersViewMode>(() => route.query.view === 'calendar' ? 'calendar' : 'list')
|
||||
const filteredOrders = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
if (!query) return orders.value
|
||||
|
||||
return orders.value.filter((item) => {
|
||||
const haystack = [
|
||||
item.uuid,
|
||||
item.name,
|
||||
item.status,
|
||||
item.sourceLocationName,
|
||||
item.destinationLocationName,
|
||||
...(item.orderLines || []).map(line => line?.productName || ''),
|
||||
...(item.stages || []).map(stage => stage?.name || ''),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
})
|
||||
})
|
||||
const visibleOrders = computed(() => filteredOrders.value.slice(0, visibleLimit.value))
|
||||
const canLoadMoreOrders = computed(() => visibleOrders.value.length < filteredOrders.value.length)
|
||||
const totalTurnover = computed(() => orders.value.reduce((sum, item) => sum + Number(item.totalAmount || 0), 0))
|
||||
const inTransitCount = computed(() => orders.value.filter(item => ['processing', 'in_transit'].includes(String(item.status || '').toLowerCase())).length)
|
||||
const deliveredCount = computed(() => orders.value.filter(item => ['delivered', 'completed'].includes(String(item.status || '').toLowerCase())).length)
|
||||
|
||||
const orderViewTabs = computed<Array<{ key: OrdersViewMode, label: string, active: boolean }>>(() => ([
|
||||
{ key: 'list', label: 'Списком', active: viewMode.value === 'list' },
|
||||
{ key: 'calendar', label: 'Календарь', active: viewMode.value === 'calendar' },
|
||||
]))
|
||||
|
||||
function formatMoney(value?: number | null, currency?: string | null) {
|
||||
return new Intl.NumberFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(value || 0))
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function routeLabel(item: TeamOrder) {
|
||||
return `${item.sourceLocationName || 'Откуда'} - ${item.destinationLocationName || 'Куда'}`
|
||||
}
|
||||
|
||||
function cargoLabel(item: TeamOrder) {
|
||||
const lines = (item.orderLines || [])
|
||||
.filter((line): line is NonNullable<(typeof item.orderLines)[number]> => line !== null)
|
||||
.map(line => `${line.productName || 'Cargo'}${line.quantity ? ` · ${line.quantity} ${line.unit || ''}` : ''}`)
|
||||
return lines.slice(0, 2).join(' · ') || 'Состав груза уточняется'
|
||||
}
|
||||
|
||||
function statusMeta(status?: string | null) {
|
||||
const normalized = String(status || '').toLowerCase()
|
||||
if (normalized === 'delivered' || normalized === 'completed') return { label: 'Завершён', className: 'bg-emerald-100 text-emerald-700' }
|
||||
if (normalized === 'cancelled' || normalized === 'canceled') return { label: 'Отменён', className: 'bg-rose-100 text-rose-700' }
|
||||
if (normalized === 'in_transit' || normalized === 'processing') return { label: 'В пути', className: 'bg-sky-100 text-sky-700' }
|
||||
return { label: 'В работе', className: 'bg-amber-100 text-amber-700' }
|
||||
}
|
||||
|
||||
function checkpointLabel(item: TeamOrder) {
|
||||
const stages = (item.stages || []).filter((stage): stage is TeamStage => stage !== null)
|
||||
return stages[0]?.name || 'Этап уточняется'
|
||||
}
|
||||
|
||||
function customerLabel(item: TeamOrder) {
|
||||
return `Клиент ${(item.uuid || 'order').slice(-6).toUpperCase()}`
|
||||
}
|
||||
|
||||
function stageCount(item: TeamOrder) {
|
||||
return (item.stages || []).filter(Boolean).length
|
||||
}
|
||||
|
||||
function mapCalendarOrders() {
|
||||
return filteredOrders.value.map((item) => {
|
||||
const stages = (item.stages || []).filter((stage): stage is TeamStage => stage !== null)
|
||||
const checkpoints = stages.map((stage, index) => {
|
||||
const firstTrip = (stage.trips || []).find(Boolean)
|
||||
return {
|
||||
code: stage.uuid || `stage-${index}`,
|
||||
name: stage.name || 'Этап',
|
||||
plannedDate: firstTrip?.plannedLoadingDate || firstTrip?.plannedUnloadingDate || null,
|
||||
actualDate: firstTrip?.actualLoadingDate || firstTrip?.actualUnloadingDate || null,
|
||||
completed: Boolean(firstTrip?.actualUnloadingDate || firstTrip?.realLoadingDate),
|
||||
current: index === 0,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: item.uuid || '',
|
||||
status: item.status || '',
|
||||
totalAmount: Number(item.totalAmount || 0),
|
||||
currency: item.currency || 'USD',
|
||||
createdAt: item.createdAt || new Date().toISOString(),
|
||||
pickupDate: checkpoints[0]?.plannedDate || null,
|
||||
fromAddress: {
|
||||
city: item.sourceLocationName || '',
|
||||
country: '',
|
||||
},
|
||||
toAddress: {
|
||||
city: item.destinationLocationName || '',
|
||||
country: '',
|
||||
},
|
||||
currentCheckpoint: checkpoints[0] || null,
|
||||
checkpoints,
|
||||
}
|
||||
}).filter(order => order.id)
|
||||
}
|
||||
|
||||
async function openOrder(orderId?: string | null) {
|
||||
if (!orderId) return
|
||||
await navigateTo(localePath(`/manager/orders/${orderId}`))
|
||||
}
|
||||
|
||||
async function setViewMode(nextMode: OrdersViewMode) {
|
||||
const nextQuery = { ...route.query }
|
||||
if (nextMode === 'calendar') {
|
||||
nextQuery.view = 'calendar'
|
||||
} else {
|
||||
delete nextQuery.view
|
||||
}
|
||||
|
||||
await navigateTo({
|
||||
path: route.path,
|
||||
query: nextQuery,
|
||||
}, { replace: true })
|
||||
}
|
||||
|
||||
async function loadMoreOrders() {
|
||||
visibleLimit.value += 12
|
||||
await refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="grid gap-3 lg:grid-cols-4">
|
||||
<article class="rounded-[28px] bg-white p-5 shadow-none">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Orders</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ orders.length }}</p>
|
||||
<p class="mt-1 text-sm text-[#6f6353]">Активный пул заказов в microservice flow</p>
|
||||
</article>
|
||||
<article class="rounded-[28px] bg-white p-5 shadow-none">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">In transit</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ inTransitCount }}</p>
|
||||
<p class="mt-1 text-sm text-[#6f6353]">Маршруты, которые сейчас в движении</p>
|
||||
</article>
|
||||
<article class="rounded-[28px] bg-white p-5 shadow-none">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Delivered</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ deliveredCount }}</p>
|
||||
<p class="mt-1 text-sm text-[#6f6353]">Завершённые кейсы для менеджерского кабинета</p>
|
||||
</article>
|
||||
<article class="rounded-[28px] bg-white p-5 shadow-none">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Turnover</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ formatMoney(totalTurnover, orders[0]?.currency || 'USD') }}</p>
|
||||
<p class="mt-1 text-sm text-[#6f6353]">Сумма по заказам без фронтового Odoo-слоя</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Manager workspace</p>
|
||||
<h2 class="mt-1 text-3xl font-black text-[#2f2418]">Orders</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-3 lg:w-auto lg:min-w-[620px] lg:flex-row">
|
||||
<label class="block flex-1">
|
||||
<span class="sr-only">Search orders</span>
|
||||
<input
|
||||
v-model="search"
|
||||
class="input h-12 w-full rounded-full border-0 bg-white px-5 shadow-none"
|
||||
placeholder="Поиск по маршруту, грузу или этапу"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="inline-flex w-fit items-center rounded-full bg-white p-1 shadow-[0_16px_38px_rgba(38,29,18,0.08)]">
|
||||
<button
|
||||
v-for="tab in orderViewTabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="rounded-full px-4 py-2 text-sm font-bold transition"
|
||||
:class="tab.active ? 'bg-[#2f2418] text-white' : 'text-[#6a5947] hover:bg-[#f6f1ea]'"
|
||||
@click="setViewMode(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="pending && !orders.length" class="mt-6 rounded-[28px] bg-white p-8 text-center">
|
||||
<p class="text-sm opacity-70">Загружаем заказы…</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="error" class="mt-6 rounded-[28px] bg-rose-50/92 p-6 text-rose-700">
|
||||
<p class="text-sm font-medium">Не удалось загрузить manager orders</p>
|
||||
<p class="mt-2 text-sm opacity-80">{{ error.message }}</p>
|
||||
</section>
|
||||
|
||||
<OrdersCalendarPanel
|
||||
v-else-if="viewMode === 'calendar'"
|
||||
class="mt-6"
|
||||
:orders="mapCalendarOrders()"
|
||||
@select="openOrder"
|
||||
/>
|
||||
|
||||
<section v-else class="mt-6 space-y-3">
|
||||
<article
|
||||
v-for="item in visibleOrders"
|
||||
:key="item.uuid"
|
||||
class="cursor-pointer rounded-[28px] bg-white px-6 py-5 shadow-none transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]"
|
||||
@click="openOrder(item.uuid)"
|
||||
>
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,2.1fr)_minmax(0,1.15fr)_auto_auto] md:items-center">
|
||||
<div class="flex min-w-0 items-start gap-4">
|
||||
<UserAvatar
|
||||
:seed="item.uuid"
|
||||
:label="customerLabel(item)"
|
||||
:size="52"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-bold text-[#8a7761]">{{ customerLabel(item) }}</p>
|
||||
<p class="mt-1 truncate text-lg font-black leading-tight text-[#2f2418]">{{ routeLabel(item) }}</p>
|
||||
<p class="mt-1 truncate text-sm text-[#6f6353]">{{ cargoLabel(item) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 text-sm text-[#6f6353]">
|
||||
<p class="truncate">{{ checkpointLabel(item) }}</p>
|
||||
<p class="mt-1 truncate text-xs text-[#998b78]">
|
||||
{{ stageCount(item) }} этапов · {{ formatDate(item.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="md:text-right">
|
||||
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold" :class="statusMeta(item.status).className">
|
||||
{{ statusMeta(item.status).label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="whitespace-nowrap text-right text-lg font-semibold text-[#2f2418]">
|
||||
{{ formatMoney(item.totalAmount, item.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-if="visibleOrders.length === 0"
|
||||
class="rounded-[28px] bg-white p-8 text-center"
|
||||
>
|
||||
<p class="text-lg font-semibold text-[#2f2418]">Заказы не найдены</p>
|
||||
<p class="mt-2 text-sm text-[#6f6353]">Попробуй изменить строку поиска или фильтр представления.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<ManagerListLoadMore
|
||||
v-if="!error && visibleOrders.length"
|
||||
class="mt-5"
|
||||
:shown="visibleOrders.length"
|
||||
:total="filteredOrders.length"
|
||||
:can-load-more="canLoadMoreOrders"
|
||||
:loading="pending"
|
||||
:page-size="12"
|
||||
item-label="заказов"
|
||||
@load-more="loadMoreOrders"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
166
app/pages/manager/quotations/index.vue
Normal file
166
app/pages/manager/quotations/index.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'manager',
|
||||
middleware: ['auth-oidc'],
|
||||
})
|
||||
|
||||
type QuotationCard = {
|
||||
id: string
|
||||
client: string
|
||||
from: string
|
||||
to: string
|
||||
mode: string
|
||||
weight: string
|
||||
status: 'draft' | 'pricing' | 'graph-ready'
|
||||
note: string
|
||||
}
|
||||
|
||||
const search = ref('')
|
||||
const quotations = useState<QuotationCard[]>('manager-quotation-cards', () => [
|
||||
{
|
||||
id: 'qt-001',
|
||||
client: 'Client 42A7',
|
||||
from: 'Guangzhou',
|
||||
to: 'Moscow',
|
||||
mode: 'Multimodal',
|
||||
weight: '800 kg',
|
||||
status: 'pricing',
|
||||
note: 'Базовый сценарий для переноса quotation flow из logistics.',
|
||||
},
|
||||
{
|
||||
id: 'qt-002',
|
||||
client: 'Client 90F2',
|
||||
from: 'Shenzhen',
|
||||
to: 'Novorossiysk',
|
||||
mode: 'Sea + truck',
|
||||
weight: '1 200 kg',
|
||||
status: 'graph-ready',
|
||||
note: 'Маршрут уже мыслится как graph-native corridor без Odoo-связки.',
|
||||
},
|
||||
{
|
||||
id: 'qt-003',
|
||||
client: 'Client 18D1',
|
||||
from: 'Yiwu',
|
||||
to: 'Kazan',
|
||||
mode: 'Rail',
|
||||
weight: '2 400 kg',
|
||||
status: 'draft',
|
||||
note: 'Черновик manager workspace под tariffs и decision rules.',
|
||||
},
|
||||
])
|
||||
|
||||
const filteredCards = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
if (!query) return quotations.value
|
||||
|
||||
return quotations.value.filter((item) => {
|
||||
return [item.client, item.from, item.to, item.mode, item.note, item.status]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
function createNewQuotation() {
|
||||
quotations.value = [
|
||||
{
|
||||
id: `qt-${String(quotations.value.length + 1).padStart(3, '0')}`,
|
||||
client: `Client ${(Math.random().toString(16).slice(2, 6)).toUpperCase()}`,
|
||||
from: 'Shanghai',
|
||||
to: 'Saint Petersburg',
|
||||
mode: 'Multimodal',
|
||||
weight: '950 kg',
|
||||
status: 'draft',
|
||||
note: 'Новый manager draft. Дальше его нужно привязать к реальному quotation microservice.',
|
||||
},
|
||||
...quotations.value,
|
||||
]
|
||||
}
|
||||
|
||||
function statusMeta(status: QuotationCard['status']) {
|
||||
if (status === 'graph-ready') return { label: 'Graph ready', className: 'bg-emerald-100 text-emerald-700' }
|
||||
if (status === 'pricing') return { label: 'Pricing', className: 'bg-amber-100 text-amber-700' }
|
||||
return { label: 'Draft', className: 'bg-stone-200 text-stone-700' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="grid gap-3 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,0.7fr)]">
|
||||
<article class="rounded-[28px] bg-white p-6">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Quotations workspace</p>
|
||||
<h2 class="mt-2 text-3xl font-black text-[#2f2418]">Exact logistics shell, Optovia logic next</h2>
|
||||
<p class="mt-4 max-w-[760px] text-sm leading-6 text-[#5f4b33]">
|
||||
Этот экран уже переведён в визуальный паттерн `logistic`: те же теплые поверхности, те же rounded cards, тот же manager rhythm.
|
||||
Следующий шаг здесь очевидный: посадить quotation CRUD и tariff selection на ваши microservices и graph-модель.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="rounded-[28px] bg-[#2f2418] p-6 text-white shadow-[0_20px_44px_rgba(47,36,24,0.18)]">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-white/60">Migration note</p>
|
||||
<h3 class="mt-2 text-2xl font-black">No Odoo frontend path</h3>
|
||||
<p class="mt-4 text-sm leading-6 text-white/78">
|
||||
Quotation UI here is ready to stop depending on old Odoo-driven flows. Сейчас это staging surface, дальше сюда подключается новый graph-native backend contract.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<label class="block flex-1">
|
||||
<span class="sr-only">Search quotations</span>
|
||||
<input
|
||||
v-model="search"
|
||||
class="input h-12 w-full rounded-full border-0 bg-white px-5 shadow-none"
|
||||
placeholder="Поиск по клиенту, маршруту или статусу"
|
||||
>
|
||||
</label>
|
||||
<button class="btn rounded-full border-0 bg-[#2f2418] px-6 text-white hover:bg-[#493824]" @click="createNewQuotation">
|
||||
Создать quotation draft
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 space-y-3">
|
||||
<article
|
||||
v-for="item in filteredCards"
|
||||
:key="item.id"
|
||||
class="rounded-[28px] bg-white px-6 py-5 shadow-none transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]"
|
||||
>
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,2fr)_auto_minmax(0,1.3fr)_auto] md:items-center">
|
||||
<div class="flex min-w-0 items-center gap-3 text-lg font-black leading-tight text-[#2f2418]">
|
||||
<p class="truncate">{{ item.from }}</p>
|
||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#efe7da] text-[#7a684f]">
|
||||
<svg viewBox="0 0 20 20" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3.5 10h13" />
|
||||
<path d="M12 5.5 16.5 10 12 14.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<p class="truncate">{{ item.to }}</p>
|
||||
</div>
|
||||
|
||||
<p class="whitespace-nowrap text-sm font-medium text-[#6f6353]">
|
||||
{{ item.weight }}
|
||||
</p>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-[#2f2418]">{{ item.client }}</p>
|
||||
<p class="mt-1 truncate text-sm text-[#6f6353]">{{ item.mode }} · {{ item.note }}</p>
|
||||
</div>
|
||||
|
||||
<div class="md:text-right">
|
||||
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold" :class="statusMeta(item.status).className">
|
||||
{{ statusMeta(item.status).label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-if="filteredCards.length === 0"
|
||||
class="rounded-[28px] bg-white p-8 text-center"
|
||||
>
|
||||
<p class="text-lg font-semibold text-[#2f2418]">Quotation cards не найдены</p>
|
||||
<p class="mt-2 text-sm text-[#6f6353]">Измени строку поиска или добавь новый draft.</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
165
app/pages/manager/tariffs/index.vue
Normal file
165
app/pages/manager/tariffs/index.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GetHubCountriesDocument,
|
||||
HubsListDocument,
|
||||
type GetHubCountriesQueryVariables,
|
||||
type HubsListQueryResult,
|
||||
type HubsListQueryVariables,
|
||||
} from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'manager',
|
||||
middleware: ['auth-oidc'],
|
||||
})
|
||||
|
||||
type HubRecord = HubsListQueryResult['hubsList'][number]
|
||||
|
||||
const { execute } = useGraphQL()
|
||||
const search = ref('')
|
||||
const selectedCountry = ref('')
|
||||
|
||||
const [{ data: countriesData }, { data: hubsData, pending, error }] = await Promise.all([
|
||||
useAsyncData('manager-tariff-countries', async () => {
|
||||
const response = await execute(GetHubCountriesDocument, {} as GetHubCountriesQueryVariables, 'public', 'geo')
|
||||
return response.hubCountries || []
|
||||
}),
|
||||
useAsyncData('manager-tariff-hubs', async () => {
|
||||
const response = await execute(HubsListDocument, {
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
country: null,
|
||||
transportType: null,
|
||||
west: null,
|
||||
south: null,
|
||||
east: null,
|
||||
north: null,
|
||||
} as HubsListQueryVariables, 'public', 'geo')
|
||||
return response.hubsList || []
|
||||
}),
|
||||
])
|
||||
|
||||
const countries = computed(() => (countriesData.value || []).filter(Boolean))
|
||||
const hubs = computed(() => (Array.isArray(hubsData.value) ? hubsData.value.filter((item): item is HubRecord => item !== null) : []))
|
||||
const filteredHubs = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
|
||||
return hubs.value.filter((hub) => {
|
||||
if (selectedCountry.value && hub.country !== selectedCountry.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!query) return true
|
||||
|
||||
return [hub.name, hub.country, hub.countryCode, ...(hub.transportTypes || [])]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const uniqueTransportTypes = computed(() => {
|
||||
return new Set(
|
||||
hubs.value.flatMap(hub => (hub.transportTypes || []).filter((item): item is string => Boolean(item))),
|
||||
).size
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="grid gap-3 lg:grid-cols-3">
|
||||
<article class="rounded-[28px] bg-white p-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Tariff anchors</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ hubs.length }}</p>
|
||||
<p class="mt-1 text-sm text-[#6f6353]">Хабы из geo graph для будущего tariff workspace</p>
|
||||
</article>
|
||||
<article class="rounded-[28px] bg-white p-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Countries</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ countries.length }}</p>
|
||||
<p class="mt-1 text-sm text-[#6f6353]">Страны, уже доступные для corridor pricing</p>
|
||||
</article>
|
||||
<article class="rounded-[28px] bg-white p-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Transport types</p>
|
||||
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ uniqueTransportTypes }}</p>
|
||||
<p class="mt-1 text-sm text-[#6f6353]">Основа для будущих tariff rules без Odoo</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 rounded-[28px] bg-white p-6">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Tariff topology</p>
|
||||
<h2 class="mt-2 text-3xl font-black text-[#2f2418]">Graph-native corridor base</h2>
|
||||
<p class="mt-4 max-w-[840px] text-sm leading-6 text-[#5f4b33]">
|
||||
Здесь я не делаю вид, что тарифный backend уже существует. Но сам экран уже опирается на реальные geo microservices и показывает,
|
||||
от каких hub nodes дальше строить tariff references, quotation routes и decision rules.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-3 lg:flex-row">
|
||||
<label class="block flex-1">
|
||||
<span class="sr-only">Search hubs</span>
|
||||
<input
|
||||
v-model="search"
|
||||
class="input h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 shadow-none"
|
||||
placeholder="Поиск по хабу, стране или типу транспорта"
|
||||
>
|
||||
</label>
|
||||
|
||||
<select v-model="selectedCountry" class="select h-12 rounded-full border-0 bg-[#f6f1ea] px-5 shadow-none">
|
||||
<option value="">Все страны</option>
|
||||
<option v-for="country in countries" :key="country" :value="country">
|
||||
{{ country }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="pending && !hubs.length" class="mt-6 rounded-[28px] bg-white p-8 text-center">
|
||||
<p class="text-sm opacity-70">Загружаем graph hubs…</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="error" class="mt-6 rounded-[28px] bg-rose-50/92 p-6 text-rose-700">
|
||||
<p class="text-sm font-medium">Не удалось загрузить hub topology</p>
|
||||
<p class="mt-2 text-sm opacity-80">{{ error.message }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else class="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
v-for="hub in filteredHubs"
|
||||
:key="hub.uuid"
|
||||
class="rounded-[28px] bg-white p-5 shadow-none transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.12em] text-[#8c7b67]">{{ hub.countryCode || 'Hub' }}</p>
|
||||
<h3 class="mt-1 text-xl font-black leading-tight text-[#2f2418]">{{ hub.name || 'Unnamed hub' }}</h3>
|
||||
<p class="mt-2 text-sm text-[#6f6353]">{{ hub.country || 'Country pending' }}</p>
|
||||
</div>
|
||||
<div class="rounded-full bg-[#f6f1ea] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#8a7761]">
|
||||
{{ (hub.transportTypes || []).length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="transportType in hub.transportTypes || []"
|
||||
:key="transportType || 'transport'"
|
||||
class="rounded-full bg-[#fbf8f4] px-3 py-1 text-xs font-semibold text-[#5f4b33]"
|
||||
>
|
||||
{{ transportType }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm leading-6 text-[#5f4b33]">
|
||||
Этот хаб можно использовать как anchor point для новой tariff reference модели внутри Optovia manager workspace.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-if="filteredHubs.length === 0"
|
||||
class="rounded-[28px] bg-white p-8 text-center md:col-span-2 xl:col-span-3"
|
||||
>
|
||||
<p class="text-lg font-semibold text-[#2f2418]">Под фильтры ничего не попало</p>
|
||||
<p class="mt-2 text-sm text-[#6f6353]">Сбрось поиск или выбери другую страну.</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
23
app/utils/profileAvatars.ts
Normal file
23
app/utils/profileAvatars.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const DICEBEAR_STYLE = 'notionists'
|
||||
const DICEBEAR_BACKGROUNDS = ['f6efe3', 'e7f4ee', 'eef2ff', 'fff1dc', 'fce7f3']
|
||||
|
||||
function hashProfileAvatarValue(value: string) {
|
||||
return [...value].reduce((acc, char) => {
|
||||
return (acc * 33 + char.charCodeAt(0)) >>> 0
|
||||
}, 5381)
|
||||
}
|
||||
|
||||
export function profileAvatarSeedFromValue(value?: string | null) {
|
||||
const normalized = String(value || 'profile').trim() || 'profile'
|
||||
return `gl-${hashProfileAvatarValue(normalized).toString(36)}`
|
||||
}
|
||||
|
||||
export function profileAvatarUrl(seed?: string | null) {
|
||||
const params = new URLSearchParams({
|
||||
seed: String(seed || 'profile').trim() || 'profile',
|
||||
radius: '32',
|
||||
backgroundColor: DICEBEAR_BACKGROUNDS.join(','),
|
||||
})
|
||||
|
||||
return `https://api.dicebear.com/9.x/${DICEBEAR_STYLE}/svg?${params.toString()}`
|
||||
}
|
||||
@@ -161,7 +161,17 @@ export default defineNuxtConfig({
|
||||
ssr: true,
|
||||
app: {
|
||||
baseURL: '/',
|
||||
layoutTransition: { name: 'layout', mode: 'out-in' }
|
||||
layoutTransition: { name: 'layout', mode: 'out-in' },
|
||||
head: {
|
||||
link: [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700;800;900&display=swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sourcemap: enableSourceMaps ? { server: true, client: true } : false,
|
||||
nitro: {
|
||||
@@ -193,7 +203,6 @@ export default defineNuxtConfig({
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
odooApiUrl: process.env.NUXT_PUBLIC_ODOO_API_URL || 'https://odoo.optovia.ru',
|
||||
terminusGraphql: process.env.NUXT_PUBLIC_TERMINUS_GRAPHQL || 'https://terminus.optovia.ru/graphql',
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
||||
langAgentUrl: process.env.NUXT_PUBLIC_LANG_AGENT_URL || 'http://localhost:8000/agent',
|
||||
|
||||
Reference in New Issue
Block a user