Initial commit from monorepo
This commit is contained in:
16
app/pages/callback.vue
Normal file
16
app/pages/callback.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack align="center" justify="center" gap="4" fullHeight>
|
||||
<Spinner />
|
||||
<Text tone="muted">Redirecting...</Text>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const localePath = useLocalePath()
|
||||
|
||||
onMounted(() => {
|
||||
navigateTo(localePath('/clientarea'))
|
||||
})
|
||||
</script>
|
||||
310
app/pages/catalog/hubs/[id].vue
Normal file
310
app/pages/catalog/hubs/[id].vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<Stack gap="0">
|
||||
<!-- Loading -->
|
||||
<Section v-if="isLoading" variant="plain" paddingY="lg">
|
||||
<Stack align="center" justify="center" gap="4">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('catalogHub.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Error / Not Found -->
|
||||
<Section v-else-if="!hub" variant="plain" paddingY="lg">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="primary">
|
||||
<Icon name="lucide:map-pin" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="2">{{ t('catalogHub.not_found.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('catalogHub.not_found.subtitle') }}</Text>
|
||||
<Button @click="navigateTo(localePath('/catalog'))">
|
||||
{{ t('catalogHub.actions.back_to_catalog') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<template v-else>
|
||||
<!-- Map Hero -->
|
||||
<MapHero
|
||||
:title="hub.name"
|
||||
:location="mapLocation"
|
||||
:badges="hubBadges"
|
||||
/>
|
||||
|
||||
<!-- Offers Section -->
|
||||
<Section v-if="offers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogHub.sections.offers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<OfferCard
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
/>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Empty offers state -->
|
||||
<Section v-else variant="plain" paddingY="md">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="primary">
|
||||
<Icon name="lucide:package-x" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="3">{{ t('catalogHub.empty.offers.title') }}</Heading>
|
||||
<Text tone="muted" align="center">
|
||||
{{ t('catalogHub.empty.offers.subtitle') }}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<!-- Suppliers Section -->
|
||||
<Section v-if="uniqueSuppliers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogHub.sections.suppliers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<SupplierCard
|
||||
v-for="supplier in uniqueSuppliers"
|
||||
:key="supplier.teamUuid"
|
||||
:supplier="supplier"
|
||||
/>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Products Section -->
|
||||
<Section v-if="uniqueProducts.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogHub.sections.products.title') }}</Heading>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<NuxtLink
|
||||
v-for="product in uniqueProducts"
|
||||
:key="product.uuid"
|
||||
:to="localePath(`/catalog/products/${product.uuid}`)"
|
||||
>
|
||||
<Pill variant="primary" class="hover:bg-primary hover:text-white transition-colors cursor-pointer">
|
||||
{{ product.name }}
|
||||
</Pill>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Nearby Connections -->
|
||||
<NearbyConnectionsSection
|
||||
:auto-edges="autoEdges"
|
||||
:rail-edges="railEdges"
|
||||
:hub="currentHubForMap"
|
||||
:rail-hub="railHubForMap"
|
||||
:auto-route-geometries="autoRouteGeometries"
|
||||
:rail-route-geometries="railRouteGeometries"
|
||||
/>
|
||||
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetLocationOffersDocument, GetSupplierProfilesDocument } from '~/composables/graphql/public/exchange-generated'
|
||||
import { GetNodeConnectionsDocument, GetAutoRouteDocument, GetRailRouteDocument } from '~/composables/graphql/public/geo-generated'
|
||||
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
interface RouteGeometry {
|
||||
toUuid: string
|
||||
coordinates: [number, number][]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const hub = ref<any>(null)
|
||||
const railHub = ref<any>(null)
|
||||
const offers = ref<any[]>([])
|
||||
const allSuppliers = ref<any[]>([])
|
||||
const autoEdges = ref<EdgeType[]>([])
|
||||
const railEdges = ref<EdgeType[]>([])
|
||||
const autoRouteGeometries = ref<RouteGeometry[]>([])
|
||||
const railRouteGeometries = ref<RouteGeometry[]>([])
|
||||
|
||||
const hubId = computed(() => route.params.id as string)
|
||||
|
||||
// Map location
|
||||
const mapLocation = computed(() => ({
|
||||
uuid: hub.value?.uuid,
|
||||
name: hub.value?.name,
|
||||
latitude: hub.value?.latitude,
|
||||
longitude: hub.value?.longitude,
|
||||
country: hub.value?.country,
|
||||
countryCode: hub.value?.countryCode
|
||||
}))
|
||||
|
||||
// Badges for MapHero
|
||||
const hubBadges = computed(() => {
|
||||
const badges: Array<{ icon?: string; text: string }> = []
|
||||
if (hub.value?.country) {
|
||||
badges.push({ icon: 'lucide:globe', text: hub.value.country })
|
||||
}
|
||||
if (offers.value.length > 0) {
|
||||
badges.push({ icon: 'lucide:package', text: t('catalogHub.badges.offers', { count: offers.value.length }) })
|
||||
}
|
||||
if (hub.value?.latitude && hub.value?.longitude) {
|
||||
badges.push({ icon: 'lucide:map-pin', text: `${hub.value.latitude.toFixed(2)}°, ${hub.value.longitude.toFixed(2)}°` })
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
// Unique suppliers
|
||||
const uniqueSuppliers = computed(() => {
|
||||
const suppliers = new Map<string, { teamUuid: string; name: string; offersCount: number }>()
|
||||
offers.value.forEach(offer => {
|
||||
if (offer.teamUuid) {
|
||||
const existing = suppliers.get(offer.teamUuid)
|
||||
const supplierInfo = allSuppliers.value.find(s => s.teamUuid === offer.teamUuid)
|
||||
if (existing) {
|
||||
existing.offersCount++
|
||||
} else {
|
||||
suppliers.set(offer.teamUuid, {
|
||||
teamUuid: offer.teamUuid,
|
||||
name: supplierInfo?.name || t('catalogHub.labels.default_supplier'),
|
||||
offersCount: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return Array.from(suppliers.values())
|
||||
})
|
||||
|
||||
// Unique products
|
||||
const uniqueProducts = computed(() => {
|
||||
const products = new Map<string, { uuid: string; name: string }>()
|
||||
offers.value.forEach(offer => {
|
||||
offer.lines?.forEach((line: any) => {
|
||||
if (line?.productUuid && line?.productName) {
|
||||
products.set(line.productUuid, { uuid: line.productUuid, name: line.productName })
|
||||
}
|
||||
})
|
||||
})
|
||||
return Array.from(products.values())
|
||||
})
|
||||
|
||||
// Current hub for NearbyConnectionsSection
|
||||
const currentHubForMap = computed(() => ({
|
||||
uuid: hub.value?.uuid || '',
|
||||
name: hub.value?.name || '',
|
||||
latitude: hub.value?.latitude || 0,
|
||||
longitude: hub.value?.longitude || 0
|
||||
}))
|
||||
|
||||
const railHubForMap = computed(() => ({
|
||||
uuid: railHub.value?.uuid || hub.value?.uuid || '',
|
||||
name: railHub.value?.name || hub.value?.name || '',
|
||||
latitude: railHub.value?.latitude || hub.value?.latitude || 0,
|
||||
longitude: railHub.value?.longitude || hub.value?.longitude || 0
|
||||
}))
|
||||
|
||||
// Load route geometries for edges
|
||||
const loadRouteGeometries = async (
|
||||
edges: EdgeType[],
|
||||
hubLat: number,
|
||||
hubLon: number,
|
||||
transportType: 'auto' | 'rail'
|
||||
): Promise<RouteGeometry[]> => {
|
||||
const RouteDocument = transportType === 'auto' ? GetAutoRouteDocument : GetRailRouteDocument
|
||||
const routeField = transportType === 'auto' ? 'autoRoute' : 'railRoute'
|
||||
|
||||
const filteredEdges = edges
|
||||
.filter(e => e?.transportType === transportType && e?.toLatitude && e?.toLongitude)
|
||||
.sort((a, b) => (a.distanceKm || 0) - (b.distanceKm || 0))
|
||||
.slice(0, 12)
|
||||
|
||||
const routePromises = filteredEdges.map(async (edge) => {
|
||||
try {
|
||||
const { data: routeDataResponse } = await useServerQuery(
|
||||
`hub-route-${transportType}-${edge.toUuid}`,
|
||||
RouteDocument,
|
||||
{
|
||||
fromLat: hubLat,
|
||||
fromLon: hubLon,
|
||||
toLat: edge.toLatitude!,
|
||||
toLon: edge.toLongitude!
|
||||
},
|
||||
'public',
|
||||
'geo'
|
||||
)
|
||||
|
||||
const routeData = routeDataResponse.value?.[routeField]
|
||||
if (routeData?.geometry) {
|
||||
const geometryArray = typeof routeData.geometry === 'string'
|
||||
? JSON.parse(routeData.geometry)
|
||||
: routeData.geometry
|
||||
|
||||
if (Array.isArray(geometryArray) && geometryArray.length > 0) {
|
||||
return {
|
||||
toUuid: edge.toUuid!,
|
||||
coordinates: geometryArray as [number, number][]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load ${transportType} route to ${edge.toName}:`, error)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const results = await Promise.all(routePromises)
|
||||
return results.filter(Boolean) as RouteGeometry[]
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ data: connectionsData }, { data: offersData }, { data: suppliersData }] = await Promise.all([
|
||||
useServerQuery('hub-connections', GetNodeConnectionsDocument, { uuid: hubId.value }, 'public', 'geo'),
|
||||
useServerQuery('hub-offers', GetLocationOffersDocument, { locationUuid: hubId.value }, 'public', 'exchange'),
|
||||
useServerQuery('hub-suppliers', GetSupplierProfilesDocument, {}, 'public', 'exchange')
|
||||
])
|
||||
|
||||
const connectionsResult = connectionsData.value
|
||||
hub.value = connectionsResult?.nodeConnections?.hub || null
|
||||
railHub.value = connectionsResult?.nodeConnections?.railNode || null
|
||||
offers.value = offersData.value?.getOffers || []
|
||||
allSuppliers.value = suppliersData.value?.getSupplierProfiles || []
|
||||
autoEdges.value = (connectionsResult?.nodeConnections?.autoEdges || []).filter((e): e is EdgeType => e !== null)
|
||||
railEdges.value = (connectionsResult?.nodeConnections?.railEdges || []).filter((e): e is EdgeType => e !== null)
|
||||
|
||||
if (hub.value?.latitude && hub.value?.longitude) {
|
||||
const railOrigin = railHub.value || hub.value
|
||||
const [autoGeometries, railGeometries] = await Promise.all([
|
||||
loadRouteGeometries(autoEdges.value, hub.value.latitude, hub.value.longitude, 'auto'),
|
||||
loadRouteGeometries(railEdges.value, railOrigin.latitude, railOrigin.longitude, 'rail')
|
||||
])
|
||||
autoRouteGeometries.value = autoGeometries
|
||||
railRouteGeometries.value = railGeometries
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading hub:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead(() => ({
|
||||
title: hub.value?.name
|
||||
? t('catalogHub.meta.title_with_name', { name: hub.value.name })
|
||||
: t('catalogHub.meta.title'),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: t('catalogHub.meta.description', {
|
||||
name: hub.value?.name || '',
|
||||
country: hub.value?.country || '',
|
||||
offers: offers.value.length,
|
||||
suppliers: uniqueSuppliers.value.length
|
||||
})
|
||||
}
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
80
app/pages/catalog/hubs/index.vue
Normal file
80
app/pages/catalog/hubs/index.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<Section variant="plain" paddingY="md">
|
||||
<PageHeader :title="t('catalogHubsSection.header.title')" />
|
||||
</Section>
|
||||
|
||||
<Section v-if="isLoading" variant="plain" paddingY="md">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Section v-else variant="plain" paddingY="md">
|
||||
<Stack gap="6">
|
||||
<NuxtLink :to="localePath('/catalog/hubs/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
map-id="hubs-map"
|
||||
:locations="itemsWithCoords"
|
||||
:height="192"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</NuxtLink>
|
||||
|
||||
<CatalogFilters :filters="filters" v-model="selectedFilter" />
|
||||
|
||||
<div v-for="country in itemsByCountry" :key="country.name" class="space-y-3">
|
||||
<Text weight="semibold">{{ country.name }}</Text>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<HubCard
|
||||
v-for="hub in country.hubs"
|
||||
:key="hub.uuid"
|
||||
:hub="hub"
|
||||
/>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<PaginationLoadMore
|
||||
:shown="items.length"
|
||||
:total="total"
|
||||
:can-load-more="canLoadMore"
|
||||
:loading="isLoadingMore"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
|
||||
<Stack v-if="items.length === 0" align="center" gap="2">
|
||||
<Text tone="muted">{{ t('catalogHubsSection.empty.no_hubs') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
itemsWithCoords,
|
||||
itemsByCountry,
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
init
|
||||
} = useCatalogHubs()
|
||||
|
||||
await init()
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalogHubsSection.header.title')
|
||||
}))
|
||||
</script>
|
||||
70
app/pages/catalog/hubs/map.vue
Normal file
70
app/pages/catalog/hubs/map.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<NuxtLayout name="map">
|
||||
<template #sidebar>
|
||||
<CatalogMapSidebar
|
||||
:title="t('catalogHubsSection.header.title')"
|
||||
:back-link="localePath('/catalog/hubs')"
|
||||
:back-label="t('catalogMap.actions.list_view')"
|
||||
:items-count="items.length"
|
||||
:filters="filters"
|
||||
:selected-filter="selectedFilter"
|
||||
:loading="isLoading"
|
||||
:empty-text="t('catalogMap.empty.hubs')"
|
||||
@update:selected-filter="selectedFilter = $event"
|
||||
>
|
||||
<template #cards>
|
||||
<HubCard
|
||||
v-for="hub in items"
|
||||
:key="hub.uuid"
|
||||
:hub="hub"
|
||||
selectable
|
||||
:is-selected="selectedItemId === hub.uuid"
|
||||
@select="selectItem(hub)"
|
||||
/>
|
||||
</template>
|
||||
</CatalogMapSidebar>
|
||||
</template>
|
||||
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="hubs-fullscreen-map"
|
||||
:items="itemsWithCoords"
|
||||
point-color="#10b981"
|
||||
@select-item="onMapSelectItem"
|
||||
/>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
itemsWithCoords,
|
||||
init
|
||||
} = useCatalogHubs()
|
||||
|
||||
await init()
|
||||
|
||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||
const selectedItemId = ref<string | null>(null)
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
selectedItemId.value = item.uuid
|
||||
if (item.latitude && item.longitude) {
|
||||
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
||||
}
|
||||
}
|
||||
|
||||
const onMapSelectItem = (uuid: string) => {
|
||||
selectedItemId.value = uuid
|
||||
}
|
||||
</script>
|
||||
219
app/pages/catalog/offers/[uuid].vue
Normal file
219
app/pages/catalog/offers/[uuid].vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<Stack gap="0">
|
||||
<!-- Loading -->
|
||||
<Section v-if="isLoading" variant="plain" paddingY="lg">
|
||||
<Stack align="center" justify="center" gap="4">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('catalogOffer.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Error / Not Found -->
|
||||
<Section v-else-if="!offer" variant="plain" paddingY="lg">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="primary">
|
||||
<Icon name="lucide:package" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="2">{{ t('catalogOffer.not_found.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('catalogOffer.not_found.subtitle') }}</Text>
|
||||
<Button @click="navigateTo(localePath('/catalog'))">
|
||||
{{ t('catalogOffer.actions.back_to_catalog') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<template v-else>
|
||||
<!-- Map Hero -->
|
||||
<MapHero
|
||||
:title="offer.productName"
|
||||
:location="mapLocation"
|
||||
:badges="offerBadges"
|
||||
/>
|
||||
|
||||
<!-- Product and price -->
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Card padding="lg">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="start" justify="between" gap="4">
|
||||
<div>
|
||||
<NuxtLink
|
||||
v-if="offer.productUuid"
|
||||
:to="localePath(`/catalog/products/${offer.productUuid}`)"
|
||||
class="hover:text-primary transition-colors"
|
||||
>
|
||||
<Heading :level="2">{{ offer.productName }}</Heading>
|
||||
</NuxtLink>
|
||||
<Heading v-else :level="2">{{ offer.productName }}</Heading>
|
||||
<Pill v-if="offer.categoryName" variant="outline" size="sm" class="mt-2">
|
||||
{{ offer.categoryName }}
|
||||
</Pill>
|
||||
</div>
|
||||
<div v-if="offer.pricePerUnit" class="text-right">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{{ formatPrice(offer.pricePerUnit, offer.currency) }}
|
||||
</div>
|
||||
<Text tone="muted" size="sm">{{ t('catalogOffer.labels.per_unit', { unit: displayUnit(offer.unit) }) }}</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Badge variant="primary" size="md">{{ t('catalogOffer.labels.quantity_with_unit', { quantity: offer.quantity, unit: displayUnit(offer.unit) }) }}</Badge>
|
||||
<span v-if="offer.validUntil" class="text-base-content/60">
|
||||
{{ t('catalogOffer.labels.valid_until', { date: formatDate(offer.validUntil) }) }}
|
||||
</span>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<!-- Description -->
|
||||
<Section v-if="offer.description" variant="plain" paddingY="md">
|
||||
<Card padding="lg">
|
||||
<Stack gap="3">
|
||||
<Heading :level="3">{{ t('catalogOffer.sections.description.title') }}</Heading>
|
||||
<Text>{{ offer.description }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<!-- Supplier -->
|
||||
<Section v-if="supplier" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="3">{{ t('catalogOffer.sections.supplier.title') }}</Heading>
|
||||
<SupplierCard :supplier="supplier" />
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Location -->
|
||||
<Section v-if="offer.locationUuid" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="3">{{ t('catalogOffer.sections.location.title') }}</Heading>
|
||||
<NuxtLink :to="localePath(`/catalog/hubs/${offer.locationUuid}`)">
|
||||
<Card padding="lg" interactive>
|
||||
<Stack direction="row" align="center" gap="3">
|
||||
<span class="text-2xl">{{ countryFlag }}</span>
|
||||
<Stack gap="1">
|
||||
<Text weight="semibold">{{ offer.locationName }}</Text>
|
||||
<Text v-if="offer.locationCountry" tone="muted" size="sm">{{ offer.locationCountry }}</Text>
|
||||
</Stack>
|
||||
<Icon name="lucide:arrow-right" size="20" class="ml-auto text-base-content/50" />
|
||||
</Stack>
|
||||
</Card>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GetOfferDocument,
|
||||
GetSupplierProfilesDocument,
|
||||
} from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { data: offerData, pending: offerPending } = await useServerQuery('catalog-offer-detail', GetOfferDocument, { uuid: route.params.uuid as string }, 'public', 'exchange')
|
||||
const { data: suppliersData, pending: suppliersPending } = await useServerQuery('catalog-offer-suppliers', GetSupplierProfilesDocument, {}, 'public', 'exchange')
|
||||
|
||||
const isLoading = computed(() => offerPending.value || suppliersPending.value)
|
||||
const offer = computed(() => offerData.value?.getOffer || null)
|
||||
const supplier = computed(() => {
|
||||
const suppliers = suppliersData.value?.getSupplierProfiles || []
|
||||
if (offer.value?.teamUuid) {
|
||||
return suppliers.find((s: any) => s?.teamUuid === offer.value.teamUuid)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const offerUuid = computed(() => route.params.uuid as string)
|
||||
|
||||
// Map location
|
||||
const mapLocation = computed(() => ({
|
||||
uuid: offer.value?.locationUuid,
|
||||
name: offer.value?.locationName,
|
||||
latitude: offer.value?.locationLatitude,
|
||||
longitude: offer.value?.locationLongitude,
|
||||
country: offer.value?.locationCountry,
|
||||
countryCode: offer.value?.locationCountryCode
|
||||
}))
|
||||
|
||||
// Badges for MapHero
|
||||
const offerBadges = computed(() => {
|
||||
const badges: Array<{ icon?: string; text: string }> = []
|
||||
if (offer.value?.locationName) {
|
||||
badges.push({ icon: 'lucide:map-pin', text: offer.value.locationName })
|
||||
}
|
||||
if (offer.value?.locationCountry) {
|
||||
badges.push({ icon: 'lucide:globe', text: offer.value.locationCountry })
|
||||
}
|
||||
if (offer.value?.validUntil) {
|
||||
badges.push({ icon: 'lucide:calendar', text: t('catalogOffer.badges.valid_until', { date: formatDate(offer.value.validUntil) }) })
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(new Date(date))
|
||||
} catch {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: number | string, currency?: string) => {
|
||||
const num = typeof price === 'string' ? parseFloat(price) : price
|
||||
const curr = currency || 'USD'
|
||||
try {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: curr,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(num)
|
||||
} catch {
|
||||
return `${num} ${curr}`
|
||||
}
|
||||
}
|
||||
|
||||
const displayUnit = (unit?: string | null) => unit || t('catalogOffer.units.ton_short')
|
||||
|
||||
// Convert ISO code to emoji flag
|
||||
const isoToEmoji = (code: string): string => {
|
||||
return code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0)))
|
||||
.join('')
|
||||
}
|
||||
|
||||
const countryFlag = computed(() => {
|
||||
if (offer.value?.locationCountryCode) {
|
||||
return isoToEmoji(offer.value.locationCountryCode)
|
||||
}
|
||||
return '📍'
|
||||
})
|
||||
|
||||
// SEO
|
||||
useHead(() => ({
|
||||
title: offer.value?.title
|
||||
? t('catalogOffer.meta.title_with_name', { title: offer.value.title })
|
||||
: t('catalogOffer.meta.title'),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: offer.value?.description || t('catalogOffer.meta.description', { title: offer.value?.title || '' })
|
||||
}
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
76
app/pages/catalog/offers/index.vue
Normal file
76
app/pages/catalog/offers/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<Section variant="plain" paddingY="md">
|
||||
<PageHeader :title="t('catalogOffersSection.header.title')" />
|
||||
</Section>
|
||||
|
||||
<Section v-if="isLoading" variant="plain" paddingY="md">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Section v-else variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<NuxtLink :to="localePath('/catalog/offers/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
map-id="offers-map"
|
||||
:locations="itemsWithCoords"
|
||||
:height="192"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</NuxtLink>
|
||||
|
||||
<CatalogFilters :filters="filters" v-model="selectedFilter" />
|
||||
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<OfferCard
|
||||
v-for="offer in items"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<PaginationLoadMore
|
||||
:shown="items.length"
|
||||
:total="total"
|
||||
:can-load-more="canLoadMore"
|
||||
:loading="isLoadingMore"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
|
||||
<Stack v-if="total === 0" align="center" gap="2">
|
||||
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
itemsWithCoords,
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
init
|
||||
} = useCatalogOffers()
|
||||
|
||||
await init()
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalogOffersSection.header.title')
|
||||
}))
|
||||
</script>
|
||||
72
app/pages/catalog/offers/map.vue
Normal file
72
app/pages/catalog/offers/map.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<NuxtLayout name="map">
|
||||
<template #sidebar>
|
||||
<CatalogMapSidebar
|
||||
:title="t('catalogOffersSection.header.title')"
|
||||
:back-link="localePath('/catalog/offers')"
|
||||
:back-label="t('catalogMap.actions.list_view')"
|
||||
:items-count="items.length"
|
||||
:filters="filters"
|
||||
:selected-filter="selectedFilter"
|
||||
:loading="isLoading"
|
||||
:empty-text="t('catalogMap.empty.offers')"
|
||||
@update:selected-filter="selectedFilter = $event"
|
||||
>
|
||||
<template #cards>
|
||||
<OfferCard
|
||||
v-for="offer in items"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
selectable
|
||||
:is-selected="selectedItemId === offer.uuid"
|
||||
@select="selectItem(offer)"
|
||||
/>
|
||||
</template>
|
||||
</CatalogMapSidebar>
|
||||
</template>
|
||||
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="offers-fullscreen-map"
|
||||
:items="itemsWithCoords"
|
||||
point-color="#f59e0b"
|
||||
@select-item="onMapSelectItem"
|
||||
/>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
itemsWithCoords,
|
||||
init
|
||||
} = useCatalogOffers()
|
||||
|
||||
await init()
|
||||
|
||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||
const selectedItemId = ref<string | null>(null)
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
selectedItemId.value = item.uuid
|
||||
const lat = item.locationLatitude
|
||||
const lng = item.locationLongitude
|
||||
if (lat && lng) {
|
||||
mapRef.value?.flyTo(lat, lng, 8)
|
||||
}
|
||||
}
|
||||
|
||||
const onMapSelectItem = (uuid: string) => {
|
||||
selectedItemId.value = uuid
|
||||
}
|
||||
</script>
|
||||
384
app/pages/catalog/products/[id].vue
Normal file
384
app/pages/catalog/products/[id].vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<Stack gap="0">
|
||||
<!-- Loading -->
|
||||
<Section v-if="isLoading" variant="plain" paddingY="lg">
|
||||
<Stack align="center" justify="center" gap="4">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('catalogProduct.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Error / Not Found -->
|
||||
<Section v-else-if="!product" variant="plain" paddingY="lg">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="primary">
|
||||
<Icon name="lucide:package" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="2">{{ t('catalogProduct.not_found.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('catalogProduct.not_found.subtitle') }}</Text>
|
||||
<Button @click="navigateTo(localePath('/catalog'))">
|
||||
{{ t('catalogProduct.actions.back_to_catalog') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<template v-else>
|
||||
<!-- HERO Section -->
|
||||
<Section variant="hero" paddingY="lg">
|
||||
<Stack gap="6">
|
||||
<Stack direction="row" align="start" gap="6">
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<IconCircle tone="primary" class="w-20 h-20 text-3xl bg-white/20">
|
||||
<Icon :name="getCategoryIcon(product.categoryName)" size="32" />
|
||||
</IconCircle>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<Stack gap="3" class="flex-1">
|
||||
<Heading :level="1" tone="inverse">{{ product.name }}</Heading>
|
||||
<Stack direction="row" align="center" gap="4">
|
||||
<Pill variant="inverse">
|
||||
<Icon name="lucide:folder" size="14" />
|
||||
{{ product.categoryName || t('catalogProduct.labels.category_unknown') }}
|
||||
</Pill>
|
||||
<Pill v-if="offers.length > 0" variant="inverse">
|
||||
<Icon name="lucide:tag" size="14" />
|
||||
{{ t('catalogProduct.labels.offers_count', { count: offers.length }) }}
|
||||
</Pill>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Grid :cols="2" :md="4" :gap="4">
|
||||
<Card padding="md">
|
||||
<Stack gap="1" align="center">
|
||||
<Text size="2xl" weight="bold" class="text-primary">{{ offers.length }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.offers') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<Stack gap="1" align="center">
|
||||
<Text size="2xl" weight="bold" class="text-primary">{{ uniqueSuppliers.length }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.suppliers') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<Stack gap="1" align="center">
|
||||
<Text size="2xl" weight="bold" class="text-primary">{{ uniqueLocations.length }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.locations') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<Stack gap="1" align="center">
|
||||
<Text size="2xl" weight="bold" class="text-primary">{{ priceRange }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.price_range') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
<!-- Offers Section -->
|
||||
<Section v-if="offers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogProduct.sections.offers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
padding="md"
|
||||
interactive
|
||||
@click="navigateTo(localePath(`/catalog/offers/${offer.uuid}`))"
|
||||
>
|
||||
<Stack gap="3">
|
||||
<Stack gap="1">
|
||||
<Text size="lg" weight="semibold">{{ offer.title }}</Text>
|
||||
<Stack direction="row" align="center" gap="2">
|
||||
<Icon name="lucide:map-pin" size="14" class="text-base-content/60" />
|
||||
<Text tone="muted">{{ offer.locationName }}, {{ offer.locationCountry }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<!-- Line with this product -->
|
||||
<template v-for="line in getProductLines(offer)" :key="line?.uuid">
|
||||
<Card padding="sm" class="bg-base-200">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack gap="0">
|
||||
<Text weight="semibold">{{ line?.quantity }} {{ line?.unit }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.labels.in_stock') }}</Text>
|
||||
</Stack>
|
||||
<Stack gap="0" align="end">
|
||||
<Text weight="bold" class="text-primary">
|
||||
{{ formatPrice(line?.pricePerUnit, line?.currency) }}
|
||||
</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.labels.per_unit', { unit: line?.unit }) }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Badge :variant="getStatusVariant(offer.status)">
|
||||
{{ getStatusLabel(offer.status) }}
|
||||
</Badge>
|
||||
<Text v-if="offer.validUntil" tone="muted" size="sm">
|
||||
{{ t('catalogProduct.labels.valid_until', { date: formatDate(offer.validUntil) }) }}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Suppliers Section -->
|
||||
<Section v-if="uniqueSuppliers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogProduct.sections.suppliers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="supplier in uniqueSuppliers"
|
||||
:key="supplier.teamUuid"
|
||||
padding="md"
|
||||
interactive
|
||||
@click="navigateTo(localePath(`/catalog/suppliers/${supplier.teamUuid}`))"
|
||||
>
|
||||
<Stack direction="row" align="center" gap="3">
|
||||
<IconCircle tone="primary">
|
||||
{{ supplier.name?.charAt(0) || '?' }}
|
||||
</IconCircle>
|
||||
<Stack gap="1" class="flex-1">
|
||||
<Text weight="semibold">{{ supplier.name || t('catalogProduct.labels.default_supplier') }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.labels.supplier_offers', { count: supplier.offersCount }) }}</Text>
|
||||
</Stack>
|
||||
<Icon name="lucide:chevron-right" size="20" class="text-base-content/60" />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Map Section -->
|
||||
<Section v-if="uniqueLocations.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogProduct.sections.locations.title') }}</Heading>
|
||||
<div class="h-80 rounded-lg overflow-hidden">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
:locations="mapLocations"
|
||||
:height="320"
|
||||
:initial-zoom="2"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<Section variant="plain" paddingY="lg">
|
||||
<Card padding="lg" class="bg-gradient-to-r from-primary/10 via-primary/5 to-primary/10 border border-base-300">
|
||||
<Stack gap="4" align="center">
|
||||
<Heading :level="3">{{ t('catalogProduct.cta.title') }}</Heading>
|
||||
<Text tone="muted" align="center">
|
||||
{{ t('catalogProduct.cta.subtitle') }}
|
||||
</Text>
|
||||
<Button size="large">
|
||||
<Icon name="lucide:plus" size="18" class="mr-2" />
|
||||
{{ t('catalogProduct.cta.action') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GetProductsDocument,
|
||||
GetProductOffersDocument,
|
||||
GetSupplierProfilesDocument,
|
||||
} from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
data: productsData,
|
||||
pending: productsPending
|
||||
} = await useServerQuery('catalog-product-list', GetProductsDocument, {}, 'public', 'exchange')
|
||||
|
||||
const {
|
||||
data: productOffersData,
|
||||
pending: productOffersPending
|
||||
} = await useServerQuery('catalog-product-offers', GetProductOffersDocument, { productUuid: route.params.id as string }, 'public', 'exchange')
|
||||
|
||||
const {
|
||||
data: suppliersData,
|
||||
pending: suppliersPending
|
||||
} = await useServerQuery('catalog-product-suppliers', GetSupplierProfilesDocument, {}, 'public', 'exchange')
|
||||
|
||||
const isLoading = computed(() => productsPending.value || productOffersPending.value || suppliersPending.value)
|
||||
const product = computed(() => findProduct(productsData.value?.getProducts || []))
|
||||
const offers = computed(() => productOffersData.value?.getOffers || [])
|
||||
const allSuppliers = computed(() => suppliersData.value?.getSupplierProfiles || [])
|
||||
|
||||
const productId = computed(() => route.params.id as string)
|
||||
|
||||
// Find product by uuid from list
|
||||
const findProduct = (products: any[]) => {
|
||||
return products.find(p => p?.uuid === productId.value)
|
||||
}
|
||||
|
||||
// Unique suppliers from offers
|
||||
const uniqueSuppliers = computed(() => {
|
||||
const suppliers = new Map<string, { teamUuid: string; name: string; offersCount: number }>()
|
||||
offers.value.forEach(offer => {
|
||||
if (offer.teamUuid) {
|
||||
const existing = suppliers.get(offer.teamUuid)
|
||||
const supplierInfo = allSuppliers.value.find(s => s.teamUuid === offer.teamUuid)
|
||||
if (existing) {
|
||||
existing.offersCount++
|
||||
} else {
|
||||
suppliers.set(offer.teamUuid, {
|
||||
teamUuid: offer.teamUuid,
|
||||
name: supplierInfo?.name || t('catalogProduct.labels.default_supplier'),
|
||||
offersCount: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return Array.from(suppliers.values())
|
||||
})
|
||||
|
||||
// Unique locations
|
||||
const uniqueLocations = computed(() => {
|
||||
const locations = new Map<string, { uuid: string; name: string; country: string; latitude: number; longitude: number }>()
|
||||
offers.value.forEach(offer => {
|
||||
if (offer.locationUuid && offer.locationLatitude && offer.locationLongitude) {
|
||||
locations.set(offer.locationUuid, {
|
||||
uuid: offer.locationUuid,
|
||||
name: offer.locationName,
|
||||
country: offer.locationCountry || '',
|
||||
latitude: offer.locationLatitude,
|
||||
longitude: offer.locationLongitude
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(locations.values())
|
||||
})
|
||||
|
||||
// Locations for map
|
||||
const mapLocations = computed(() => {
|
||||
return uniqueLocations.value.map(loc => ({
|
||||
uuid: loc.uuid,
|
||||
name: loc.name,
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
country: loc.country
|
||||
}))
|
||||
})
|
||||
|
||||
// Price range
|
||||
const priceRange = computed(() => {
|
||||
const prices: number[] = []
|
||||
offers.value.forEach(offer => {
|
||||
offer.lines?.forEach((line: any) => {
|
||||
if (line?.productUuid === productId.value && line?.pricePerUnit) {
|
||||
prices.push(Number(line.pricePerUnit))
|
||||
}
|
||||
})
|
||||
})
|
||||
if (prices.length === 0) return t('common.values.not_available')
|
||||
const min = Math.min(...prices)
|
||||
const max = Math.max(...prices)
|
||||
if (min === max) return t('catalogProduct.labels.price_single', { price: min.toLocaleString() })
|
||||
return t('catalogProduct.labels.price_range', { min: min.toLocaleString(), max: max.toLocaleString() })
|
||||
})
|
||||
|
||||
// Get lines with this product
|
||||
const getProductLines = (offer: any) => {
|
||||
return (offer.lines || []).filter((line: any) => line?.productUuid === productId.value)
|
||||
}
|
||||
|
||||
const getCategoryIcon = (categoryName: string | null | undefined) => {
|
||||
const name = (categoryName || '').toLowerCase()
|
||||
if (name.includes('metal')) return 'lucide:boxes'
|
||||
if (name.includes('food')) return 'lucide:wheat'
|
||||
if (name.includes('chem')) return 'lucide:flask-conical'
|
||||
if (name.includes('build')) return 'lucide:building'
|
||||
return 'lucide:package'
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: string | null | undefined) => {
|
||||
const variants: Record<string, string> = {
|
||||
ACTIVE: 'success',
|
||||
DRAFT: 'warning',
|
||||
CANCELLED: 'error',
|
||||
CLOSED: 'muted'
|
||||
}
|
||||
return variants[status || ''] || 'muted'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string | null | undefined) => {
|
||||
const labels: Record<string, string> = {
|
||||
ACTIVE: t('catalogProduct.status.active'),
|
||||
DRAFT: t('catalogProduct.status.draft'),
|
||||
CANCELLED: t('catalogProduct.status.cancelled'),
|
||||
CLOSED: t('catalogProduct.status.closed')
|
||||
}
|
||||
return labels[status || ''] || status || t('catalogProduct.status.unknown')
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat('ru', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(new Date(dateStr))
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: any, currency: string | null | undefined) => {
|
||||
if (!price) return '—'
|
||||
const num = Number(price)
|
||||
const curr = currency || 'USD'
|
||||
try {
|
||||
return new Intl.NumberFormat('ru', {
|
||||
style: 'currency',
|
||||
currency: curr,
|
||||
maximumFractionDigits: 0
|
||||
}).format(num)
|
||||
} catch {
|
||||
return `${num} ${curr}`
|
||||
}
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead(() => ({
|
||||
title: product.value?.name
|
||||
? t('catalogProduct.meta.title_with_name', { name: product.value.name })
|
||||
: t('catalogProduct.meta.title'),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: t('catalogProduct.meta.description', {
|
||||
name: product.value?.name || '',
|
||||
offers: offers.value.length,
|
||||
suppliers: uniqueSuppliers.value.length
|
||||
})
|
||||
}
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
251
app/pages/catalog/suppliers/[id].vue
Normal file
251
app/pages/catalog/suppliers/[id].vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<Stack gap="0">
|
||||
<!-- Loading -->
|
||||
<Section v-if="isLoading" variant="plain" paddingY="lg">
|
||||
<Stack align="center" justify="center" gap="4">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('catalogSupplier.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Error / Not Found -->
|
||||
<Section v-else-if="!supplier" variant="plain" paddingY="lg">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="primary">
|
||||
<Icon name="lucide:building-2" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="2">{{ t('catalogSupplier.not_found.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('catalogSupplier.not_found.subtitle') }}</Text>
|
||||
<Button @click="navigateTo(localePath('/catalog'))">
|
||||
{{ t('catalogSupplier.actions.back_to_catalog') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<template v-else>
|
||||
<!-- Map Hero -->
|
||||
<MapHero
|
||||
:title="supplier.name"
|
||||
:location="supplierLocation"
|
||||
:badges="supplierBadges"
|
||||
:initial-zoom="3"
|
||||
/>
|
||||
|
||||
<!-- Offers Section -->
|
||||
<Section v-if="offers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogSupplier.sections.offers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<OfferCard
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
/>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Products Section -->
|
||||
<Section v-if="uniqueProducts.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogSupplier.sections.products.title') }}</Heading>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<NuxtLink
|
||||
v-for="product in uniqueProducts"
|
||||
:key="product.uuid"
|
||||
:to="localePath(`/catalog/products/${product.uuid}`)"
|
||||
>
|
||||
<Pill variant="primary" class="hover:bg-primary hover:text-white transition-colors cursor-pointer">
|
||||
{{ product.name }}
|
||||
</Pill>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Locations Map Section -->
|
||||
<Section v-if="uniqueLocations.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogSupplier.sections.locations.title') }}</Heading>
|
||||
<div class="h-64 rounded-lg overflow-hidden border border-base-300 bg-base-100">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
:key="`supplier-locations-${supplierId}`"
|
||||
:map-id="`supplier-locations-${supplierId}`"
|
||||
:locations="mapLocations"
|
||||
:height="256"
|
||||
:initial-zoom="3"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<NuxtLink
|
||||
v-for="location in uniqueLocations"
|
||||
:key="location.uuid"
|
||||
:to="localePath(`/catalog/hubs/${location.uuid}`)"
|
||||
>
|
||||
<Pill variant="outline" class="hover:bg-base-200 transition-colors cursor-pointer">
|
||||
<Icon name="lucide:map-pin" size="14" />
|
||||
{{ location.name }}
|
||||
</Pill>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GetSupplierProfileDocument,
|
||||
GetSupplierOffersDocument,
|
||||
GetSupplierProfilesDocument,
|
||||
GetOffersDocument,
|
||||
} from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const supplier = ref<any>(null)
|
||||
const offers = ref<any[]>([])
|
||||
|
||||
const supplierId = computed(() => route.params.id as string)
|
||||
|
||||
// Supplier location for map - use supplier's own coordinates
|
||||
const supplierLocation = computed(() => {
|
||||
if (supplier.value?.latitude && supplier.value?.longitude) {
|
||||
return {
|
||||
uuid: supplier.value.uuid,
|
||||
name: supplier.value.name,
|
||||
latitude: supplier.value.latitude,
|
||||
longitude: supplier.value.longitude,
|
||||
country: supplier.value.country,
|
||||
countryCode: supplier.value.countryCode
|
||||
}
|
||||
}
|
||||
// Fallback to first offer location if supplier has no coordinates
|
||||
const firstOffer = offers.value.find(o => o.locationLatitude && o.locationLongitude)
|
||||
if (firstOffer) {
|
||||
return {
|
||||
uuid: firstOffer.locationUuid,
|
||||
name: firstOffer.locationName,
|
||||
latitude: firstOffer.locationLatitude,
|
||||
longitude: firstOffer.locationLongitude,
|
||||
country: firstOffer.locationCountry,
|
||||
countryCode: firstOffer.locationCountryCode
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Badges for MapHero
|
||||
const supplierBadges = computed(() => {
|
||||
const badges: Array<{ icon?: string; text: string }> = []
|
||||
if (supplier.value?.country) {
|
||||
badges.push({ icon: 'lucide:globe', text: supplier.value.country })
|
||||
}
|
||||
if (supplier.value?.isVerified) {
|
||||
badges.push({ icon: 'lucide:check-circle', text: t('catalogSupplier.badges.verified') })
|
||||
}
|
||||
if (offers.value.length > 0) {
|
||||
badges.push({ icon: 'lucide:package', text: t('catalogSupplier.badges.offers', { count: offers.value.length }) })
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
// Unique products from offers
|
||||
const uniqueProducts = computed(() => {
|
||||
const products = new Map<string, { uuid: string; name: string }>()
|
||||
offers.value.forEach(offer => {
|
||||
offer.lines?.forEach((line: any) => {
|
||||
if (line?.productUuid && line?.productName) {
|
||||
products.set(line.productUuid, { uuid: line.productUuid, name: line.productName })
|
||||
}
|
||||
})
|
||||
})
|
||||
return Array.from(products.values())
|
||||
})
|
||||
|
||||
// Unique locations from offers
|
||||
const uniqueLocations = computed(() => {
|
||||
const locations = new Map<string, { uuid: string; name: string; latitude: number; longitude: number; country?: string | null; countryCode?: string | null }>()
|
||||
offers.value.forEach(offer => {
|
||||
if (offer.locationUuid && offer.locationName && offer.locationLatitude && offer.locationLongitude) {
|
||||
locations.set(offer.locationUuid, {
|
||||
uuid: offer.locationUuid,
|
||||
name: offer.locationName,
|
||||
latitude: offer.locationLatitude,
|
||||
longitude: offer.locationLongitude,
|
||||
country: offer.locationCountry,
|
||||
countryCode: offer.locationCountryCode
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(locations.values())
|
||||
})
|
||||
|
||||
// Locations for the map
|
||||
const mapLocations = computed(() => {
|
||||
return uniqueLocations.value.map(loc => ({
|
||||
uuid: loc.uuid,
|
||||
name: loc.name,
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
country: ''
|
||||
}))
|
||||
})
|
||||
|
||||
try {
|
||||
const { data: supplierData } = await useServerQuery('catalog-supplier-detail', GetSupplierProfileDocument, { uuid: supplierId.value }, 'public', 'exchange')
|
||||
supplier.value = supplierData.value?.getSupplierProfile || null
|
||||
|
||||
if (!supplier.value) {
|
||||
const { data: suppliersList } = await useServerQuery('catalog-suppliers-fallback', GetSupplierProfilesDocument, {}, 'public', 'exchange')
|
||||
supplier.value = (suppliersList.value?.getSupplierProfiles || []).find((s: any) => s?.teamUuid === supplierId.value || s?.uuid === supplierId.value) || null
|
||||
}
|
||||
|
||||
const teamIds = [
|
||||
supplier.value?.teamUuid,
|
||||
supplier.value?.uuid,
|
||||
supplierId.value
|
||||
].filter(Boolean)
|
||||
|
||||
if (teamIds.length) {
|
||||
const primaryId = teamIds[0] as string
|
||||
const { data: offersData } = await useServerQuery('catalog-supplier-offers', GetSupplierOffersDocument, { teamUuid: primaryId }, 'public', 'exchange')
|
||||
offers.value = offersData.value?.getOffers || []
|
||||
|
||||
if (!offers.value.length) {
|
||||
const { data: allOffersData } = await useServerQuery('catalog-supplier-offers-fallback', GetOffersDocument, {}, 'public', 'exchange')
|
||||
const ids = new Set(teamIds)
|
||||
offers.value = (allOffersData.value?.getOffers || []).filter((o: any) => o?.teamUuid && ids.has(o.teamUuid))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading supplier:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead(() => ({
|
||||
title: supplier.value?.name
|
||||
? t('catalogSupplier.meta.title_with_name', { name: supplier.value.name })
|
||||
: t('catalogSupplier.meta.title'),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: supplier.value?.description
|
||||
|| (supplier.value?.name
|
||||
? t('catalogSupplier.meta.description_with_name', { name: supplier.value.name })
|
||||
: t('catalogSupplier.meta.description'))
|
||||
}
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
76
app/pages/catalog/suppliers/index.vue
Normal file
76
app/pages/catalog/suppliers/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<Section variant="plain" paddingY="md">
|
||||
<PageHeader :title="t('catalogSuppliersSection.header.title')" />
|
||||
</Section>
|
||||
|
||||
<Section v-if="isLoading" variant="plain" paddingY="md">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Section v-else variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<NuxtLink :to="localePath('/catalog/suppliers/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
map-id="suppliers-map"
|
||||
:locations="itemsWithCoords"
|
||||
:height="192"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</NuxtLink>
|
||||
|
||||
<CatalogFilters :filters="filters" v-model="selectedFilter" />
|
||||
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<SupplierCard
|
||||
v-for="supplier in items"
|
||||
:key="supplier.uuid || supplier.teamUuid"
|
||||
:supplier="supplier"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<PaginationLoadMore
|
||||
:shown="items.length"
|
||||
:total="total"
|
||||
:can-load-more="canLoadMore"
|
||||
:loading="isLoadingMore"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
|
||||
<Stack v-if="total === 0" align="center" gap="2">
|
||||
<Text tone="muted">{{ t('catalogSuppliersSection.empty.no_suppliers') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
itemsWithCoords,
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
init
|
||||
} = useCatalogSuppliers()
|
||||
|
||||
await init()
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalogSuppliersSection.header.title')
|
||||
}))
|
||||
</script>
|
||||
70
app/pages/catalog/suppliers/map.vue
Normal file
70
app/pages/catalog/suppliers/map.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<NuxtLayout name="map">
|
||||
<template #sidebar>
|
||||
<CatalogMapSidebar
|
||||
:title="t('catalogSuppliersSection.header.title')"
|
||||
:back-link="localePath('/catalog/suppliers')"
|
||||
:back-label="t('catalogMap.actions.list_view')"
|
||||
:items-count="items.length"
|
||||
:filters="filters"
|
||||
:selected-filter="selectedFilter"
|
||||
:loading="isLoading"
|
||||
:empty-text="t('catalogMap.empty.suppliers')"
|
||||
@update:selected-filter="selectedFilter = $event"
|
||||
>
|
||||
<template #cards>
|
||||
<SupplierCard
|
||||
v-for="supplier in items"
|
||||
:key="supplier.uuid"
|
||||
:supplier="supplier"
|
||||
selectable
|
||||
:is-selected="selectedItemId === supplier.uuid"
|
||||
@select="selectItem(supplier)"
|
||||
/>
|
||||
</template>
|
||||
</CatalogMapSidebar>
|
||||
</template>
|
||||
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="suppliers-fullscreen-map"
|
||||
:items="itemsWithCoords"
|
||||
point-color="#3b82f6"
|
||||
@select-item="onMapSelectItem"
|
||||
/>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
itemsWithCoords,
|
||||
init
|
||||
} = useCatalogSuppliers()
|
||||
|
||||
await init()
|
||||
|
||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||
const selectedItemId = ref<string | null>(null)
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
selectedItemId.value = item.uuid
|
||||
if (item.latitude && item.longitude) {
|
||||
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
||||
}
|
||||
}
|
||||
|
||||
const onMapSelectItem = (uuid: string) => {
|
||||
selectedItemId.value = uuid
|
||||
}
|
||||
</script>
|
||||
88
app/pages/clientarea/addresses/index.vue
Normal file
88
app/pages/clientarea/addresses/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="6">
|
||||
<PageHeader
|
||||
:title="t('profileAddresses.header.title')"
|
||||
:actions="[{ label: t('profileAddresses.actions.add'), icon: 'lucide:plus', to: localePath('/clientarea/addresses/new') }]"
|
||||
/>
|
||||
|
||||
<Card v-if="isLoading" padding="lg">
|
||||
<Stack align="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('profileAddresses.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<template v-else-if="items.length">
|
||||
<NuxtLink :to="localePath('/clientarea/addresses/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
map-id="addresses-map"
|
||||
:locations="itemsWithCoords"
|
||||
:height="192"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</NuxtLink>
|
||||
|
||||
<Grid :cols="1" :md="2" :gap="4">
|
||||
<Card v-for="addr in items" :key="addr.uuid" padding="small" interactive>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Text size="base" weight="semibold" class="truncate">{{ addr.name }}</Text>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs btn-circle">
|
||||
<Icon name="lucide:more-vertical" size="16" />
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow bg-base-100 rounded-box w-40">
|
||||
<li>
|
||||
<a class="text-error" @click="deleteAddress(addr.uuid)">
|
||||
<Icon name="lucide:trash-2" size="16" />
|
||||
{{ t('profileAddresses.actions.delete') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text tone="muted" size="sm" class="line-clamp-2">{{ addr.address }}</Text>
|
||||
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-lg">{{ isoToEmoji(addr.countryCode) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Grid>
|
||||
</template>
|
||||
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="📍"
|
||||
:title="t('profileAddresses.empty.title')"
|
||||
:description="t('profileAddresses.empty.description')"
|
||||
:action-label="t('profileAddresses.empty.cta')"
|
||||
:action-to="localePath('/clientarea/addresses/new')"
|
||||
action-icon="lucide:plus"
|
||||
/>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
isLoading,
|
||||
itemsWithCoords,
|
||||
deleteAddress,
|
||||
isoToEmoji,
|
||||
init
|
||||
} = useTeamAddresses()
|
||||
|
||||
await init()
|
||||
</script>
|
||||
75
app/pages/clientarea/addresses/map.vue
Normal file
75
app/pages/clientarea/addresses/map.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<NuxtLayout name="map">
|
||||
<template #sidebar>
|
||||
<CatalogMapSidebar
|
||||
:title="t('profileAddresses.header.title')"
|
||||
:back-link="localePath('/clientarea/addresses')"
|
||||
:back-label="t('catalogMap.actions.list_view')"
|
||||
:items-count="items.length"
|
||||
:loading="isLoading"
|
||||
:empty-text="t('profileAddresses.empty.title')"
|
||||
>
|
||||
<template #cards>
|
||||
<Card
|
||||
v-for="addr in items"
|
||||
:key="addr.uuid"
|
||||
padding="small"
|
||||
interactive
|
||||
:class="{ 'ring-2 ring-primary': selectedItemId === addr.uuid }"
|
||||
@click="selectItem(addr)"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Text size="base" weight="semibold" class="truncate">{{ addr.name }}</Text>
|
||||
<span class="text-lg">{{ isoToEmoji(addr.countryCode) }}</span>
|
||||
</div>
|
||||
<Text tone="muted" size="sm" class="line-clamp-2">{{ addr.address }}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</CatalogMapSidebar>
|
||||
</template>
|
||||
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="addresses-fullscreen-map"
|
||||
:items="itemsWithCoords"
|
||||
point-color="#3b82f6"
|
||||
@select-item="onMapSelectItem"
|
||||
/>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
isLoading,
|
||||
itemsWithCoords,
|
||||
isoToEmoji,
|
||||
init
|
||||
} = useTeamAddresses()
|
||||
|
||||
await init()
|
||||
|
||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||
const selectedItemId = ref<string | null>(null)
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
selectedItemId.value = item.uuid
|
||||
if (item.latitude && item.longitude) {
|
||||
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
||||
}
|
||||
}
|
||||
|
||||
const onMapSelectItem = (uuid: string) => {
|
||||
selectedItemId.value = uuid
|
||||
}
|
||||
</script>
|
||||
247
app/pages/clientarea/addresses/new.vue
Normal file
247
app/pages/clientarea/addresses/new.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<PageHeader :title="t('profileAddresses.form.title')" />
|
||||
|
||||
<Stack gap="4">
|
||||
<Input
|
||||
v-model="newAddress.name"
|
||||
:label="t('profileAddresses.form.name.label')"
|
||||
:placeholder="t('profileAddresses.form.name.placeholder')"
|
||||
/>
|
||||
|
||||
<!-- Address search with Mapbox -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ t('profileAddresses.form.address.label') }}</span>
|
||||
</label>
|
||||
<div ref="searchBoxContainer" class="w-full" />
|
||||
<input
|
||||
v-if="newAddress.address"
|
||||
type="hidden"
|
||||
:value="newAddress.address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mapbox map for selecting coordinates -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span>
|
||||
</label>
|
||||
<div class="h-80 rounded-lg overflow-hidden border border-base-300">
|
||||
<MapboxMap
|
||||
ref="mapComponentRef"
|
||||
:map-id="'address-picker'"
|
||||
style="width: 100%; height: 100%"
|
||||
:options="{
|
||||
style: 'mapbox://styles/mapbox/streets-v12',
|
||||
center: [newAddress.longitude || 37.6173, newAddress.latitude || 55.7558],
|
||||
zoom: 10
|
||||
}"
|
||||
@mb-click="onMapClick"
|
||||
@mb-created="onMapCreated"
|
||||
>
|
||||
<MapboxDefaultMarker
|
||||
v-if="newAddress.latitude && newAddress.longitude"
|
||||
:marker-id="'selected-location'"
|
||||
:lnglat="[newAddress.longitude, newAddress.latitude]"
|
||||
color="#3b82f6"
|
||||
/>
|
||||
</MapboxMap>
|
||||
</div>
|
||||
<label class="label" v-if="newAddress.latitude && newAddress.longitude">
|
||||
<span class="label-text-alt text-success">
|
||||
{{ newAddress.latitude.toFixed(6) }}, {{ newAddress.longitude.toFixed(6) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Stack direction="row" gap="3">
|
||||
<Button @click="createAddress" :disabled="isCreating || !newAddress.latitude">
|
||||
{{ isCreating ? t('profileAddresses.form.saving') : t('profileAddresses.form.save') }}
|
||||
</Button>
|
||||
<Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')">
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NuxtLink } from '#components'
|
||||
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const { mutate } = useGraphQL()
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const isCreating = ref(false)
|
||||
const searchBoxContainer = ref<HTMLElement | null>(null)
|
||||
const mapInstance = ref<MapboxMapType | null>(null)
|
||||
const searchBoxRef = ref<any>(null)
|
||||
|
||||
const newAddress = reactive({
|
||||
name: '',
|
||||
address: '',
|
||||
latitude: null as number | null,
|
||||
longitude: null as number | null,
|
||||
countryCode: null as string | null
|
||||
})
|
||||
|
||||
const onMapCreated = (map: MapboxMapType) => {
|
||||
mapInstance.value = map
|
||||
}
|
||||
|
||||
// Reverse geocode: get address by coordinates (local language)
|
||||
const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => {
|
||||
try {
|
||||
const token = config.public.mapboxAccessToken
|
||||
const response = await fetch(
|
||||
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${token}`
|
||||
)
|
||||
const data = await response.json()
|
||||
const feature = data.features?.[0]
|
||||
if (!feature) return { address: null, countryCode: null }
|
||||
|
||||
// Extract country code from context
|
||||
const countryContext = feature.context?.find((c: any) => c.id?.startsWith('country.'))
|
||||
const countryCode = countryContext?.short_code?.toUpperCase() || null
|
||||
|
||||
return { address: feature.place_name, countryCode }
|
||||
} catch {
|
||||
return { address: null, countryCode: null }
|
||||
}
|
||||
}
|
||||
|
||||
const onMapClick = async (event: MapMouseEvent) => {
|
||||
const lat = event.lngLat.lat
|
||||
const lng = event.lngLat.lng
|
||||
|
||||
newAddress.latitude = lat
|
||||
newAddress.longitude = lng
|
||||
|
||||
// Reverse geocode: load address by coordinates
|
||||
const result = await reverseGeocode(lat, lng)
|
||||
if (result.address) {
|
||||
newAddress.address = result.address
|
||||
newAddress.countryCode = result.countryCode
|
||||
// Update SearchBox input
|
||||
if (searchBoxRef.value) {
|
||||
searchBoxRef.value.value = result.address
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Mapbox SearchBox
|
||||
onMounted(async () => {
|
||||
if (!searchBoxContainer.value) return
|
||||
|
||||
const { MapboxSearchBox } = await import('@mapbox/search-js-web')
|
||||
|
||||
const searchBox = new MapboxSearchBox()
|
||||
searchBox.accessToken = config.public.mapboxAccessToken as string
|
||||
searchBox.options = {
|
||||
// Without language: uses local country language
|
||||
}
|
||||
searchBox.placeholder = t('profileAddresses.form.address.placeholder')
|
||||
|
||||
searchBox.addEventListener('retrieve', (event: any) => {
|
||||
const feature = event.detail.features?.[0]
|
||||
if (feature) {
|
||||
const [lng, lat] = feature.geometry.coordinates
|
||||
newAddress.address = feature.properties.full_address || feature.properties.name
|
||||
newAddress.latitude = lat
|
||||
newAddress.longitude = lng
|
||||
|
||||
// Extract country code from context
|
||||
const countryContext = feature.properties.context?.country
|
||||
newAddress.countryCode = countryContext?.country_code?.toUpperCase() || null
|
||||
|
||||
// Fly to selected location
|
||||
if (mapInstance.value) {
|
||||
mapInstance.value.flyTo({
|
||||
center: [lng, lat],
|
||||
zoom: 15
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
searchBoxRef.value = searchBox
|
||||
searchBoxContainer.value.appendChild(searchBox as unknown as Node)
|
||||
})
|
||||
|
||||
const createAddress = async () => {
|
||||
if (!newAddress.name || !newAddress.address || !newAddress.latitude) return
|
||||
|
||||
isCreating.value = true
|
||||
try {
|
||||
const { CreateTeamAddressDocument } = await import('~/composables/graphql/team/teams-generated')
|
||||
const result = await mutate(CreateTeamAddressDocument, {
|
||||
input: {
|
||||
name: newAddress.name,
|
||||
address: newAddress.address,
|
||||
latitude: newAddress.latitude,
|
||||
longitude: newAddress.longitude,
|
||||
countryCode: newAddress.countryCode,
|
||||
isDefault: false
|
||||
}
|
||||
}, 'team', 'teams')
|
||||
|
||||
if (result.createTeamAddress?.success) {
|
||||
// Address is created asynchronously via workflow
|
||||
// Redirect to list; address will appear after sync
|
||||
navigateTo(localePath('/clientarea/addresses'))
|
||||
} else {
|
||||
console.error('Failed to create address:', result.createTeamAddress?.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create address', e)
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Mapbox SearchBox styling */
|
||||
mapbox-search-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
mapbox-search-box::part(input) {
|
||||
height: 3rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid oklch(var(--bc) / 0.2);
|
||||
border-radius: var(--rounded-btn, 0.5rem);
|
||||
background-color: oklch(var(--b1));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
|
||||
mapbox-search-box::part(input):focus {
|
||||
outline: 2px solid oklch(var(--p));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
mapbox-search-box::part(results-list) {
|
||||
background-color: oklch(var(--b1));
|
||||
border: 1px solid oklch(var(--bc) / 0.2);
|
||||
border-radius: var(--rounded-btn, 0.5rem);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
mapbox-search-box::part(result-item) {
|
||||
padding: 0.75rem 1rem;
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
|
||||
mapbox-search-box::part(result-item):hover {
|
||||
background-color: oklch(var(--b2));
|
||||
}
|
||||
</style>
|
||||
4
app/pages/clientarea/ai/[id].vue
Normal file
4
app/pages/clientarea/ai/[id].vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
const localePath = useLocalePath()
|
||||
await navigateTo(localePath('/clientarea/ai'))
|
||||
</script>
|
||||
120
app/pages/clientarea/ai/index.vue
Normal file
120
app/pages/clientarea/ai/index.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="6">
|
||||
<PageHeader :title="t('aiAssistants.header.title')" />
|
||||
|
||||
<Card padding="lg" class="w-full">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" gap="3" align="center">
|
||||
<IconCircle tone="primary" size="lg">🛰️</IconCircle>
|
||||
<div>
|
||||
<Heading :level="3" weight="semibold">{{ t('aiAssistants.view.agentTitle') }}</Heading>
|
||||
<Text tone="muted">{{ t('aiAssistants.view.agentSubtitle') }}</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<div class="bg-base-200 rounded-box p-4 h-[70vh] overflow-y-auto space-y-3">
|
||||
<div
|
||||
v-for="(message, idx) in chat"
|
||||
:key="idx"
|
||||
class="flex"
|
||||
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<div
|
||||
class="max-w-[80%] rounded-2xl px-4 py-3 shadow-sm"
|
||||
:class="message.role === 'user' ? 'bg-primary text-primary-content' : 'bg-base-100 text-base-content'"
|
||||
>
|
||||
<Text weight="semibold" class="mb-1">
|
||||
{{ message.role === 'user' ? t('aiAssistants.view.you') : t('aiAssistants.view.agentName') }}
|
||||
</Text>
|
||||
<Text :tone="message.role === 'user' ? undefined : 'muted'">
|
||||
{{ message.content }}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isStreaming" class="text-sm text-base-content/60">
|
||||
{{ t('aiAssistants.view.typing') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="handleSend">
|
||||
<Textarea
|
||||
v-model="input"
|
||||
:placeholder="t('aiAssistants.view.placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button type="submit" :loading="isSending" :disabled="!input.trim()">
|
||||
{{ t('aiAssistants.view.send') }}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" @click="resetChat" :disabled="isSending">
|
||||
{{ t('aiAssistants.view.reset') }}
|
||||
</Button>
|
||||
<div class="text-sm text-base-content/60" v-if="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
|
||||
const agentUrl = computed(() => runtimeConfig.public.langAgentUrl || '')
|
||||
const chat = ref<{ role: 'user' | 'assistant', content: string }[]>([
|
||||
{ role: 'assistant', content: t('aiAssistants.view.welcome') }
|
||||
])
|
||||
const input = ref('')
|
||||
const isSending = ref(false)
|
||||
const isStreaming = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.value.trim()) return
|
||||
error.value = ''
|
||||
const userMessage = input.value.trim()
|
||||
chat.value.push({ role: 'user', content: userMessage })
|
||||
input.value = ''
|
||||
isSending.value = true
|
||||
isStreaming.value = true
|
||||
|
||||
try {
|
||||
const body = {
|
||||
input: {
|
||||
messages: chat.value.map((m) => ({
|
||||
role: m.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: m.content
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const response = await $fetch(`${agentUrl.value}/invoke`, {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
const outputMessages = (response as any)?.output?.messages || []
|
||||
const last = outputMessages[outputMessages.length - 1]
|
||||
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
|
||||
chat.value.push({ role: 'assistant', content })
|
||||
} catch (e: any) {
|
||||
console.error('Agent error', e)
|
||||
error.value = e?.message || t('aiAssistants.view.error')
|
||||
chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') })
|
||||
} finally {
|
||||
isSending.value = false
|
||||
isStreaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetChat = () => {
|
||||
chat.value = [{ role: 'assistant', content: t('aiAssistants.view.welcome') }]
|
||||
input.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
</script>
|
||||
181
app/pages/clientarea/billing/index.vue
Normal file
181
app/pages/clientarea/billing/index.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="6">
|
||||
<PageHeader :title="t('billing.header.title')" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<Card v-if="isLoading" padding="lg">
|
||||
<Stack align="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('billing.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<!-- Error state -->
|
||||
<Alert v-else-if="error" variant="error">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ t('billing.errors.title') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
<Button @click="loadBalance">{{ t('billing.errors.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<!-- Balance card -->
|
||||
<template v-else>
|
||||
<Card padding="lg">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" gap="4" align="center" justify="between">
|
||||
<Stack gap="1">
|
||||
<Text tone="muted" size="sm">{{ t('billing.balance.label') }}</Text>
|
||||
<Heading :level="2" weight="bold">
|
||||
{{ formatCurrency(balance.balance) }}
|
||||
</Heading>
|
||||
</Stack>
|
||||
<IconCircle tone="primary" size="lg">
|
||||
<Icon name="lucide:wallet" size="24" />
|
||||
</IconCircle>
|
||||
</Stack>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<Grid :cols="2" :gap="4">
|
||||
<Stack gap="1">
|
||||
<Text tone="muted" size="sm">{{ t('billing.balance.credits') }}</Text>
|
||||
<Text weight="semibold" class="text-success">
|
||||
+{{ formatCurrency(balance.creditsPosted) }}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap="1">
|
||||
<Text tone="muted" size="sm">{{ t('billing.balance.debits') }}</Text>
|
||||
<Text weight="semibold" class="text-error">
|
||||
-{{ formatCurrency(balance.debitsPosted) }}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<!-- Transactions section -->
|
||||
<Stack gap="3">
|
||||
<Heading :level="3">{{ t('billing.transactions.title') }}</Heading>
|
||||
|
||||
<Card v-if="transactions.length === 0" padding="lg" tone="muted">
|
||||
<Stack align="center" gap="2">
|
||||
<Icon name="lucide:receipt" size="32" class="opacity-50" />
|
||||
<Text tone="muted">{{ t('billing.transactions.empty') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card v-else padding="none">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('billing.transactions.date') }}</th>
|
||||
<th>{{ t('billing.transactions.code') }}</th>
|
||||
<th>{{ t('billing.transactions.amount') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tx in transactions" :key="tx.id">
|
||||
<td>{{ formatTimestamp(tx.timestamp) }}</td>
|
||||
<td>{{ tx.codeName || tx.code || '—' }}</td>
|
||||
<td :class="tx.direction === 'credit' ? 'text-success' : 'text-error'">
|
||||
{{ tx.direction === 'credit' ? '+' : '-' }}{{ formatAmount(tx.amount) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Stack>
|
||||
</template>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const balance = ref({
|
||||
balance: 0,
|
||||
creditsPosted: 0,
|
||||
debitsPosted: 0,
|
||||
exists: false
|
||||
})
|
||||
|
||||
const transactions = ref<any[]>([])
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
// Amount is in kopecks, convert to base units
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount / 100)
|
||||
}
|
||||
|
||||
const formatAmount = (amount: number) => {
|
||||
// Amount is in kopecks, convert to rubles
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount / 100)
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
if (!timestamp) return '—'
|
||||
// TigerBeetle timestamp is in nanoseconds since epoch
|
||||
const date = new Date(timestamp / 1000000)
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadBalance = async () => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Import will work after codegen runs
|
||||
const { GetTeamBalanceDocument } = await import('~/composables/graphql/team/billing-generated')
|
||||
const { data, error: balanceError } = await useServerQuery('team-balance', GetTeamBalanceDocument, {}, 'team', 'billing')
|
||||
|
||||
if (balanceError.value) throw balanceError.value
|
||||
|
||||
if (data.value?.teamBalance) {
|
||||
balance.value = data.value.teamBalance
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message || t('billing.errors.load_failed')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTransactions = async () => {
|
||||
try {
|
||||
const { GetTeamTransactionsDocument } = await import('~/composables/graphql/team/billing-generated')
|
||||
const { data, error: txError } = await useServerQuery('team-transactions', GetTeamTransactionsDocument, { limit: 50 }, 'team', 'billing')
|
||||
|
||||
if (txError.value) throw txError.value
|
||||
|
||||
transactions.value = data.value?.teamTransactions || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load transactions', e)
|
||||
}
|
||||
}
|
||||
|
||||
await loadBalance()
|
||||
await loadTransactions()
|
||||
</script>
|
||||
160
app/pages/clientarea/company-switch.vue
Normal file
160
app/pages/clientarea/company-switch.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<Section variant="plain">
|
||||
<Stack gap="6">
|
||||
<Stack gap="2">
|
||||
<Heading :level="1">{{ $t('dashboard.switch_company') }}</Heading>
|
||||
<Text tone="muted" size="base">{{ $t('teams.switch_description') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Alert v-if="hasError" variant="error">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ $t('common.error') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
<Button @click="loadUserTeams">{{ t('clientTeam.error.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Card v-else-if="isLoading" tone="muted" padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('clientTeamSwitch.loading.message') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="!userTeams.length && !showCreateForm" padding="lg">
|
||||
<Stack align="center" gap="3">
|
||||
<IconCircle tone="primary">🏢</IconCircle>
|
||||
<Heading :level="3" align="center">{{ $t('teams.no_team') }}</Heading>
|
||||
<Text tone="muted" align="center">{{ $t('teams.no_team_description') }}</Text>
|
||||
<Button @click="showCreateForm = true">
|
||||
{{ $t('teams.create_first_team') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4" v-if="!showCreateForm">
|
||||
<Card
|
||||
v-for="team in userTeams"
|
||||
:key="team.id"
|
||||
padding="lg"
|
||||
:class="[
|
||||
'cursor-pointer transition-all',
|
||||
team.isActive ? 'ring-2 ring-primary bg-primary/5' : 'hover:shadow-md'
|
||||
]"
|
||||
@click="switchToTeam(team.id)"
|
||||
>
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" gap="3" align="center">
|
||||
<IconCircle :tone="team.isActive ? 'primary' : 'neutral'">
|
||||
{{ team.name?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</IconCircle>
|
||||
<Stack gap="1">
|
||||
<Heading :level="4" weight="semibold">{{ team.name }}</Heading>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Pill v-if="team.isActive" variant="primary">{{ $t('teams.active') }}</Pill>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" class="border-2 border-dashed border-base-300 hover:border-primary cursor-pointer transition-colors" @click="showCreateForm = true">
|
||||
<Stack align="center" gap="3">
|
||||
<IconCircle tone="neutral">+</IconCircle>
|
||||
<Heading :level="4" weight="semibold">{{ $t('teams.create_new_team') }}</Heading>
|
||||
<Text tone="muted" align="center">{{ $t('teams.create_description') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<TeamCreateForm
|
||||
v-else
|
||||
@team-created="handleTeamCreated"
|
||||
@cancel="showCreateForm = false"
|
||||
/>
|
||||
</template>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SwitchTeamDocument } from '~/composables/graphql/user/teams-generated'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
const { mutate } = useGraphQL()
|
||||
const { setActiveTeam } = useActiveTeam()
|
||||
const me = useState<{
|
||||
teams?: Array<{ id?: string | null; name: string; logtoOrgId?: string | null } | null> | null
|
||||
activeTeamId?: string | null
|
||||
} | null>('me', () => null)
|
||||
|
||||
const userTeams = ref<Array<{ id: string; name: string; logtoOrgId?: string | null; isActive?: boolean }>>([])
|
||||
const isLoading = ref(true)
|
||||
const hasError = ref(false)
|
||||
const error = ref('')
|
||||
const showCreateForm = ref(false)
|
||||
|
||||
const currentActiveTeam = computed(() => userTeams.value.find(team => team.isActive) || null)
|
||||
const otherTeams = computed(() => userTeams.value.filter(team => !team.isActive))
|
||||
|
||||
const markActiveTeam = (teamId: string) => {
|
||||
if (me.value) {
|
||||
me.value = { ...me.value, activeTeamId: teamId }
|
||||
}
|
||||
userTeams.value = userTeams.value.map(team => ({
|
||||
...team,
|
||||
isActive: team.id === teamId
|
||||
}))
|
||||
}
|
||||
|
||||
const loadUserTeams = () => {
|
||||
isLoading.value = true
|
||||
hasError.value = false
|
||||
|
||||
if (!me.value?.teams) {
|
||||
hasError.value = true
|
||||
error.value = t('clientTeamSwitch.error.load')
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
userTeams.value = me.value.teams
|
||||
.filter((t): t is NonNullable<typeof t> => t !== null)
|
||||
.map(t => ({
|
||||
id: t.id || '',
|
||||
name: t.name,
|
||||
logtoOrgId: t.logtoOrgId,
|
||||
isActive: t.id === me.value?.activeTeamId
|
||||
}))
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const switchToTeam = async (teamId: string) => {
|
||||
try {
|
||||
const selectedTeam = userTeams.value.find(team => team.id === teamId)
|
||||
if (selectedTeam) setActiveTeam(teamId, selectedTeam.logtoOrgId)
|
||||
|
||||
const result = await mutate(SwitchTeamDocument, { teamId }, 'user', 'teams')
|
||||
if (result.switchTeam?.user) {
|
||||
const newActiveId = result.switchTeam.user.activeTeamId || teamId
|
||||
setActiveTeam(newActiveId, selectedTeam?.logtoOrgId)
|
||||
markActiveTeam(newActiveId)
|
||||
navigateTo(localePath('/clientarea/team'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || t('clientTeamSwitch.error.switch')
|
||||
hasError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTeamCreated = () => {
|
||||
showCreateForm.value = false
|
||||
navigateTo(localePath('/clientarea/team'))
|
||||
}
|
||||
|
||||
loadUserTeams()
|
||||
</script>
|
||||
9
app/pages/clientarea/goods.vue
Normal file
9
app/pages/clientarea/goods.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<GoodsContent />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
</script>
|
||||
15
app/pages/clientarea/index.vue
Normal file
15
app/pages/clientarea/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack align="center" justify="center" gap="2">
|
||||
<Text tone="muted">{{ t('clientRedirect.status.redirecting') }}</Text>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
await navigateTo(localePath('/'))
|
||||
</script>
|
||||
156
app/pages/clientarea/kyc/index.vue
Normal file
156
app/pages/clientarea/kyc/index.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div>
|
||||
<Alert v-if="error" variant="error" class="mb-4">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ t('kycOverview.errors.title') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
<Button @click="loadKYCStatus">{{ t('kycOverview.errors.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Card v-else-if="loading" tone="muted" padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('kycOverview.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<Stack gap="6">
|
||||
<!-- Список существующих заявок -->
|
||||
<Stack v-if="kycRequests.length > 0" gap="4">
|
||||
<Heading :level="2">{{ t('kycOverview.list.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card v-for="request in kycRequests" :key="request.uuid" padding="lg">
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" gap="2" align="center" justify="between">
|
||||
<Heading :level="4" weight="semibold">{{ request.companyName || t('kycOverview.list.unnamed') }}</Heading>
|
||||
<Pill :variant="getStatusVariant(request)" :tone="getStatusTone(request)">
|
||||
{{ getStatusText(request) }}
|
||||
</Pill>
|
||||
</Stack>
|
||||
<Text tone="muted" size="base">
|
||||
{{ t('kycOverview.list.submitted') }}: {{ formatDate(request.createdAt) }}
|
||||
</Text>
|
||||
<Text v-if="request.inn" tone="muted" size="base">
|
||||
{{ t('kycOverview.list.inn') }}: {{ request.inn }}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<!-- Добавить новую заявку -->
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('kycOverview.choose_country.title') }}</Heading>
|
||||
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card padding="lg" interactive @click="selectCountry('russia')">
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" gap="2" align="center">
|
||||
<IconCircle tone="primary">🇷🇺</IconCircle>
|
||||
<Heading :level="4" weight="semibold">{{ t('kycOverview.countries.russia.title') }}</Heading>
|
||||
</Stack>
|
||||
<Text tone="muted" size="base">
|
||||
{{ t('kycOverview.countries.russia.description') }}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Pill variant="primary">{{ t('kycOverview.countries.russia.badge') }}</Pill>
|
||||
<Text tone="muted" weight="semibold">→</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" tone="muted">
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" gap="2" align="center">
|
||||
<IconCircle tone="neutral">🇰🇿</IconCircle>
|
||||
<Heading :level="4" weight="semibold">{{ t('kycOverview.countries.kazakhstan.title') }}</Heading>
|
||||
</Stack>
|
||||
<Text tone="muted" size="base">
|
||||
{{ t('kycOverview.countries.kazakhstan.description') }}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Pill variant="outline" tone="warning">{{ t('kycOverview.countries.kazakhstan.badge') }}</Pill>
|
||||
<Text tone="muted" weight="semibold">→</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ t('kycOverview.info.title') }}</Heading>
|
||||
<Text tone="muted" size="base">• {{ t('kycOverview.info.point1') }}</Text>
|
||||
<Text tone="muted" size="base">• {{ t('kycOverview.info.point2') }}</Text>
|
||||
<Text tone="muted" size="base">• {{ t('kycOverview.info.point3') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetKycRequestsRussiaDocument } from '~/composables/graphql/user/kyc-generated'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const kycRequests = ref<any[]>([])
|
||||
|
||||
const selectCountry = (country: string) => {
|
||||
if (country === 'russia') {
|
||||
navigateTo('/clientarea/kyc/russia')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusVariant = (request: any) => {
|
||||
if (request.approvedAt) return 'primary'
|
||||
if (request.rejectedAt) return 'outline'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
const getStatusTone = (request: any) => {
|
||||
if (request.approvedAt) return 'success'
|
||||
if (request.rejectedAt) return 'error'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
const getStatusText = (request: any) => {
|
||||
if (request.approvedAt) return t('kycOverview.list.status.approved')
|
||||
if (request.rejectedAt) return t('kycOverview.list.status.rejected')
|
||||
return t('kycOverview.list.status.pending')
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
const loadKYCStatus = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const { data, error: kycError } = await useServerQuery('kyc-requests', GetKycRequestsRussiaDocument, {}, 'user', 'kyc')
|
||||
if (kycError.value) throw kycError.value
|
||||
const requests = data.value?.kycRequests || []
|
||||
// Сортируем по дате создания (новые первые)
|
||||
kycRequests.value = [...requests].sort((a: any, b: any) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
} catch (err: any) {
|
||||
error.value = t('kycOverview.errors.load_failed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
await loadKYCStatus()
|
||||
</script>
|
||||
93
app/pages/clientarea/kyc/russia.vue
Normal file
93
app/pages/clientarea/kyc/russia.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<Section variant="plain">
|
||||
<Stack gap="6">
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" gap="2" align="center">
|
||||
<IconCircle tone="primary">🇷🇺</IconCircle>
|
||||
<Heading :level="1">{{ t('kycRussia.header.title') }}</Heading>
|
||||
</Stack>
|
||||
<Text tone="muted" size="base">
|
||||
{{ t('kycRussia.header.subtitle') }}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Card v-if="submitting" tone="muted" padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('kycRussia.states.submitting') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Alert v-else-if="submitError" variant="error">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ t('kycRussia.errors.title') }}</Heading>
|
||||
<Text tone="muted">{{ submitError }}</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Card v-else-if="submitSuccess" tone="muted" padding="lg">
|
||||
<Stack gap="2">
|
||||
<Heading :level="3" weight="semibold">{{ t('kycRussia.success.title') }}</Heading>
|
||||
<Text tone="muted">
|
||||
{{ t('kycRussia.success.description') }}
|
||||
</Text>
|
||||
<Button :as="'NuxtLink'" to="/clientarea/kyc" variant="outline">
|
||||
{{ t('kycRussia.success.cta') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<KYCFormRussia v-else @submit="handleSubmit" />
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CreateKycRequestRussiaDocument } from '~/composables/graphql/user/kyc-generated'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const { mutate } = useGraphQL()
|
||||
const { t } = useI18n()
|
||||
|
||||
const submitting = ref(false)
|
||||
const submitError = ref<string | null>(null)
|
||||
const submitSuccess = ref(false)
|
||||
|
||||
const handleSubmit = async (formData: any) => {
|
||||
try {
|
||||
submitting.value = true
|
||||
submitError.value = null
|
||||
|
||||
const submitData = {
|
||||
companyName: formData.company_name,
|
||||
companyFullName: formData.company_full_name,
|
||||
inn: formData.inn,
|
||||
kpp: formData.kpp || '',
|
||||
ogrn: formData.ogrn || '',
|
||||
address: formData.address,
|
||||
bankName: formData.bank_name,
|
||||
bik: formData.bik,
|
||||
correspondentAccount: formData.correspondent_account || '',
|
||||
contactPerson: formData.contact_person,
|
||||
contactEmail: formData.contact_email,
|
||||
contactPhone: formData.contact_phone,
|
||||
}
|
||||
|
||||
const result = await mutate(CreateKycRequestRussiaDocument, { input: submitData }, 'user', 'kyc')
|
||||
|
||||
if (result.createKycRequestRussia?.success) {
|
||||
submitSuccess.value = true
|
||||
setTimeout(() => navigateTo('/clientarea/kyc'), 3000)
|
||||
} else {
|
||||
throw new Error(t('kycRussia.errors.create_failed'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
submitError.value = err.message || t('kycRussia.errors.submit_failed')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
9
app/pages/clientarea/locations.vue
Normal file
9
app/pages/clientarea/locations.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<LocationsContent />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
</script>
|
||||
281
app/pages/clientarea/offers/[uuid].vue
Normal file
281
app/pages/clientarea/offers/[uuid].vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<!-- Header -->
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="1">{{ t('clientOfferForm.header.title') }}</Heading>
|
||||
<NuxtLink :to="localePath('/clientarea/offers/new')">
|
||||
<Button variant="outline">
|
||||
<Icon name="lucide:arrow-left" size="16" class="mr-2" />
|
||||
{{ t('clientOfferForm.actions.back') }}
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
|
||||
<!-- Error -->
|
||||
<Alert v-if="hasError" variant="error">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ t('clientOfferForm.error.title') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
<Button @click="loadData">{{ t('clientOfferForm.error.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<!-- Loading -->
|
||||
<Card v-else-if="isLoading" tone="muted" padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('clientOfferForm.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<!-- No schema -->
|
||||
<Card v-else-if="!schemaId" padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="warning" size="lg">
|
||||
<Icon name="lucide:alert-triangle" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="3" align="center">{{ t('clientOfferForm.noSchema.title') }}</Heading>
|
||||
<Text tone="muted" align="center">
|
||||
{{ t('clientOfferForm.noSchema.description', { name: productName }) }}
|
||||
</Text>
|
||||
<NuxtLink :to="localePath('/clientarea/offers/new')">
|
||||
<Button variant="outline">{{ t('clientOfferForm.actions.chooseAnother') }}</Button>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<!-- Form -->
|
||||
<Card v-else padding="lg">
|
||||
<Stack gap="4">
|
||||
<Stack gap="2">
|
||||
<Heading :level="2">{{ productName }}</Heading>
|
||||
<Text v-if="schemaDescription" tone="muted">{{ schemaDescription }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="2">
|
||||
<Text weight="semibold">{{ t('clientOfferForm.labels.location') }}</Text>
|
||||
<select v-model="selectedAddressUuid" class="select select-bordered w-full">
|
||||
<option v-if="!addresses.length" :value="null">
|
||||
{{ t('clientOfferForm.labels.location_empty') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="address in addresses"
|
||||
:key="address.uuid"
|
||||
:value="address.uuid"
|
||||
>
|
||||
{{ address.name }} — {{ address.address }}
|
||||
</option>
|
||||
</select>
|
||||
</Stack>
|
||||
|
||||
<hr class="border-base-300" />
|
||||
|
||||
<!-- FormKit dynamic form -->
|
||||
<FormKit
|
||||
type="form"
|
||||
:actions="false"
|
||||
:config="formKitConfig"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<Stack gap="4">
|
||||
<FormKitSchema :schema="formkitSchema" />
|
||||
|
||||
<Stack direction="row" gap="3" justify="end">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
@click="navigateTo(localePath('/clientarea/offers/new'))"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? t('clientOfferForm.actions.saving') : t('clientOfferForm.actions.save') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FormKit>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<!-- Debug info -->
|
||||
<Card v-if="isDev" padding="md" tone="muted">
|
||||
<Stack gap="2">
|
||||
<Text size="sm" weight="semibold">Debug Info</Text>
|
||||
<Text size="sm" tone="muted">Product UUID: {{ productUuid }}</Text>
|
||||
<Text size="sm" tone="muted">Product Name: {{ productName }}</Text>
|
||||
<Text size="sm" tone="muted">Schema ID: {{ schemaId || t('clientOfferForm.debug.schema_missing') }}</Text>
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm text-base-content/70">FormKit Schema</summary>
|
||||
<pre class="text-xs mt-2 p-2 bg-base-200 border border-base-300 rounded overflow-auto">{{ JSON.stringify(formkitSchema, null, 2) }}</pre>
|
||||
</details>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FormKitSchema } from '@formkit/vue'
|
||||
import type { FormKitSchemaNode } from '@formkit/core'
|
||||
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
|
||||
import { CreateOfferDocument } from '~/composables/graphql/team/exchange-generated'
|
||||
import { GetTeamAddressesDocument } from '~/composables/graphql/team/teams-generated'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc'],
|
||||
validate: (route) => {
|
||||
// Exclude 'new' from the dynamic route
|
||||
return route.params.uuid !== 'new'
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
const { execute, mutate } = useGraphQL()
|
||||
const { getSchema, getEnums, schemaToFormKit } = useTerminus()
|
||||
const { activeTeamId } = useActiveTeam()
|
||||
|
||||
// State
|
||||
const isLoading = ref(true)
|
||||
const hasError = ref(false)
|
||||
const error = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const productUuid = computed(() => route.params.uuid as string)
|
||||
const productName = ref<string>('')
|
||||
const schemaId = ref<string | null>(null)
|
||||
const schemaDescription = ref<string | null>(null)
|
||||
const formkitSchema = ref<FormKitSchemaNode[]>([])
|
||||
const addresses = ref<any[]>([])
|
||||
const selectedAddressUuid = ref<string | null>(null)
|
||||
const formKitConfig = {
|
||||
classes: {
|
||||
form: 'space-y-4',
|
||||
label: 'text-sm font-semibold',
|
||||
inner: 'w-full',
|
||||
input: 'input input-bordered w-full',
|
||||
textarea: 'textarea textarea-bordered w-full',
|
||||
select: 'select select-bordered w-full',
|
||||
help: 'text-sm text-base-content/60',
|
||||
messages: 'text-error text-sm mt-1',
|
||||
message: 'text-error text-sm',
|
||||
},
|
||||
}
|
||||
|
||||
const isDev = process.dev
|
||||
|
||||
const loadAddresses = async () => {
|
||||
try {
|
||||
const { data, error: addressesError } = await useServerQuery('offer-form-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
|
||||
if (addressesError.value) throw addressesError.value
|
||||
addresses.value = data.value?.teamAddresses || []
|
||||
const defaultAddress = addresses.value.find((address: any) => address.isDefault)
|
||||
selectedAddressUuid.value = defaultAddress?.uuid || addresses.value[0]?.uuid || null
|
||||
} catch (err) {
|
||||
console.error('Failed to load addresses:', err)
|
||||
addresses.value = []
|
||||
selectedAddressUuid.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Load data
|
||||
const loadData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
hasError.value = false
|
||||
|
||||
// 1. Load product and get terminus_schema_id
|
||||
const { data: productsData, error: productsError } = await useServerQuery('offer-form-products', GetProductsDocument, {}, 'public', 'exchange')
|
||||
if (productsError.value) throw productsError.value
|
||||
const products = productsData.value?.getProducts || []
|
||||
const product = products.find((p: any) => p.uuid === productUuid.value)
|
||||
|
||||
if (!product) {
|
||||
throw new Error(t('clientOfferForm.errors.productNotFound', { uuid: productUuid.value }))
|
||||
}
|
||||
|
||||
productName.value = product.name
|
||||
schemaId.value = product.terminusSchemaId || null
|
||||
|
||||
if (!schemaId.value) {
|
||||
// No schema configured
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Load schema from TerminusDB
|
||||
const terminusClass = await getSchema(schemaId.value)
|
||||
|
||||
if (!terminusClass) {
|
||||
throw new Error(t('clientOfferForm.errors.schemaNotFound', { schema: schemaId.value }))
|
||||
}
|
||||
|
||||
// Save description
|
||||
schemaDescription.value = terminusClass['@documentation']?.['@comment'] || null
|
||||
|
||||
// 3. Load enums and convert to FormKit schema
|
||||
const enums = await getEnums()
|
||||
formkitSchema.value = schemaToFormKit(terminusClass, enums)
|
||||
await loadAddresses()
|
||||
|
||||
} catch (err: any) {
|
||||
hasError.value = true
|
||||
error.value = err.message || t('clientOfferForm.error.load')
|
||||
console.error('Load error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
try {
|
||||
isSubmitting.value = true
|
||||
|
||||
if (!activeTeamId.value) {
|
||||
throw new Error(t('clientOfferForm.error.load'))
|
||||
}
|
||||
|
||||
const selectedAddress = addresses.value.find((address: any) => address.uuid === selectedAddressUuid.value)
|
||||
if (!selectedAddress) {
|
||||
throw new Error(t('clientOfferForm.error.save'))
|
||||
}
|
||||
|
||||
const input = {
|
||||
teamUuid: activeTeamId.value,
|
||||
productUuid: productUuid.value,
|
||||
productName: productName.value,
|
||||
categoryName: undefined,
|
||||
locationUuid: selectedAddress.uuid,
|
||||
locationName: selectedAddress.name,
|
||||
locationCountry: '',
|
||||
locationCountryCode: selectedAddress.countryCode || '',
|
||||
locationLatitude: selectedAddress.latitude,
|
||||
locationLongitude: selectedAddress.longitude,
|
||||
quantity: data.quantity || 0,
|
||||
unit: data.unit || 'ton',
|
||||
pricePerUnit: data.price_per_unit || data.pricePerUnit || null,
|
||||
currency: data.currency || 'USD',
|
||||
description: data.description || '',
|
||||
validUntil: data.valid_until || data.validUntil || null,
|
||||
terminusSchemaId: schemaId.value,
|
||||
terminusPayload: JSON.stringify(data),
|
||||
}
|
||||
|
||||
const result = await mutate(CreateOfferDocument, { input }, 'team', 'exchange')
|
||||
if (!result.createOffer?.success) {
|
||||
throw new Error(result.createOffer?.message || t('clientOfferForm.error.save'))
|
||||
}
|
||||
|
||||
await navigateTo(localePath('/clientarea/offers'))
|
||||
|
||||
} catch (err: any) {
|
||||
error.value = err.message || t('clientOfferForm.error.save')
|
||||
hasError.value = true
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
await loadData()
|
||||
</script>
|
||||
207
app/pages/clientarea/offers/index.vue
Normal file
207
app/pages/clientarea/offers/index.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<PageHeader :title="t('clientOffersList.header.title')">
|
||||
<template #actions>
|
||||
<NuxtLink :to="localePath('/clientarea/offers/new')">
|
||||
<Button>
|
||||
<Icon name="lucide:plus" size="16" class="mr-2" />
|
||||
{{ t('clientOffersList.actions.add') }}
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<Alert v-if="hasError" variant="error">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ t('clientOffersList.error.title') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
<Button @click="loadOffers">{{ t('clientOffersList.error.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Stack v-else-if="isLoading" align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('clientOffersList.states.loading') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<template v-else>
|
||||
<Stack v-if="offers.length" gap="4">
|
||||
<Card v-for="offer in offers" :key="offer.uuid" padding="lg">
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading :level="3">{{ offer.productName || t('clientOffersList.labels.untitled') }}</Heading>
|
||||
<Badge :variant="getStatusVariant(offer.status)">
|
||||
{{ getStatusText(offer.status) }}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Text v-if="offer.categoryName" tone="muted">{{ offer.categoryName }}</Text>
|
||||
|
||||
<Grid :cols="1" :md="4" :gap="3">
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.quantity') }}</Text>
|
||||
<Text weight="semibold">{{ offer.quantity }} {{ offer.unit || t('search.units.tons_short') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.price') }}</Text>
|
||||
<Text weight="semibold">{{ formatPrice(offer.pricePerUnit, offer.currency) }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.location') }}</Text>
|
||||
<Text>{{ offer.locationName || t('clientOffersList.labels.not_specified') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.valid_until') }}</Text>
|
||||
<Text>{{ formatDate(offer.validUntil) }}</Text>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<PaginationLoadMore
|
||||
:shown="offers.length"
|
||||
:total="totalOffers"
|
||||
:can-load-more="canLoadMore"
|
||||
:loading="isLoadingMore"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="🏷️"
|
||||
:title="t('clientOffersList.empty.title')"
|
||||
:description="t('clientOffersList.empty.subtitle')"
|
||||
:action-label="t('clientOffersList.actions.addOffer')"
|
||||
:action-to="localePath('/clientarea/offers/new')"
|
||||
action-icon="lucide:plus"
|
||||
/>
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t, locale } = useI18n()
|
||||
const { activeTeamId } = useActiveTeam()
|
||||
|
||||
const { execute } = useGraphQL()
|
||||
const PAGE_SIZE = 24
|
||||
const offers = ref<any[]>([])
|
||||
const totalOffers = ref(0)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const {
|
||||
data: offersData,
|
||||
pending: offersPending,
|
||||
error: loadError,
|
||||
refresh: refreshOffers
|
||||
} = await useServerQuery(
|
||||
'client-offers-list',
|
||||
GetOffersDocument,
|
||||
{ teamUuid: activeTeamId.value || null, limit: PAGE_SIZE, offset: 0 },
|
||||
'public',
|
||||
'exchange'
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
if (offersData.value?.getOffers) {
|
||||
offers.value = offersData.value.getOffers
|
||||
totalOffers.value = offersData.value.getOffersCount ?? offersData.value.getOffers.length
|
||||
}
|
||||
})
|
||||
|
||||
const isLoading = computed(() => offersPending.value && offers.value.length === 0)
|
||||
const canLoadMore = computed(() => offers.value.length < totalOffers.value)
|
||||
const hasError = computed(() => !!loadError.value)
|
||||
const error = computed(() => loadError.value?.message || t('clientOffersList.error.load'))
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
const variants: Record<string, string> = {
|
||||
active: 'success',
|
||||
draft: 'warning',
|
||||
expired: 'error',
|
||||
sold: 'muted'
|
||||
}
|
||||
return variants[status] || 'muted'
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
active: t('clientOffersList.status.active'),
|
||||
draft: t('clientOffersList.status.draft'),
|
||||
expired: t('clientOffersList.status.expired'),
|
||||
sold: t('clientOffersList.status.sold')
|
||||
}
|
||||
return texts[status] || status || t('clientOffersList.status.unknown')
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return t('clientOffersList.labels.not_specified')
|
||||
try {
|
||||
const dateObj = new Date(date)
|
||||
if (isNaN(dateObj.getTime())) return t('clientOffersList.labels.invalid_date')
|
||||
return new Intl.DateTimeFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(dateObj)
|
||||
} catch {
|
||||
return t('clientOffersList.labels.invalid_date')
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: number | string | null | undefined, currency?: string | null) => {
|
||||
if (!price) return t('clientOffersList.labels.not_specified')
|
||||
const num = typeof price === 'string' ? parseFloat(price) : price
|
||||
const curr = currency || 'USD'
|
||||
try {
|
||||
return new Intl.NumberFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: curr,
|
||||
maximumFractionDigits: 0
|
||||
}).format(num)
|
||||
} catch {
|
||||
return `${num} ${curr}`
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOffers = async (offset = 0, replace = false) => {
|
||||
const data = await execute(
|
||||
GetOffersDocument,
|
||||
{ teamUuid: activeTeamId.value || null, limit: PAGE_SIZE, offset },
|
||||
'public',
|
||||
'exchange'
|
||||
)
|
||||
const next = data?.getOffers || []
|
||||
offers.value = replace ? next : offers.value.concat(next)
|
||||
totalOffers.value = data?.getOffersCount ?? totalOffers.value
|
||||
}
|
||||
|
||||
const loadOffers = async () => refreshOffers()
|
||||
const loadMore = async () => {
|
||||
if (isLoadingMore.value) return
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
await fetchOffers(offers.value.length)
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => activeTeamId.value,
|
||||
async () => {
|
||||
await fetchOffers(0, true)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
91
app/pages/clientarea/offers/new.vue
Normal file
91
app/pages/clientarea/offers/new.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="1">{{ t('offersNew.header.title') }}</Heading>
|
||||
<NuxtLink :to="localePath('/clientarea/offers')">
|
||||
<Button variant="outline">
|
||||
<Icon name="lucide:arrow-left" size="16" class="mr-2" />
|
||||
{{ t('offersNew.actions.back') }}
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
|
||||
<Alert v-if="hasError" variant="error">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ t('offersNew.errors.title') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
<Button @click="loadProducts">{{ t('offersNew.errors.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Stack v-else-if="isLoading" align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('offersNew.states.loading') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<template v-else>
|
||||
<Grid v-if="products.length" :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="product in products"
|
||||
:key="product.uuid"
|
||||
padding="lg"
|
||||
class="cursor-pointer hover:shadow-md transition-shadow"
|
||||
@click="selectProduct(product)"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Heading :level="3">{{ product.name }}</Heading>
|
||||
<Text tone="muted">{{ product.categoryName }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Stack v-else align="center" gap="3">
|
||||
<IconCircle tone="warning">
|
||||
<Icon name="lucide:package-x" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="3">{{ t('offersNew.empty.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('offersNew.empty.description') }}</Text>
|
||||
</Stack>
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
const { execute } = useGraphQL()
|
||||
|
||||
const products = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const hasError = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
hasError.value = false
|
||||
const { data, error: productsError } = await useServerQuery('offers-new-products', GetProductsDocument, {}, 'public', 'exchange')
|
||||
if (productsError.value) throw productsError.value
|
||||
products.value = data.value?.getProducts || []
|
||||
} catch (err: any) {
|
||||
hasError.value = true
|
||||
error.value = err.message || t('offersNew.errors.load_failed')
|
||||
products.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectProduct = (product: any) => {
|
||||
// Navigate to product details page
|
||||
navigateTo(localePath(`/clientarea/offers/${product.uuid}`))
|
||||
}
|
||||
|
||||
await loadProducts()
|
||||
</script>
|
||||
243
app/pages/clientarea/orders/[id].vue
Normal file
243
app/pages/clientarea/orders/[id].vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<Section variant="plain">
|
||||
<Stack gap="8">
|
||||
<template v-if="hasOrderError">
|
||||
<div class="text-sm text-error">
|
||||
{{ orderError }}
|
||||
</div>
|
||||
<Button @click="loadOrder">{{ t('ordersDetail.errors.retry') }}</Button>
|
||||
</template>
|
||||
|
||||
<div v-else-if="isLoadingOrder" class="text-sm text-base-content/60">
|
||||
{{ t('ordersDetail.states.loading') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<Card padding="lg" class="border border-base-300">
|
||||
<RouteSummaryHeader :title="orderTitle" :meta="orderMeta" />
|
||||
</Card>
|
||||
|
||||
<Card v-if="orderRoutesForMap.length" padding="lg" class="border border-base-300">
|
||||
<Stack gap="4">
|
||||
<RouteStagesList
|
||||
:stages="orderStageItems"
|
||||
:empty-text="t('ordersDetail.sections.stages.empty')"
|
||||
/>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<RequestRoutesMap :routes="orderRoutesForMap" :height="260" />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Heading :level="3" weight="semibold">{{ t('ordersDetail.sections.timeline.title') }}</Heading>
|
||||
<GanttTimeline
|
||||
v-if="order?.stages"
|
||||
:stages="order.stages"
|
||||
:showLoading="showLoading"
|
||||
:showUnloading="showUnloading"
|
||||
/>
|
||||
<Text v-else tone="muted">{{ t('ordersDetail.sections.timeline.empty') }}</Text>
|
||||
</div>
|
||||
</template>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetOrderDocument } from '~/composables/graphql/team/orders-generated'
|
||||
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const order = ref<any>(null)
|
||||
const isLoadingOrder = ref(true)
|
||||
const hasOrderError = ref(false)
|
||||
const orderError = ref('')
|
||||
const showLoading = ref(true)
|
||||
const showUnloading = ref(true)
|
||||
|
||||
const orderTitle = computed(() => {
|
||||
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
|
||||
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
|
||||
return `${source} → ${destination}`
|
||||
})
|
||||
|
||||
const orderMeta = computed(() => {
|
||||
const meta: string[] = []
|
||||
const orderName = order.value?.name || (route.params.id as string)
|
||||
if (orderName) meta.push(`#${orderName}`)
|
||||
|
||||
const line = order.value?.orderLines?.[0]
|
||||
if (line?.quantity) {
|
||||
meta.push(`${line.quantity} ${line.unit || t('ordersDetail.labels.unit_tons')}`)
|
||||
}
|
||||
if (line?.productName) {
|
||||
meta.push(line.productName)
|
||||
}
|
||||
if (order.value?.totalAmount) {
|
||||
meta.push(formatPrice(order.value.totalAmount, order.value?.currency))
|
||||
}
|
||||
|
||||
const durationDays = getOrderDuration()
|
||||
if (durationDays) {
|
||||
meta.push(`${durationDays} ${t('ordersDetail.labels.delivery_days')}`)
|
||||
}
|
||||
|
||||
return meta
|
||||
})
|
||||
|
||||
const orderRoutesForMap = computed(() => {
|
||||
const stages = (order.value?.stages || [])
|
||||
.filter(Boolean)
|
||||
.map((stage: any) => {
|
||||
if (stage.stageType === 'transport') {
|
||||
if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null
|
||||
return {
|
||||
fromLat: stage.sourceLatitude,
|
||||
fromLon: stage.sourceLongitude,
|
||||
fromName: stage.sourceLocationName,
|
||||
toLat: stage.destinationLatitude,
|
||||
toLon: stage.destinationLongitude,
|
||||
toName: stage.destinationLocationName,
|
||||
transportType: stage.transportType
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (!stages.length) return []
|
||||
return [{ stages }]
|
||||
})
|
||||
|
||||
const orderStageItems = computed<RouteStageItem[]>(() => {
|
||||
return (order.value?.stages || []).map((stage: any) => {
|
||||
const isTransport = stage.stageType === 'transport'
|
||||
const from = isTransport ? stage.sourceLocationName : stage.locationName
|
||||
const to = isTransport ? stage.destinationLocationName : stage.locationName
|
||||
|
||||
const meta: string[] = []
|
||||
const dateRange = getStageDateRange(stage)
|
||||
if (dateRange) {
|
||||
meta.push(dateRange)
|
||||
}
|
||||
|
||||
const companies = getCompaniesSummary(stage)
|
||||
companies.forEach((company: any) => {
|
||||
meta.push(
|
||||
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
key: stage.uuid,
|
||||
from,
|
||||
to,
|
||||
label: stage.name,
|
||||
meta
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const loadOrder = async () => {
|
||||
try {
|
||||
isLoadingOrder.value = true
|
||||
hasOrderError.value = false
|
||||
const orderUuid = route.params.id as string
|
||||
const { data, error: orderErrorResp } = await useServerQuery('order-detail', GetOrderDocument, { orderUuid }, 'team', 'orders')
|
||||
if (orderErrorResp.value) throw orderErrorResp.value
|
||||
order.value = data.value?.getOrder
|
||||
} catch (err: any) {
|
||||
hasOrderError.value = true
|
||||
orderError.value = err.message || t('ordersDetail.errors.load_failed')
|
||||
} finally {
|
||||
isLoadingOrder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency?: string | null) => {
|
||||
if (!price) return t('ordersDetail.labels.price_zero')
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: currency || 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
const getCompaniesSummary = (stage: any) => {
|
||||
const companies = []
|
||||
if (stage.stageType === 'service' && stage.selectedCompany) {
|
||||
companies.push({
|
||||
name: stage.selectedCompany.name,
|
||||
totalWeight: 0,
|
||||
tripsCount: 0,
|
||||
company: stage.selectedCompany
|
||||
})
|
||||
return companies
|
||||
}
|
||||
|
||||
if (stage.stageType === 'transport' && stage.trips?.length) {
|
||||
const companiesMap = new Map()
|
||||
stage.trips.forEach((trip: any) => {
|
||||
const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown')
|
||||
const weight = trip.plannedWeight || 0
|
||||
if (companiesMap.has(companyName)) {
|
||||
const existing = companiesMap.get(companyName)
|
||||
existing.totalWeight += weight
|
||||
existing.tripsCount += 1
|
||||
} else {
|
||||
companiesMap.set(companyName, {
|
||||
name: companyName,
|
||||
totalWeight: weight,
|
||||
tripsCount: 1,
|
||||
company: trip.company
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(companiesMap.values())
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getOrderDuration = () => {
|
||||
if (!order.value?.stages?.length) return 0
|
||||
let minDate: Date | null = null
|
||||
let maxDate: Date | null = null
|
||||
order.value.stages.forEach((stage: any) => {
|
||||
stage.trips?.forEach((trip: any) => {
|
||||
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate)
|
||||
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate)
|
||||
if (!minDate || startDate < minDate) minDate = startDate
|
||||
if (!maxDate || endDate > maxDate) maxDate = endDate
|
||||
})
|
||||
})
|
||||
if (!minDate || !maxDate) return 0
|
||||
const diffTime = Math.abs(maxDate.getTime() - minDate.getTime())
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
const getStageDateRange = (stage: any) => {
|
||||
if (!stage.trips?.length) return t('ordersDetail.labels.dates_undefined')
|
||||
let minDate: Date | null = null
|
||||
let maxDate: Date | null = null
|
||||
stage.trips.forEach((trip: any) => {
|
||||
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate)
|
||||
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate)
|
||||
if (!minDate || startDate < minDate) minDate = startDate
|
||||
if (!maxDate || endDate > maxDate) maxDate = endDate
|
||||
})
|
||||
if (!minDate || !maxDate) return t('ordersDetail.labels.dates_undefined')
|
||||
const formatDate = (date: Date) => date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||
if (minDate.toDateString() === maxDate.toDateString()) return formatDate(minDate)
|
||||
return `${formatDate(minDate)} - ${formatDate(maxDate)}`
|
||||
}
|
||||
|
||||
await loadOrder()
|
||||
</script>
|
||||
174
app/pages/clientarea/orders/index.vue
Normal file
174
app/pages/clientarea/orders/index.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="6">
|
||||
<PageHeader
|
||||
:title="$t('dashboard.orders')"
|
||||
:actions="[{ label: t('ordersList.actions.new_calc'), icon: 'lucide:plus', to: localePath('/clientarea') }]"
|
||||
/>
|
||||
|
||||
<Alert v-if="hasError" variant="error">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ $t('common.error') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
<Button @click="load">{{ t('ordersList.errors.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Stack v-else-if="isLoading" align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('ordersList.states.loading') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="items.length">
|
||||
<NuxtLink :to="localePath('/clientarea/orders/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
|
||||
<ClientOnly>
|
||||
<OrdersRoutesPreview :routes="routesForMap" :height="192" />
|
||||
</ClientOnly>
|
||||
</NuxtLink>
|
||||
|
||||
<CatalogFilters :filters="filters" v-model="selectedFilter" />
|
||||
|
||||
<Stack gap="4">
|
||||
<Card v-for="order in filteredItems" :key="order.uuid" padding="lg" class="cursor-pointer" @click="openOrder(order)">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('ordersList.card.order_label') }}</Text>
|
||||
<Heading :level="3">#{{ order.name }}</Heading>
|
||||
</Stack>
|
||||
<div class="badge badge-outline">
|
||||
{{ getOrderStartDate(order) }} → {{ getOrderEndDate(order) }}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<Grid :cols="1" :md="3" :gap="3">
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('ordersList.card.route') }}</Text>
|
||||
<Text weight="semibold">{{ order.sourceLocationName }} → {{ order.destinationLocationName }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('ordersList.card.product') }}</Text>
|
||||
<Text>
|
||||
{{ order.orderLines?.[0]?.productName || t('ordersList.card.product_loading') }}
|
||||
<template v-if="order.orderLines?.length > 1">
|
||||
<span class="badge badge-ghost ml-2">+{{ order.orderLines.length - 1 }}</span>
|
||||
</template>
|
||||
</Text>
|
||||
<Text tone="muted" size="sm">
|
||||
{{ order.orderLines?.[0]?.quantity || 0 }} {{ order.orderLines?.[0]?.unit || t('ordersList.card.unit_tons') }}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('ordersList.card.status') }}</Text>
|
||||
<Badge :variant="getStatusVariant(order.status)">
|
||||
{{ getStatusText(order.status) }}
|
||||
</Badge>
|
||||
<Text tone="muted" size="sm">{{ t('ordersList.card.stages_completed', { done: getCompletedStages(order), total: order.stages?.length || 0 }) }}</Text>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="📦"
|
||||
:title="$t('orders.no_orders')"
|
||||
:description="$t('orders.no_orders_desc')"
|
||||
:action-label="$t('orders.create_new')"
|
||||
:action-to="localePath('/clientarea')"
|
||||
action-icon="lucide:plus"
|
||||
/>
|
||||
</template>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
items,
|
||||
filteredItems,
|
||||
isLoading,
|
||||
filters,
|
||||
selectedFilter,
|
||||
routesForMap,
|
||||
load,
|
||||
init,
|
||||
getStatusVariant,
|
||||
getStatusText
|
||||
} = useTeamOrders()
|
||||
|
||||
const hasError = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
try {
|
||||
await init()
|
||||
} catch (err: any) {
|
||||
hasError.value = true
|
||||
error.value = err.message || t('ordersDetail.errors.load_failed')
|
||||
}
|
||||
|
||||
const openOrder = (order: any) => {
|
||||
navigateTo(localePath(`/clientarea/orders/${order.uuid}`))
|
||||
}
|
||||
|
||||
const getOrderStartDate = (order: any) => {
|
||||
if (!order.createdAt) return t('ordersDetail.labels.dates_undefined')
|
||||
return formatDate(order.createdAt)
|
||||
}
|
||||
|
||||
const getOrderEndDate = (order: any) => {
|
||||
let latestDate: Date | null = null
|
||||
order.stages?.forEach((stage: any) => {
|
||||
stage.trips?.forEach((trip: any) => {
|
||||
const endDate = trip.actualUnloadingDate || trip.plannedUnloadingDate
|
||||
if (endDate) {
|
||||
const date = new Date(endDate)
|
||||
if (!latestDate || date > latestDate) {
|
||||
latestDate = date
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
if (latestDate) return formatDate((latestDate as Date).toISOString())
|
||||
if (order.createdAt) {
|
||||
const fallbackDate = new Date(order.createdAt)
|
||||
fallbackDate.setMonth(fallbackDate.getMonth() + 1)
|
||||
return formatDate(fallbackDate.toISOString())
|
||||
}
|
||||
return t('ordersDetail.labels.dates_undefined')
|
||||
}
|
||||
|
||||
const getCompletedStages = (order: any) => {
|
||||
if (!order.stages?.length) return 0
|
||||
return order.stages.filter((stage: any) => stage.status === 'completed').length
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return t('ordersDetail.labels.dates_undefined')
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date
|
||||
if (isNaN(dateObj.getTime())) return t('ordersDetail.labels.dates_undefined')
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(dateObj)
|
||||
} catch {
|
||||
return t('ordersDetail.labels.dates_undefined')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
85
app/pages/clientarea/orders/map.vue
Normal file
85
app/pages/clientarea/orders/map.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<NuxtLayout name="map">
|
||||
<template #sidebar>
|
||||
<CatalogMapSidebar
|
||||
:title="t('dashboard.orders')"
|
||||
:back-link="localePath('/clientarea/orders')"
|
||||
:back-label="t('catalogMap.actions.list_view')"
|
||||
:items-count="filteredItems.length"
|
||||
:filters="filters"
|
||||
:selected-filter="selectedFilter"
|
||||
:loading="isLoading"
|
||||
:empty-text="t('orders.no_orders')"
|
||||
@update:selected-filter="selectedFilter = $event"
|
||||
>
|
||||
<template #cards>
|
||||
<Card
|
||||
v-for="order in filteredItems"
|
||||
:key="order.uuid"
|
||||
padding="small"
|
||||
interactive
|
||||
:class="{ 'ring-2 ring-primary': selectedOrderId === order.uuid }"
|
||||
@click="selectOrder(order)"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Text weight="semibold">#{{ order.name }}</Text>
|
||||
<Badge :variant="getStatusVariant(order.status)" size="sm">
|
||||
{{ getStatusText(order.status) }}
|
||||
</Badge>
|
||||
</Stack>
|
||||
<Text tone="muted" size="sm" class="truncate">
|
||||
{{ order.sourceLocationName }} → {{ order.destinationLocationName }}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</template>
|
||||
</CatalogMapSidebar>
|
||||
</template>
|
||||
|
||||
<ClientOnly>
|
||||
<OrdersRoutesMap
|
||||
ref="mapRef"
|
||||
:routes="routesForMap"
|
||||
:selected-order-id="selectedOrderId"
|
||||
@select-order="onMapSelectOrder"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
filteredItems,
|
||||
isLoading,
|
||||
filters,
|
||||
selectedFilter,
|
||||
routesForMap,
|
||||
init,
|
||||
getStatusVariant,
|
||||
getStatusText
|
||||
} = useTeamOrders()
|
||||
|
||||
await init()
|
||||
|
||||
const mapRef = ref<{ flyTo: (orderId: string) => void } | null>(null)
|
||||
const selectedOrderId = ref<string | null>(null)
|
||||
|
||||
const selectOrder = (order: any) => {
|
||||
selectedOrderId.value = order.uuid
|
||||
mapRef.value?.flyTo(order.uuid)
|
||||
}
|
||||
|
||||
const onMapSelectOrder = (uuid: string) => {
|
||||
selectedOrderId.value = uuid
|
||||
}
|
||||
</script>
|
||||
275
app/pages/clientarea/profile/debug-tokens.vue
Normal file
275
app/pages/clientarea/profile/debug-tokens.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<Section variant="plain">
|
||||
<Stack gap="6">
|
||||
<PageHeader
|
||||
title="Debug Tokens"
|
||||
:actions="[
|
||||
{ label: 'Back', icon: 'lucide:arrow-left', to: localePath('/clientarea/profile') }
|
||||
]"
|
||||
/>
|
||||
|
||||
<Alert v-if="error" variant="error">
|
||||
<Text>{{ error }}</Text>
|
||||
</Alert>
|
||||
|
||||
<Card padding="lg">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="3">JWT Tokens</Heading>
|
||||
<Button variant="ghost" size="sm" @click="loadTokens" :loading="isLoading">
|
||||
<Icon name="lucide:refresh-ccw" size="16" />
|
||||
<span>Refresh</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<!-- ID Token -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">ID Token</Heading>
|
||||
<Button v-if="rawTokens.id" variant="ghost" size="sm" @click="copyToken(rawTokens.id)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.id || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.id) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: teams -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: teams.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.teams" variant="ghost" size="sm" @click="copyToken(rawTokens.teams)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.teams || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.teams) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: exchange -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: exchange.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.exchange" variant="ghost" size="sm" @click="copyToken(rawTokens.exchange)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.exchange || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.exchange) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: orders -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: orders.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.orders" variant="ghost" size="sm" @click="copyToken(rawTokens.orders)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.orders || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.orders) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: kyc -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: kyc.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.kyc" variant="ghost" size="sm" @click="copyToken(rawTokens.kyc)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.kyc || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.kyc) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: billing -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: billing.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.billing" variant="ghost" size="sm" @click="copyToken(rawTokens.billing)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.billing || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.billing) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const RESOURCES = {
|
||||
teams: 'https://teams.optovia.ru',
|
||||
exchange: 'https://exchange.optovia.ru',
|
||||
orders: 'https://orders.optovia.ru',
|
||||
kyc: 'https://kyc.optovia.ru',
|
||||
billing: 'https://billing.optovia.ru'
|
||||
} as const
|
||||
|
||||
type ResourceKey = keyof typeof RESOURCES
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const auth = useAuth()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const rawTokens = ref<{ id: string | null } & Record<ResourceKey, string | null>>({
|
||||
id: null,
|
||||
teams: null,
|
||||
exchange: null,
|
||||
orders: null,
|
||||
kyc: null,
|
||||
billing: null
|
||||
})
|
||||
|
||||
const decodedTokens = ref<{ id: unknown } & Record<ResourceKey, unknown>>({
|
||||
id: null,
|
||||
teams: null,
|
||||
exchange: null,
|
||||
orders: null,
|
||||
kyc: null,
|
||||
billing: null
|
||||
})
|
||||
|
||||
function decodeJwt(token?: string | null) {
|
||||
if (!token) return null
|
||||
try {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) return null
|
||||
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
return JSON.parse(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson(data: unknown) {
|
||||
if (!data) return '—'
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
async function copyToken(token: string | null) {
|
||||
if (!token) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(token)
|
||||
} catch (e) {
|
||||
console.error('Failed to copy:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTokens = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
// Get ID token
|
||||
try {
|
||||
const idToken = await auth.getIdToken()
|
||||
rawTokens.value.id = idToken || null
|
||||
decodedTokens.value.id = decodeJwt(idToken)
|
||||
} catch (e: unknown) {
|
||||
rawTokens.value.id = null
|
||||
decodedTokens.value.id = { error: e instanceof Error ? e.message : 'Failed to get ID token' }
|
||||
}
|
||||
|
||||
// Get access tokens for ALL resources
|
||||
for (const [key, url] of Object.entries(RESOURCES) as [ResourceKey, string][]) {
|
||||
try {
|
||||
const accessToken = await auth.getAccessToken(url)
|
||||
rawTokens.value[key] = accessToken || null
|
||||
decodedTokens.value[key] = decodeJwt(accessToken)
|
||||
} catch (e: unknown) {
|
||||
rawTokens.value[key] = null
|
||||
decodedTokens.value[key] = { error: e instanceof Error ? e.message : 'Failed to get access token' }
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Error loading tokens'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTokens()
|
||||
})
|
||||
</script>
|
||||
157
app/pages/clientarea/profile/index.vue
Normal file
157
app/pages/clientarea/profile/index.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="6">
|
||||
<PageHeader
|
||||
:title="$t('dashboard.profile')"
|
||||
:actions="[{ label: t('clientProfile.actions.debugTokens'), icon: 'lucide:bug', to: localePath('/clientarea/profile/debug-tokens') }]"
|
||||
/>
|
||||
|
||||
<Alert v-if="hasError" variant="error">
|
||||
<Stack gap="1">
|
||||
<Heading :level="4" weight="semibold">{{ $t('common.error') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Stack v-if="isLoading" align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('clientProfile.states.loading') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<template v-else>
|
||||
<Card padding="lg">
|
||||
<Grid :cols="1" :lg="3" :gap="8">
|
||||
<GridItem :lg="2">
|
||||
<Stack gap="4">
|
||||
<form @submit.prevent="updateProfile">
|
||||
<Stack gap="4">
|
||||
<Input
|
||||
v-model="profileForm.firstName"
|
||||
type="text"
|
||||
:label="$t('profile.first_name')"
|
||||
:placeholder="$t('profile.first_name_placeholder')"
|
||||
/>
|
||||
<Input
|
||||
v-model="profileForm.lastName"
|
||||
type="text"
|
||||
:label="$t('profile.last_name')"
|
||||
:placeholder="$t('profile.last_name_placeholder')"
|
||||
/>
|
||||
<Input
|
||||
v-model="profileForm.phone"
|
||||
type="tel"
|
||||
:label="$t('profile.phone')"
|
||||
:placeholder="$t('profile.phone_placeholder')"
|
||||
/>
|
||||
<Button type="submit" :full-width="true" :disabled="isUpdating">
|
||||
<template v-if="isUpdating">{{ $t('profile.saving') }}...</template>
|
||||
<template v-else>{{ $t('profile.save') }}</template>
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
<GridItem>
|
||||
<Stack gap="6" align="center">
|
||||
<Stack gap="3" align="center">
|
||||
<Heading :level="3">{{ $t('profile.avatar') }}</Heading>
|
||||
<UserAvatar
|
||||
:userId="userData?.id"
|
||||
:firstName="userData?.firstName"
|
||||
:lastName="userData?.lastName"
|
||||
:avatarId="userData?.avatarId"
|
||||
@avatar-changed="handleAvatarChange"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Card>
|
||||
</template>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
const { mutate } = useGraphQL()
|
||||
|
||||
const userData = useState<{
|
||||
id?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
phone?: string | null
|
||||
avatarId?: string | null
|
||||
} | null>('me', () => null)
|
||||
const isLoading = ref(true)
|
||||
const hasError = ref(false)
|
||||
const error = ref('')
|
||||
const isUpdating = ref(false)
|
||||
const avatarDraftId = ref<string | null>(null)
|
||||
|
||||
const profileForm = reactive({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const syncProfileForm = () => {
|
||||
if (!userData.value) {
|
||||
hasError.value = true
|
||||
error.value = t('clientProfile.error.load')
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
hasError.value = false
|
||||
error.value = ''
|
||||
profileForm.firstName = userData.value.firstName || ''
|
||||
profileForm.lastName = userData.value.lastName || ''
|
||||
profileForm.phone = userData.value.phone || ''
|
||||
avatarDraftId.value = userData.value.avatarId || null
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const updateProfile = async () => {
|
||||
try {
|
||||
isUpdating.value = true
|
||||
|
||||
const { UpdateUserDocument } = await import('~/composables/graphql/user/teams-generated')
|
||||
const result = await mutate(UpdateUserDocument, {
|
||||
userId: userData.value.id,
|
||||
input: {
|
||||
firstName: profileForm.firstName,
|
||||
lastName: profileForm.lastName,
|
||||
phone: profileForm.phone,
|
||||
avatarId: avatarDraftId.value || null
|
||||
},
|
||||
}, 'user', 'teams')
|
||||
|
||||
if (result?.updateUser?.user) {
|
||||
userData.value = { ...(userData.value || {}), ...result.updateUser.user }
|
||||
avatarDraftId.value = userData.value.avatarId || avatarDraftId.value
|
||||
}
|
||||
} catch (err) {
|
||||
hasError.value = true
|
||||
error.value = err?.message || t('clientProfile.error.save')
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarChange = async (newAvatarId?: string) => {
|
||||
if (!newAvatarId) return
|
||||
// Only stage avatar change; will be saved on form submit
|
||||
avatarDraftId.value = newAvatarId
|
||||
}
|
||||
|
||||
watch(userData, () => {
|
||||
syncProfileForm()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
6
app/pages/clientarea/request/[id].vue
Normal file
6
app/pages/clientarea/request/[id].vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<CalcResultContent />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
196
app/pages/clientarea/team/index.vue
Normal file
196
app/pages/clientarea/team/index.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<Section variant="plain">
|
||||
<Stack gap="6">
|
||||
<PageHeader :title="t('clientTeam.header.title')" :actions="teamHeaderActions" />
|
||||
|
||||
<Alert v-if="hasError" variant="error">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ t('clientTeam.error.title') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
<Button @click="loadUserTeams">{{ t('clientTeam.error.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Card v-else-if="isLoading" tone="muted" padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('clientTeam.loading.message') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<!-- No team - prompt to create KYC application -->
|
||||
<EmptyState
|
||||
v-else-if="!currentTeam"
|
||||
icon="👥"
|
||||
:title="t('clientTeam.empty.title')"
|
||||
:description="t('clientTeam.empty.description')"
|
||||
:action-label="t('clientTeam.empty.cta')"
|
||||
:action-to="localePath('/clientarea/kyc')"
|
||||
action-icon="lucide:plus"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<Card padding="lg">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" gap="4" align="start" justify="between">
|
||||
<Stack direction="row" gap="3" align="center">
|
||||
<IconCircle tone="neutral" size="lg">
|
||||
{{ currentTeam.name?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</IconCircle>
|
||||
<Heading :level="2" weight="semibold">{{ currentTeam.name }}</Heading>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Stack gap="3">
|
||||
<Heading :level="2">{{ t('clientTeam.members.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="member in currentTeam?.members || []"
|
||||
:key="member.user?.id"
|
||||
padding="lg"
|
||||
>
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" gap="3" align="center">
|
||||
<IconCircle tone="neutral">{{ getMemberInitials(member.user) }}</IconCircle>
|
||||
<Stack gap="1">
|
||||
<Text weight="semibold">{{ member.user?.firstName }} {{ member.user?.lastName || '—' }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<Pill variant="primary">{{ roleText(member.role) }}</Pill>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<!-- Pending invitations -->
|
||||
<Card
|
||||
v-for="invitation in currentTeam?.invitations || []"
|
||||
:key="invitation.uuid"
|
||||
padding="lg"
|
||||
class="border-dashed border-warning"
|
||||
>
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" gap="3" align="center">
|
||||
<IconCircle tone="warning">
|
||||
<Icon name="lucide:mail" size="16" />
|
||||
</IconCircle>
|
||||
<Stack gap="1">
|
||||
<Text weight="semibold">{{ invitation.email }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('clientTeam.invitations.pending') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<Pill variant="outline" tone="warning">{{ roleText(invitation.role) }}</Pill>
|
||||
<Pill variant="ghost" tone="muted">{{ t('clientTeam.invitations.sent') }}</Pill>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
class="border-2 border-dashed border-base-300 hover:border-primary cursor-pointer transition-colors"
|
||||
@click="inviteMember"
|
||||
>
|
||||
<Stack gap="3" align="center" justify="center" class="h-full min-h-[100px]">
|
||||
<div class="w-10 h-10 rounded-full bg-base-200 flex items-center justify-center text-base-content/50">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<Text weight="semibold" tone="muted">{{ t('clientTeam.inviteCard.title') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</template>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetTeamDocument } from '~/composables/graphql/user/teams-generated'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const me = useState<{
|
||||
teams?: Array<{ id?: string | null; name: string; logtoOrgId?: string | null } | null> | null
|
||||
activeTeamId?: string | null
|
||||
activeTeam?: { logtoOrgId?: string | null } | null
|
||||
} | null>('me', () => null)
|
||||
const { setActiveTeam } = useActiveTeam()
|
||||
|
||||
const userTeams = ref<any[]>([])
|
||||
const currentTeam = ref<any>(null)
|
||||
const isLoading = ref(true)
|
||||
const hasError = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const teamHeaderActions = computed(() => {
|
||||
const actions: Array<{ label: string; icon?: string; to?: string }> = []
|
||||
actions.push({ label: t('clientTeam.actions.addCompany'), icon: 'lucide:plus', to: localePath('/clientarea/kyc') })
|
||||
if (userTeams.value.length > 1) {
|
||||
actions.push({ label: t('clientTeam.actions.switch'), icon: 'lucide:arrow-left-right', to: localePath('/clientarea/company-switch') })
|
||||
}
|
||||
return actions
|
||||
})
|
||||
const roleText = (role?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
OWNER: t('clientTeam.roles.owner'),
|
||||
ADMIN: t('clientTeam.roles.admin'),
|
||||
MANAGER: t('clientTeam.roles.manager'),
|
||||
MEMBER: t('clientTeam.roles.member'),
|
||||
}
|
||||
return map[role || ''] || role || t('clientTeam.roles.member')
|
||||
}
|
||||
|
||||
const getMemberInitials = (user?: any) => {
|
||||
if (!user) return '??'
|
||||
const first = user.firstName?.charAt(0) || ''
|
||||
const last = user.lastName?.charAt(0) || ''
|
||||
return (first + last).toUpperCase() || user.id?.charAt(0).toUpperCase() || '??'
|
||||
}
|
||||
|
||||
const loadUserTeams = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
hasError.value = false
|
||||
|
||||
if (!me.value) {
|
||||
throw new Error(t('clientTeam.error.load'))
|
||||
}
|
||||
|
||||
userTeams.value = me.value.teams?.filter((t): t is NonNullable<typeof t> => t !== null) || []
|
||||
|
||||
if (me.value.activeTeamId && me.value.activeTeam) {
|
||||
setActiveTeam(me.value.activeTeamId, me.value.activeTeam.logtoOrgId)
|
||||
const { data: teamData } = await useServerQuery('team-page-team', GetTeamDocument, { teamId: me.value.activeTeamId }, 'user', 'teams')
|
||||
currentTeam.value = teamData.value?.getTeam || null
|
||||
} else if (userTeams.value.length > 0) {
|
||||
const firstTeam = userTeams.value[0]
|
||||
setActiveTeam(firstTeam?.id || null, firstTeam?.logtoOrgId)
|
||||
currentTeam.value = firstTeam
|
||||
}
|
||||
// Если нет команды - currentTeam остаётся null, показываем EmptyState
|
||||
} catch (err: any) {
|
||||
hasError.value = true
|
||||
error.value = err.message || t('clientTeam.error.load')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const inviteMember = () => {
|
||||
router.push(localePath('/clientarea/team/invite'))
|
||||
}
|
||||
|
||||
await loadUserTeams()
|
||||
</script>
|
||||
103
app/pages/clientarea/team/invite.vue
Normal file
103
app/pages/clientarea/team/invite.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<Section variant="plain">
|
||||
<Stack gap="6">
|
||||
<PageHeader :title="t('clientTeam.invite.title')" />
|
||||
|
||||
<Card padding="lg">
|
||||
<form @submit.prevent="submitInvite">
|
||||
<Stack gap="4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ t('clientTeam.invite.email') }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="inviteEmail"
|
||||
type="email"
|
||||
:placeholder="t('clientTeam.invite.emailPlaceholder')"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ t('clientTeam.invite.role') }}</span>
|
||||
</label>
|
||||
<select v-model="inviteRole" class="select select-bordered w-full">
|
||||
<option value="MEMBER">{{ t('clientTeam.roles.member') }}</option>
|
||||
<option value="MANAGER">{{ t('clientTeam.roles.manager') }}</option>
|
||||
<option value="ADMIN">{{ t('clientTeam.roles.admin') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Alert v-if="inviteError" variant="error">
|
||||
{{ inviteError }}
|
||||
</Alert>
|
||||
|
||||
<Alert v-if="inviteSuccess" variant="success">
|
||||
{{ t('clientTeam.invite.success') }}
|
||||
</Alert>
|
||||
|
||||
<Stack direction="row" gap="3">
|
||||
<Button variant="ghost" :to="localePath('/clientarea/team')">
|
||||
{{ t('clientTeam.invite.cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" :loading="inviteLoading">
|
||||
{{ t('clientTeam.invite.submit') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { InviteMemberDocument } from '~/composables/graphql/team/teams-generated'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { mutate } = useGraphQL()
|
||||
const localePath = useLocalePath()
|
||||
const router = useRouter()
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const inviteEmail = ref('')
|
||||
const inviteRole = ref('MEMBER')
|
||||
const inviteLoading = ref(false)
|
||||
const inviteError = ref('')
|
||||
const inviteSuccess = ref(false)
|
||||
|
||||
const submitInvite = async () => {
|
||||
if (!inviteEmail.value) return
|
||||
|
||||
inviteLoading.value = true
|
||||
inviteError.value = ''
|
||||
inviteSuccess.value = false
|
||||
|
||||
try {
|
||||
const result = await mutate(InviteMemberDocument, {
|
||||
input: {
|
||||
email: inviteEmail.value,
|
||||
role: inviteRole.value
|
||||
}
|
||||
}, 'team', 'teams')
|
||||
|
||||
if (result?.inviteMember?.success) {
|
||||
inviteSuccess.value = true
|
||||
setTimeout(() => {
|
||||
router.push(localePath('/clientarea/team'))
|
||||
}, 1500)
|
||||
} else {
|
||||
inviteError.value = result?.inviteMember?.message || t('clientTeam.invite.error')
|
||||
}
|
||||
} catch (err: any) {
|
||||
inviteError.value = err.message || t('clientTeam.invite.error')
|
||||
} finally {
|
||||
inviteLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
13
app/pages/clientarea/team/new.vue
Normal file
13
app/pages/clientarea/team/new.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Ручное создание команды убрано — команды создаются автоматически после KYC
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
await navigateTo(localePath('/clientarea/kyc'))
|
||||
</script>
|
||||
5
app/pages/goods.vue
Normal file
5
app/pages/goods.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<GoodsContent />
|
||||
</Section>
|
||||
</template>
|
||||
195
app/pages/index.vue
Normal file
195
app/pages/index.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<Stack gap="12">
|
||||
<Section variant="hero">
|
||||
<Stack gap="6">
|
||||
<Heading :level="1" tone="inverse">{{ $t('search.title') }}</Heading>
|
||||
<Text tone="inverse">{{ $t('search.description') }}</Text>
|
||||
|
||||
<Card padding="lg">
|
||||
<Stack gap="6">
|
||||
<!-- Search form -->
|
||||
<Grid :cols="1" :md="4" :gap="4">
|
||||
<Stack gap="2">
|
||||
<Text tag="p" size="base" weight="semibold">{{ $t('search.product_label') }}</Text>
|
||||
<FieldButton
|
||||
:value="searchStore.searchForm.product"
|
||||
:placeholder="$t('search.product_placeholder')"
|
||||
@click="navigateTo(localePath('/goods'))"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="2">
|
||||
<Text tag="p" size="base" weight="semibold">{{ $t('search.quantity_label') }}</Text>
|
||||
<Input
|
||||
type="number"
|
||||
v-model="searchStore.searchForm.quantity"
|
||||
:placeholder="$t('search.quantity_placeholder')"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="2">
|
||||
<Text tag="p" size="base" weight="semibold">{{ $t('search.location_label') }}</Text>
|
||||
<FieldButton
|
||||
:value="searchStore.searchForm.location"
|
||||
:placeholder="$t('search.location_placeholder')"
|
||||
@click="navigateTo(localePath({ path: '/select-location', query: { mode: 'search' } }))"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="2" align="stretch">
|
||||
<Text tag="p" size="base" weight="semibold" tone="muted"> </Text>
|
||||
<Button type="button" @click="handleSearch">
|
||||
{{ searchError ? $t('search.error') : $t('search.search_button') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Stack v-if="popularExamples.length" gap="2" align="center">
|
||||
<Text tone="inverse" size="sm">{{ $t('search.popular_requests') }}</Text>
|
||||
<Stack direction="row" gap="2" justify="center" align="center" class="flex-wrap text-center">
|
||||
<button
|
||||
v-for="example in popularExamples"
|
||||
:key="`${example.product}-${example.location}`"
|
||||
type="button"
|
||||
class="badge badge-dash badge-primary text-sm"
|
||||
@click="fillExample(example)"
|
||||
>
|
||||
{{ example.product }} {{ example.quantity }}{{ $t('search.units.tons_short') }} → {{ example.location }}
|
||||
</button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<Section variant="plain">
|
||||
<Stack gap="8" align="center">
|
||||
<Heading :level="2">{{ $t('roles.title') }}</Heading>
|
||||
<Text align="center" tone="muted">{{ $t('about.description') }}</Text>
|
||||
|
||||
<Grid :cols="1" :lg="3" :gap="6">
|
||||
<Card padding="lg">
|
||||
<Stack gap="4" align="center">
|
||||
<IconCircle tone="primary">🏭</IconCircle>
|
||||
<Heading :level="3">{{ $t('roles.producers.title') }}</Heading>
|
||||
<Text tone="muted" align="center">{{ $t('roles.producers.description') }}</Text>
|
||||
<Stack tag="ul" gap="1">
|
||||
<li>✓ {{ $t('roles.producers.benefit1') }}</li>
|
||||
<li>✓ {{ $t('roles.producers.benefit2') }}</li>
|
||||
<li>✓ {{ $t('roles.producers.benefit3') }}</li>
|
||||
<li>✓ {{ $t('roles.producers.benefit4') }}</li>
|
||||
</Stack>
|
||||
<Button :full-width="true" variant="outline">{{ $t('roles.producers.cta') }}</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Stack gap="4" align="center">
|
||||
<IconCircle tone="primary">🏢</IconCircle>
|
||||
<Heading :level="3">{{ $t('roles.buyers.title') }}</Heading>
|
||||
<Text tone="muted" align="center">{{ $t('roles.buyers.description') }}</Text>
|
||||
<Stack tag="ul" gap="1">
|
||||
<li>✓ {{ $t('roles.buyers.benefit1') }}</li>
|
||||
<li>✓ {{ $t('roles.buyers.benefit2') }}</li>
|
||||
<li>✓ {{ $t('roles.buyers.benefit3') }}</li>
|
||||
<li>✓ {{ $t('roles.buyers.benefit4') }}</li>
|
||||
</Stack>
|
||||
<Button :full-width="true" variant="outline">{{ $t('roles.buyers.cta') }}</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Stack gap="4" align="center">
|
||||
<IconCircle tone="primary">⚙️</IconCircle>
|
||||
<Heading :level="3">{{ $t('roles.services.title') }}</Heading>
|
||||
<Text tone="muted" align="center">{{ $t('roles.services.description') }}</Text>
|
||||
<Stack tag="ul" gap="1">
|
||||
<li>✓ {{ $t('roles.services.benefit1') }}</li>
|
||||
<li>✓ {{ $t('roles.services.benefit2') }}</li>
|
||||
<li>✓ {{ $t('roles.services.benefit3') }}</li>
|
||||
<li>✓ {{ $t('roles.services.benefit4') }}</li>
|
||||
</Stack>
|
||||
<Button :full-width="true" variant="outline">{{ $t('roles.services.cta') }}</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<Section variant="plain">
|
||||
<Stack gap="6" align="center">
|
||||
<Heading :level="2">{{ $t('howto.title') }}</Heading>
|
||||
<Grid :cols="1" :md="3" :gap="6">
|
||||
<Card padding="lg">
|
||||
<Stack gap="3" align="center">
|
||||
<IconCircle tone="primary">🔍</IconCircle>
|
||||
<Heading :level="3" weight="semibold">{{ $t('howto.step1.title') }}</Heading>
|
||||
<Text tone="muted" align="center">{{ $t('howto.step1.description') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card padding="lg">
|
||||
<Stack gap="3" align="center">
|
||||
<IconCircle tone="primary">🤝</IconCircle>
|
||||
<Heading :level="3" weight="semibold">{{ $t('howto.step2.title') }}</Heading>
|
||||
<Text tone="muted" align="center">{{ $t('howto.step2.description') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card padding="lg">
|
||||
<Stack gap="3" align="center">
|
||||
<IconCircle tone="primary">⚡</IconCircle>
|
||||
<Heading :level="3" weight="semibold">{{ $t('howto.step3.title') }}</Heading>
|
||||
<Text tone="muted" align="center">{{ $t('howto.step3.description') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { t } = useI18n()
|
||||
const searchStore = useSearchStore()
|
||||
const searchError = ref('')
|
||||
const localePath = useLocalePath()
|
||||
const popularExamples = computed(() => ([
|
||||
{ product: t('search.examples.metal_sheet.product'), quantity: 120, location: t('search.examples.metal_sheet.location') },
|
||||
{ product: t('search.examples.green_coffee.product'), quantity: 200, location: t('search.examples.green_coffee.location') },
|
||||
{ product: t('search.examples.wheat.product'), quantity: 500, location: t('search.examples.wheat.location') },
|
||||
{ product: t('search.examples.cocoa.product'), quantity: 150, location: t('search.examples.cocoa.location') },
|
||||
]))
|
||||
|
||||
const handleSearch = () => {
|
||||
const location = (searchStore.searchForm.location || '').trim()
|
||||
const productText = (searchStore.searchForm.product || '').trim()
|
||||
const hasProduct = !!(searchStore.searchForm.productUuid || productText)
|
||||
|
||||
if (!location) {
|
||||
searchError.value = t('search.validation.fill_product_location')
|
||||
setTimeout(() => (searchError.value = ''), 2000)
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasProduct) {
|
||||
navigateTo(localePath('/goods'))
|
||||
return
|
||||
}
|
||||
|
||||
const query = {
|
||||
productUuid: searchStore.searchForm.productUuid || undefined,
|
||||
product: productText || undefined,
|
||||
quantity: searchStore.searchForm.quantity || undefined,
|
||||
locationUuid: searchStore.searchForm.locationUuid || undefined,
|
||||
location: searchStore.searchForm.location || undefined
|
||||
}
|
||||
|
||||
navigateTo(localePath({ path: '/request', query }))
|
||||
}
|
||||
|
||||
const fillExample = (example) => {
|
||||
searchStore.searchForm.product = example.product
|
||||
searchStore.searchForm.quantity = example.quantity
|
||||
searchStore.searchForm.location = example.location
|
||||
}
|
||||
</script>
|
||||
5
app/pages/locations.vue
Normal file
5
app/pages/locations.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<LocationsContent />
|
||||
</Section>
|
||||
</template>
|
||||
5
app/pages/request/[id].vue
Normal file
5
app/pages/request/[id].vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<CalcResultContent />
|
||||
</Section>
|
||||
</template>
|
||||
5
app/pages/request/index.vue
Normal file
5
app/pages/request/index.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<CalcResultContent />
|
||||
</Section>
|
||||
</template>
|
||||
36
app/pages/search/index.vue
Normal file
36
app/pages/search/index.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Stack gap="8">
|
||||
<Section variant="hero">
|
||||
<Stack gap="4">
|
||||
<Stack gap="2">
|
||||
<Text size="base" tone="inverse" weight="semibold" transform="uppercase">Search</Text>
|
||||
<Heading :level="1" tone="inverse">{{ t('searchPage.hero.title') }}</Heading>
|
||||
<Text tone="inverse" size="base">
|
||||
{{ t('searchPage.hero.subtitle') }}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap="3">
|
||||
<Button :as="'NuxtLink'" :to="localePath('/catalog')" variant="ghost">
|
||||
{{ t('searchPage.cta.catalog') }}
|
||||
</Button>
|
||||
<Button :as="'NuxtLink'" :to="localePath('/clientarea/orders')" variant="outline">
|
||||
{{ t('searchPage.cta.orders') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Card>
|
||||
<GoodsContent />
|
||||
</Card>
|
||||
</Section>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
198
app/pages/select-location/index.vue
Normal file
198
app/pages/select-location/index.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<PageHeader :title="t('common.selectLocation')">
|
||||
<template #actions>
|
||||
<button class="btn btn-ghost" @click="router.back()">
|
||||
<Icon name="lucide:x" size="20" />
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<Stack gap="8" class="mt-6">
|
||||
<!-- My addresses -->
|
||||
<Stack v-if="isAuthenticated && teamAddresses?.length" gap="4">
|
||||
<Heading :level="3">{{ t('profileAddresses.header.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :gap="4">
|
||||
<Card
|
||||
v-for="addr in teamAddresses"
|
||||
:key="addr.uuid"
|
||||
padding="small"
|
||||
interactive
|
||||
:class="{ 'ring-2 ring-primary': isSelected('address', addr.uuid) }"
|
||||
@click="selectAddress(addr)"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" align="center" gap="2">
|
||||
<Icon name="lucide:map-pin" size="18" class="text-primary" />
|
||||
<Text size="base" weight="semibold">{{ addr.name }}</Text>
|
||||
<Pill v-if="addr.isDefault" variant="outline" size="sm">Default</Pill>
|
||||
</Stack>
|
||||
<Text tone="muted" size="sm">{{ addr.address }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<!-- Hubs -->
|
||||
<Stack gap="4">
|
||||
<Heading :level="3">{{ t('catalogMap.hubsTab') }}</Heading>
|
||||
|
||||
<NuxtLink :to="localePath('/select-location/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
map-id="select-location-map"
|
||||
:locations="itemsWithCoords"
|
||||
:height="192"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</NuxtLink>
|
||||
|
||||
<CatalogFilters :filters="filters" v-model="selectedFilter" />
|
||||
|
||||
<div v-if="isLoading" class="flex items-center justify-center p-8">
|
||||
<span class="loading loading-spinner loading-lg" />
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="!items?.length"
|
||||
:title="t('catalogMap.noHubs')"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<Grid :cols="1" :md="2" :gap="4">
|
||||
<HubCard
|
||||
v-for="hub in items"
|
||||
:key="hub.uuid"
|
||||
:hub="hub"
|
||||
selectable
|
||||
:is-selected="isSelected('hub', hub.uuid)"
|
||||
@select="selectHub(hub)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<PaginationLoadMore
|
||||
:shown="items.length"
|
||||
:total="total"
|
||||
:can-load-more="canLoadMore"
|
||||
:loading="isLoadingMore"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
</template>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocationStore } from '~/stores/location'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const locationStore = useLocationStore()
|
||||
const searchStore = useSearchStore()
|
||||
const isSearchMode = computed(() => route.query.mode === 'search')
|
||||
|
||||
// Use shared composable for hubs
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
itemsWithCoords,
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
init
|
||||
} = useCatalogHubs()
|
||||
|
||||
await init()
|
||||
|
||||
// Load team addresses
|
||||
const teamAddresses = ref<any[]>([])
|
||||
|
||||
if (isAuthenticated.value) {
|
||||
try {
|
||||
const { execute } = useGraphQL()
|
||||
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
||||
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
|
||||
teamAddresses.value = data?.teamAddresses || []
|
||||
} catch {
|
||||
// Not critical
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (type: 'address' | 'hub', uuid: string) => {
|
||||
return locationStore.selectedLocation?.type === type && locationStore.selectedLocation?.uuid === uuid
|
||||
}
|
||||
|
||||
const goToRequestIfReady = () => {
|
||||
if (route.query.after === 'request' && searchStore.searchForm.productUuid && searchStore.searchForm.locationUuid) {
|
||||
router.push({
|
||||
path: '/request',
|
||||
query: {
|
||||
productUuid: searchStore.searchForm.productUuid,
|
||||
product: searchStore.searchForm.product,
|
||||
locationUuid: searchStore.searchForm.locationUuid,
|
||||
location: searchStore.searchForm.location,
|
||||
quantity: searchStore.searchForm.quantity || undefined
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const selectHub = async (hub: any) => {
|
||||
console.log('[selectHub] called', { hub, isSearchMode: isSearchMode.value })
|
||||
|
||||
if (isSearchMode.value) {
|
||||
searchStore.setLocation(hub.name)
|
||||
searchStore.setLocationUuid(hub.uuid)
|
||||
if (goToRequestIfReady()) return
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[selectHub] calling locationStore.select')
|
||||
const success = await locationStore.select('hub', hub.uuid, hub.name, hub.latitude, hub.longitude)
|
||||
console.log('[selectHub] result:', success)
|
||||
if (success) {
|
||||
router.back()
|
||||
} else {
|
||||
console.error('[selectHub] Selection failed - success=false')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[selectHub] Error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const selectAddress = async (addr: any) => {
|
||||
console.log('[selectAddress] called', { addr, isSearchMode: isSearchMode.value })
|
||||
|
||||
if (isSearchMode.value) {
|
||||
searchStore.setLocation(addr.address || addr.name)
|
||||
searchStore.setLocationUuid(addr.uuid)
|
||||
if (goToRequestIfReady()) return
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[selectAddress] calling locationStore.select')
|
||||
const success = await locationStore.select('address', addr.uuid, addr.name, addr.latitude, addr.longitude)
|
||||
console.log('[selectAddress] result:', success)
|
||||
if (success) {
|
||||
router.back()
|
||||
} else {
|
||||
console.error('[selectAddress] Selection failed - success=false')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[selectAddress] Error:', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
103
app/pages/select-location/map.vue
Normal file
103
app/pages/select-location/map.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<NuxtLayout name="map">
|
||||
<template #sidebar>
|
||||
<CatalogMapSidebar
|
||||
:title="t('catalogMap.hubsTab')"
|
||||
:back-link="localePath('/select-location')"
|
||||
:back-label="t('catalogMap.actions.list_view')"
|
||||
:items-count="items.length"
|
||||
:filters="filters"
|
||||
:selected-filter="selectedFilter"
|
||||
:loading="isLoading"
|
||||
:empty-text="t('catalogMap.noHubs')"
|
||||
@update:selected-filter="selectedFilter = $event"
|
||||
>
|
||||
<template #cards>
|
||||
<HubCard
|
||||
v-for="hub in items"
|
||||
:key="hub.uuid"
|
||||
:hub="hub"
|
||||
selectable
|
||||
:is-selected="selectedItemId === hub.uuid"
|
||||
@select="selectItem(hub)"
|
||||
/>
|
||||
</template>
|
||||
</CatalogMapSidebar>
|
||||
</template>
|
||||
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="select-location-fullscreen-map"
|
||||
:items="itemsWithCoords"
|
||||
point-color="#10b981"
|
||||
@select-item="onMapSelectItem"
|
||||
/>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocationStore } from '~/stores/location'
|
||||
|
||||
definePageMeta({
|
||||
layout: false
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const locationStore = useLocationStore()
|
||||
const searchStore = useSearchStore()
|
||||
const isSearchMode = computed(() => route.query.mode === 'search')
|
||||
|
||||
const {
|
||||
items,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
itemsWithCoords,
|
||||
init
|
||||
} = useCatalogHubs()
|
||||
|
||||
await init()
|
||||
|
||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||
const selectedItemId = ref<string | null>(null)
|
||||
|
||||
const selectItem = async (item: any) => {
|
||||
selectedItemId.value = item.uuid
|
||||
|
||||
if (item.latitude && item.longitude) {
|
||||
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
||||
}
|
||||
|
||||
// Selection logic
|
||||
if (isSearchMode.value) {
|
||||
searchStore.setLocation(item.name)
|
||||
searchStore.setLocationUuid(item.uuid)
|
||||
if (route.query.after === 'request' && searchStore.searchForm.productUuid && searchStore.searchForm.locationUuid) {
|
||||
router.push({
|
||||
path: '/request',
|
||||
query: {
|
||||
productUuid: searchStore.searchForm.productUuid,
|
||||
product: searchStore.searchForm.product,
|
||||
locationUuid: searchStore.searchForm.locationUuid,
|
||||
location: searchStore.searchForm.location,
|
||||
quantity: searchStore.searchForm.quantity || undefined
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push(localePath('/select-location'))
|
||||
return
|
||||
}
|
||||
|
||||
const success = await locationStore.select('hub', item.uuid, item.name, item.latitude, item.longitude)
|
||||
if (success) router.push(localePath('/select-location'))
|
||||
}
|
||||
|
||||
const onMapSelectItem = (uuid: string) => {
|
||||
const item = items.value.find(h => h.uuid === uuid)
|
||||
if (item) selectItem(item)
|
||||
}
|
||||
</script>
|
||||
290
app/pages/supplier/[id].vue
Normal file
290
app/pages/supplier/[id].vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<Section variant="plain">
|
||||
<Stack gap="6">
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<Button :as="'NuxtLink'" :to="localePath('/')" variant="ghost">
|
||||
{{ t('supplierFlow.breadcrumb.home') }}
|
||||
</Button>
|
||||
<Text tone="muted">→</Text>
|
||||
<Button :as="'NuxtLink'" :to="localePath('/search')" variant="ghost">
|
||||
{{ t('supplierFlow.breadcrumb.search_results') }}
|
||||
</Button>
|
||||
<Text tone="muted">→</Text>
|
||||
<Text weight="semibold">{{ t('supplierFlow.breadcrumb.select_services') }}</Text>
|
||||
</Stack>
|
||||
<Heading :level="1">{{ supplier?.name }}</Heading>
|
||||
<Text tone="muted">📍 {{ supplier?.location }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Card padding="lg">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack gap="1">
|
||||
<Heading :level="3" weight="semibold">{{ supplier?.name }}</Heading>
|
||||
<Text tone="muted">{{ supplier?.selectedLogistics?.route }}</Text>
|
||||
</Stack>
|
||||
<Stack gap="1" align="end">
|
||||
<Text weight="semibold">{{ supplier?.totalPrice }}₽</Text>
|
||||
<Text tone="muted">{{ $t('supplier.delivery_time') }}: {{ supplier?.selectedLogistics?.time }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Stack gap="4">
|
||||
<Stack gap="2">
|
||||
<Heading :level="2">{{ t('supplierFlow.sections.logistics.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('supplierFlow.sections.logistics.subtitle') }}</Text>
|
||||
</Stack>
|
||||
<Grid :cols="1" :md="2" :gap="4">
|
||||
<Card
|
||||
v-for="logistics in logisticsCompanies"
|
||||
:key="logistics.id"
|
||||
padding="lg"
|
||||
interactive
|
||||
:active="selectedServices.logistics === logistics.id"
|
||||
@click="selectedServices.logistics = logistics.id"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading :level="4" weight="semibold">{{ logistics.name }}</Heading>
|
||||
<Pill variant="primary">{{ logistics.price.toLocaleString() }}₽</Pill>
|
||||
</Stack>
|
||||
<Text tone="muted">{{ logistics.description }}</Text>
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Text tone="muted" size="base">{{ t('supplierFlow.labels.rating_reviews', { rating: logistics.rating, reviews: logistics.reviews }) }}</Text>
|
||||
<Text tone="muted" size="base">{{ logistics.experience }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="4">
|
||||
<Stack gap="2">
|
||||
<Heading :level="2">{{ t('supplierFlow.sections.financing.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('supplierFlow.sections.financing.subtitle') }}</Text>
|
||||
</Stack>
|
||||
<Grid :cols="1" :md="2" :gap="4">
|
||||
<Card
|
||||
v-for="bank in banks"
|
||||
:key="bank.id"
|
||||
padding="lg"
|
||||
interactive
|
||||
:active="selectedServices.financing === bank.id"
|
||||
@click="selectedServices.financing = bank.id"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading :level="4" weight="semibold">{{ bank.name }}</Heading>
|
||||
<Pill variant="primary">{{ bank.rate }}%</Pill>
|
||||
</Stack>
|
||||
<Text tone="muted">{{ bank.terms }}</Text>
|
||||
<Text tone="muted" size="base">{{ bank.period }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="4">
|
||||
<Stack gap="2">
|
||||
<Heading :level="2">{{ t('supplierFlow.sections.insurance.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('supplierFlow.sections.insurance.subtitle') }}</Text>
|
||||
</Stack>
|
||||
<Grid :cols="1" :md="2" :gap="4">
|
||||
<Card
|
||||
v-for="insurance in insuranceCompanies"
|
||||
:key="insurance.id"
|
||||
padding="lg"
|
||||
interactive
|
||||
:active="selectedServices.insurance === insurance.id"
|
||||
@click="selectedServices.insurance = insurance.id"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading :level="4" weight="semibold">{{ insurance.name }}</Heading>
|
||||
<Pill variant="primary">{{ insurance.cost }}₽</Pill>
|
||||
</Stack>
|
||||
<Text tone="muted">{{ insurance.coverage }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="4">
|
||||
<Stack gap="2">
|
||||
<Heading :level="2">{{ t('supplierFlow.sections.quality.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('supplierFlow.sections.quality.subtitle') }}</Text>
|
||||
</Stack>
|
||||
<Grid :cols="1" :md="2" :gap="4">
|
||||
<Card
|
||||
v-for="lab in laboratories"
|
||||
:key="lab.id"
|
||||
padding="lg"
|
||||
interactive
|
||||
:active="selectedServices.laboratory === lab.id"
|
||||
@click="selectedServices.laboratory = lab.id"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading :level="4" weight="semibold">{{ lab.name }}</Heading>
|
||||
<Pill variant="primary">{{ lab.price }}₽</Pill>
|
||||
</Stack>
|
||||
<Text tone="muted">{{ lab.tests }}</Text>
|
||||
<Text tone="muted" size="base">🏆 {{ lab.certification }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Card padding="lg">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack gap="1">
|
||||
<Heading :level="3" weight="semibold">{{ $t('supplier.total_summary') }}</Heading>
|
||||
<Text tone="muted">{{ $t('supplier.all_services_selected') }}</Text>
|
||||
</Stack>
|
||||
<Stack gap="1" align="end">
|
||||
<Heading :level="2">{{ totalCost }}₽</Heading>
|
||||
<Text tone="muted">{{ $t('supplier.final_cost') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="row" gap="3" wrap>
|
||||
<Button variant="outline" :as="'NuxtLink'" :to="localePath('/search')">
|
||||
{{ $t('supplier.back_to_suppliers') }}
|
||||
</Button>
|
||||
<Button fullWidth>
|
||||
{{ $t('supplier.place_order') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const selectedServices = ref({
|
||||
logistics: null,
|
||||
financing: null,
|
||||
insurance: null,
|
||||
laboratory: null
|
||||
})
|
||||
|
||||
const logisticsCompanies = [
|
||||
{
|
||||
id: 1,
|
||||
name: "RosLogistic",
|
||||
price: 15000,
|
||||
description: "National transportation network",
|
||||
rating: 4.8,
|
||||
reviews: 156,
|
||||
experience: "15 years of experience",
|
||||
verified: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "TransService",
|
||||
price: 12000,
|
||||
description: "Regional carrier",
|
||||
rating: 4.5,
|
||||
reviews: 89,
|
||||
experience: "8 years of experience",
|
||||
verified: false
|
||||
}
|
||||
]
|
||||
|
||||
const banks = [
|
||||
{
|
||||
id: 1,
|
||||
name: "VTB",
|
||||
rate: 12.5,
|
||||
terms: "Loan for raw materials",
|
||||
period: "up to 24 months",
|
||||
rating: 4.7,
|
||||
reviews: 89,
|
||||
verified: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Sberbank",
|
||||
rate: 13.2,
|
||||
terms: "Trade financing",
|
||||
period: "up to 18 months",
|
||||
rating: 4.9,
|
||||
reviews: 145,
|
||||
verified: true
|
||||
}
|
||||
]
|
||||
|
||||
const insuranceCompanies = [
|
||||
{
|
||||
id: 1,
|
||||
name: "RESO",
|
||||
cost: 3500,
|
||||
coverage: "Cargo insurance up to 500M ₽",
|
||||
rating: 4.6,
|
||||
reviews: 67,
|
||||
verified: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Ingosstrakh",
|
||||
cost: 4200,
|
||||
coverage: "Full transaction insurance",
|
||||
rating: 4.8,
|
||||
reviews: 91,
|
||||
verified: true
|
||||
}
|
||||
]
|
||||
|
||||
const laboratories = [
|
||||
{
|
||||
id: 1,
|
||||
name: "MetalTest",
|
||||
price: 8000,
|
||||
tests: "Chemical analysis, mechanical properties",
|
||||
certification: "ISO 9001",
|
||||
rating: 4.7,
|
||||
reviews: 45,
|
||||
verified: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "QualityControl",
|
||||
price: 6500,
|
||||
tests: "Basic quality tests",
|
||||
certification: "GOST R",
|
||||
rating: 4.4,
|
||||
reviews: 32,
|
||||
verified: false
|
||||
}
|
||||
]
|
||||
|
||||
const supplier = computed(() => ({
|
||||
id: route.params.id,
|
||||
name: "UralMetal",
|
||||
location: "Yekaterinburg",
|
||||
totalPrice: 180000,
|
||||
selectedLogistics: { route: "Auto delivery", time: "5-7 days" }
|
||||
}))
|
||||
|
||||
const totalCost = computed(() => {
|
||||
let total = supplier.value.totalPrice
|
||||
if (selectedServices.value.logistics) {
|
||||
const logistics = logisticsCompanies.find(l => l.id === selectedServices.value.logistics)
|
||||
total += logistics?.price || 0
|
||||
}
|
||||
if (selectedServices.value.insurance) {
|
||||
const insurance = insuranceCompanies.find(i => i.id === selectedServices.value.insurance)
|
||||
total += insurance?.cost || 0
|
||||
}
|
||||
if (selectedServices.value.laboratory) {
|
||||
const lab = laboratories.find(l => l.id === selectedServices.value.laboratory)
|
||||
total += lab?.price || 0
|
||||
}
|
||||
return total
|
||||
})
|
||||
</script>
|
||||
81
app/pages/test-gantt.vue
Normal file
81
app/pages/test-gantt.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="6">
|
||||
<Heading :level="1">Test Timeline Chart</Heading>
|
||||
|
||||
<Card>
|
||||
<Stack gap="4">
|
||||
<Heading :level="3" weight="semibold">Hello World vue-timeline-chart</Heading>
|
||||
|
||||
<ClientOnly>
|
||||
<Timeline
|
||||
:groups="timelineGroups"
|
||||
:items="timelineItems"
|
||||
:viewportMin="Date.parse('2024-08-01')"
|
||||
:viewportMax="Date.parse('2024-08-31')"
|
||||
style="height: 400px;"
|
||||
>
|
||||
<template #group-label="{ group }">
|
||||
<Text weight="semibold">{{ group.label }}</Text>
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Pill variant="primary">{{ item.label }}</Pill>
|
||||
</template>
|
||||
</Timeline>
|
||||
|
||||
<template #fallback>
|
||||
<Card tone="muted" padding="lg" style="height: 384px;">
|
||||
<Stack align="center" justify="center" gap="2" fullHeight>
|
||||
<Text tone="muted">Loading...</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card tone="muted" padding="lg">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">Debug info:</Heading>
|
||||
<Text tag="pre" mono size="base">{{ debugInfo }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const timelineGroups = [
|
||||
{ id: 'group1', label: 'Stage 1' },
|
||||
{ id: 'group2', label: 'Stage 2' }
|
||||
]
|
||||
|
||||
const timelineItems = [
|
||||
{
|
||||
id: 'item1',
|
||||
group: 'group1',
|
||||
start: Date.parse('2024-08-01'),
|
||||
end: Date.parse('2024-08-10'),
|
||||
label: 'Task 1'
|
||||
},
|
||||
{
|
||||
id: 'item2',
|
||||
group: 'group2',
|
||||
start: Date.parse('2024-08-05'),
|
||||
end: Date.parse('2024-08-15'),
|
||||
label: 'Task 2'
|
||||
}
|
||||
]
|
||||
|
||||
const debugInfo = computed(() =>
|
||||
JSON.stringify(
|
||||
{
|
||||
groups: timelineGroups,
|
||||
items: timelineItems,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
</script>
|
||||
1318
app/pages/test-map.vue
Normal file
1318
app/pages/test-map.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user