Initial commit from monorepo
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user