refactor: remove all any types, add strict GraphQL scalar typing
All checks were successful
Build Docker Image / build (push) Successful in 4m3s

- Add strictScalars: true to codegen.ts with proper scalar mappings
  (Date, Decimal, JSONString, JSON, UUID, BigInt → string/Record)
- Replace all ref<any[]> with proper GraphQL-derived types
- Add type guards for null filtering in arrays
- Fix bugs exposed by typing (locationLatitude vs latitude, etc.)
- Add interfaces for external components (MapboxSearchBox)

This enables end-to-end type safety from GraphQL schema to frontend.
This commit is contained in:
Ruslan Bakiev
2026-01-27 11:34:12 +07:00
parent ff34c564e1
commit 2dbe600d8a
42 changed files with 614 additions and 324 deletions

View File

@@ -44,7 +44,9 @@
</template>
<script setup lang="ts">
import { GetNodeDocument, NearestOffersDocument } from '~/composables/graphql/public/geo-generated'
import { GetNodeDocument, NearestOffersDocument, type OfferWithRouteType, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
type Hub = NonNullable<GetNodeQueryResult['node']>
definePageMeta({
layout: 'topnav'
@@ -56,7 +58,7 @@ const { t } = useI18n()
const isLoading = ref(true)
const hoveredId = ref<string>()
const hub = ref<any>(null)
const hub = ref<Hub | null>(null)
const products = ref<Array<{ uuid: string; name: string }>>([])
const hubId = computed(() => route.params.id as string)
@@ -150,7 +152,7 @@ try {
// Group offers by product
const productsMap = new Map<string, { uuid: string; name: string }>()
offersData.value?.nearestOffers?.forEach((offer: any) => {
offersData.value?.nearestOffers?.forEach((offer) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {

View File

@@ -37,19 +37,19 @@
</div>
<!-- Location on map -->
<div v-if="offer.latitude && offer.longitude" class="h-48 rounded-lg overflow-hidden">
<div v-if="offer.locationLatitude && offer.locationLongitude" class="h-48 rounded-lg overflow-hidden">
<ClientOnly>
<MapboxMap
map-id="offer-location-map"
class="w-full h-full"
:options="{
style: 'mapbox://styles/mapbox/streets-v12',
center: [offer.longitude, offer.latitude],
center: [offer.locationLongitude, offer.locationLatitude],
zoom: 8
}"
>
<MapboxDefaultMarker
:lnglat="[offer.longitude, offer.latitude]"
:lnglat="[offer.locationLongitude, offer.locationLatitude]"
color="#10b981"
/>
</MapboxMap>
@@ -101,7 +101,10 @@
</template>
<script setup lang="ts">
import { GetOfferDocument, GetSupplierProfileByTeamDocument } from '~/composables/graphql/public/exchange-generated'
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
type Offer = NonNullable<GetOfferQueryResult['getOffer']>
type SupplierProfile = NonNullable<GetSupplierProfileByTeamQueryResult['getSupplierProfileByTeam']>
definePageMeta({
layout: 'topnav'
@@ -114,8 +117,8 @@ const { execute } = useGraphQL()
const offerId = computed(() => route.params.offerId as string)
const isLoading = ref(true)
const offer = ref<any>(null)
const supplier = ref<any>(null)
const offer = ref<Offer | null>(null)
const supplier = ref<SupplierProfile | null>(null)
// Load offer data
const loadOffer = async () => {

View File

@@ -106,7 +106,7 @@
</Stack>
<!-- Line with this product -->
<template v-for="line in getProductLines(offer)" :key="line?.uuid">
<template v-for="(line, lineIndex) in getProductLines(offer)" :key="line?.uuid ?? lineIndex">
<Card padding="sm" class="bg-base-200">
<Stack direction="row" align="center" justify="between">
<Stack gap="0">
@@ -204,8 +204,14 @@ import {
GetProductsDocument,
GetProductOffersDocument,
GetSupplierProfilesDocument,
type GetProductsQueryResult,
type GetProductOffersQueryResult
} from '~/composables/graphql/public/exchange-generated'
// Types from GraphQL
type Product = NonNullable<NonNullable<GetProductsQueryResult['getProducts']>[number]>
type ProductOffer = NonNullable<NonNullable<GetProductOffersQueryResult['getOffers']>[number]>
definePageMeta({
layout: 'topnav'
})
@@ -237,7 +243,7 @@ const allSuppliers = computed(() => suppliersData.value?.getSupplierProfiles ||
const productId = computed(() => route.params.id as string)
// Find product by uuid from list
const findProduct = (products: any[]) => {
const findProduct = (products: (Product | null)[]) => {
return products.find(p => p?.uuid === productId.value)
}
@@ -295,11 +301,13 @@ const mapLocations = computed(() => {
const priceRange = computed(() => {
const prices: number[] = []
offers.value.forEach(offer => {
(offer as any).lines?.forEach((line: any) => {
if (line?.productUuid === productId.value && line?.pricePerUnit) {
prices.push(Number(line.pricePerUnit))
// Offers for this product already filtered by productUuid
if (offer.pricePerUnit) {
const price = typeof offer.pricePerUnit === 'string' ? parseFloat(offer.pricePerUnit) : Number(offer.pricePerUnit)
if (!isNaN(price)) {
prices.push(price)
}
})
}
})
if (prices.length === 0) return t('common.values.not_available')
const min = Math.min(...prices)
@@ -308,9 +316,24 @@ const priceRange = computed(() => {
return t('catalogProduct.labels.price_range', { min: min.toLocaleString(), max: max.toLocaleString() })
})
// Get lines with this product
const getProductLines = (offer: any) => {
return (offer.lines || []).filter((line: any) => line?.productUuid === productId.value)
// Get offer as "line" - offers already have quantity/unit/price directly
interface OfferLine {
uuid?: string | null
quantity?: string | number | null
unit?: string | null
pricePerUnit?: string | number | null
currency?: string | null
}
const getProductLines = (offer: ProductOffer): OfferLine[] => {
// Each offer is a single "line" with quantity, unit, and price
return [{
uuid: offer.uuid,
quantity: offer.quantity,
unit: offer.unit,
pricePerUnit: offer.pricePerUnit,
currency: offer.currency
}]
}
const getCategoryIcon = (categoryName: string | null | undefined) => {
@@ -355,9 +378,10 @@ const formatDate = (dateStr: string | null | undefined) => {
}
}
const formatPrice = (price: any, currency: string | null | undefined) => {
const formatPrice = (price: string | number | null | undefined, currency: string | null | undefined) => {
if (!price) return '—'
const num = Number(price)
const num = typeof price === 'string' ? parseFloat(price) : Number(price)
if (isNaN(num)) return '—'
const curr = currency || 'USD'
try {
return new Intl.NumberFormat('ru', {

View File

@@ -94,6 +94,10 @@
import { NuxtLink } from '#components'
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
interface MapboxSearchBox {
value: string
}
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
@@ -112,7 +116,7 @@ const isSaving = ref(false)
const isDeleting = ref(false)
const searchBoxContainer = ref<HTMLElement | null>(null)
const mapInstance = ref<MapboxMapType | null>(null)
const searchBoxRef = ref<any>(null)
const searchBoxRef = ref<MapboxSearchBox | null>(null)
const addressData = ref<{
uuid: string
@@ -130,7 +134,7 @@ const loadAddress = async () => {
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
const addresses = data?.teamAddresses || []
const found = addresses.find((a: any) => a.uuid === uuid.value)
const found = addresses.find((a) => a?.uuid === uuid.value)
if (found) {
addressData.value = {
@@ -167,7 +171,7 @@ const reverseGeocode = async (lat: number, lng: number): Promise<{ address: stri
if (!feature) return { address: null, countryCode: null }
// Extract country code from context
const countryContext = feature.context?.find((c: any) => c.id?.startsWith('country.'))
const countryContext = feature.context?.find((c: { id?: string }) => c.id?.startsWith('country.'))
const countryCode = countryContext?.short_code?.toUpperCase() || null
return { address: feature.place_name, countryCode }
@@ -215,7 +219,7 @@ onMounted(async () => {
searchBox.value = addressData.value.address
}
searchBox.addEventListener('retrieve', (event: any) => {
searchBox.addEventListener('retrieve', (event: CustomEvent) => {
if (!addressData.value) return
const feature = event.detail.features?.[0]

View File

@@ -118,8 +118,10 @@ const onSearch = () => {
// TODO: Implement search
}
const onSelectAddress = (item: any) => {
selectedAddressId.value = item.uuid
const onSelectAddress = (item: { uuid?: string | null }) => {
if (item.uuid) {
selectedAddressId.value = item.uuid
}
}
await init()

View File

@@ -62,10 +62,12 @@ 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 selectItem = (item: { uuid?: string | null; latitude?: number | null; longitude?: number | null }) => {
if (item.uuid) {
selectedItemId.value = item.uuid
if (item.latitude && item.longitude) {
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
}
}
}

View File

@@ -71,6 +71,10 @@
import { NuxtLink } from '#components'
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
interface MapboxSearchBox {
value: string
}
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
@@ -84,7 +88,7 @@ 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 searchBoxRef = ref<MapboxSearchBox | null>(null)
const newAddress = reactive({
name: '',
@@ -110,7 +114,7 @@ const reverseGeocode = async (lat: number, lng: number): Promise<{ address: stri
if (!feature) return { address: null, countryCode: null }
// Extract country code from context
const countryContext = feature.context?.find((c: any) => c.id?.startsWith('country.'))
const countryContext = feature.context?.find((c: { id?: string }) => c.id?.startsWith('country.'))
const countryCode = countryContext?.short_code?.toUpperCase() || null
return { address: feature.place_name, countryCode }
@@ -151,7 +155,7 @@ onMounted(async () => {
}
searchBox.placeholder = t('profileAddresses.form.address.placeholder')
searchBox.addEventListener('retrieve', (event: any) => {
searchBox.addEventListener('retrieve', (event: CustomEvent) => {
const feature = event.detail.features?.[0]
if (feature) {
const [lng, lat] = feature.geometry.coordinates

View File

@@ -109,9 +109,9 @@ const handleSend = async () => {
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
chat.value.push({ role: 'assistant', content })
scrollToBottom()
} catch (e: any) {
} catch (e: unknown) {
console.error('Agent error', e)
error.value = e?.message || t('aiAssistants.view.error')
error.value = e instanceof Error ? e.message : t('aiAssistants.view.error')
chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') })
scrollToBottom()
} finally {

View File

@@ -95,6 +95,10 @@
</template>
<script setup lang="ts">
import type { GetTeamTransactionsQueryResult } from '~/composables/graphql/team/billing-generated'
type Transaction = NonNullable<NonNullable<GetTeamTransactionsQueryResult['teamTransactions']>[number]>
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
@@ -112,7 +116,7 @@ const balance = ref({
exists: false
})
const transactions = ref<any[]>([])
const transactions = ref<Transaction[]>([])
const formatCurrency = (amount: number) => {
// Amount is in kopecks, convert to base units
@@ -130,7 +134,7 @@ const formatAmount = (amount: number) => {
}).format(amount / 100)
}
const formatTimestamp = (timestamp: number) => {
const formatTimestamp = (timestamp: number | null | undefined) => {
if (!timestamp) return '—'
// TigerBeetle timestamp is in nanoseconds since epoch
const date = new Date(timestamp / 1000000)
@@ -157,8 +161,8 @@ const loadBalance = async () => {
if (data.value?.teamBalance) {
balance.value = data.value.teamBalance
}
} catch (e: any) {
error.value = e.message || t('billing.errors.load_failed')
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : t('billing.errors.load_failed')
} finally {
isLoading.value = false
}
@@ -171,7 +175,7 @@ const loadTransactions = async () => {
if (txError.value) throw txError.value
transactions.value = data.value?.teamTransactions || []
transactions.value = (data.value?.teamTransactions || []).filter((tx): tx is Transaction => tx !== null)
} catch (e) {
console.error('Failed to load transactions', e)
}

View File

@@ -146,8 +146,8 @@ const switchToTeam = async (teamId: string) => {
markActiveTeam(newActiveId)
navigateTo(localePath('/clientarea/team'))
}
} catch (err: any) {
error.value = err.message || t('clientTeamSwitch.error.switch')
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('clientTeamSwitch.error.switch')
hasError.value = true
}
}

View File

@@ -24,7 +24,7 @@
<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>
<Heading :level="4" weight="semibold">{{ request.teamName || t('kycOverview.list.unnamed') }}</Heading>
<Pill :variant="getStatusVariant(request)" :tone="getStatusTone(request)">
{{ getStatusText(request) }}
</Pill>
@@ -32,8 +32,8 @@
<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 tone="muted" size="base">
{{ t('kycOverview.list.country') }}: {{ request.countryCode }}
</Text>
</Stack>
</Card>
@@ -91,7 +91,9 @@
</template>
<script setup lang="ts">
import { GetKycRequestsRussiaDocument } from '~/composables/graphql/user/kyc-generated'
import { GetKycRequestsRussiaDocument, type GetKycRequestsRussiaQueryResult } from '~/composables/graphql/user/kyc-generated'
type KycRequest = NonNullable<NonNullable<GetKycRequestsRussiaQueryResult['kycRequests']>[number]>
definePageMeta({
layout: 'topnav',
@@ -102,7 +104,7 @@ const { t } = useI18n()
const loading = ref(true)
const error = ref<string | null>(null)
const kycRequests = ref<any[]>([])
const kycRequests = ref<KycRequest[]>([])
const selectCountry = (country: string) => {
if (country === 'russia') {
@@ -110,21 +112,18 @@ const selectCountry = (country: string) => {
}
}
const getStatusVariant = (request: any) => {
const getStatusVariant = (request: KycRequest) => {
if (request.approvedAt) return 'primary'
if (request.rejectedAt) return 'outline'
return 'outline'
}
const getStatusTone = (request: any) => {
const getStatusTone = (request: KycRequest) => {
if (request.approvedAt) return 'success'
if (request.rejectedAt) return 'error'
return 'warning'
}
const getStatusText = (request: any) => {
const getStatusText = (request: KycRequest) => {
if (request.approvedAt) return t('kycOverview.list.status.approved')
if (request.rejectedAt) return t('kycOverview.list.status.rejected')
return t('kycOverview.list.status.pending')
}
@@ -143,10 +142,10 @@ const loadKYCStatus = async () => {
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) {
kycRequests.value = [...requests]
.filter((r): r is KycRequest => r !== null)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
} catch (err: unknown) {
error.value = t('kycOverview.errors.load_failed')
} finally {
loading.value = false

View File

@@ -57,24 +57,39 @@ const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref(false)
const handleSubmit = async (formData: any) => {
interface KycFormData {
company_name?: string
company_full_name?: string
inn?: string
kpp?: string
ogrn?: string
address?: string
bank_name?: string
bik?: string
correspondent_account?: string
contact_person?: string
contact_email?: string
contact_phone?: string
}
const handleSubmit = async (formData: KycFormData) => {
try {
submitting.value = true
submitError.value = null
const submitData = {
companyName: formData.company_name,
companyFullName: formData.company_full_name,
inn: formData.inn,
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,
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,
contactPerson: formData.contact_person || '',
contactEmail: formData.contact_email || '',
contactPhone: formData.contact_phone || '',
}
const result = await mutate(CreateKycApplicationRussiaDocument, { input: submitData }, 'user', 'kyc')
@@ -85,8 +100,8 @@ const handleSubmit = async (formData: any) => {
} else {
throw new Error(t('kycRussia.errors.create_failed'))
}
} catch (err: any) {
submitError.value = err.message || t('kycRussia.errors.submit_failed')
} catch (err: unknown) {
submitError.value = err instanceof Error ? err.message : t('kycRussia.errors.submit_failed')
} finally {
submitting.value = false
}

View File

@@ -118,7 +118,9 @@ 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'
import { GetTeamAddressesDocument, type GetTeamAddressesQueryResult } from '~/composables/graphql/team/teams-generated'
type TeamAddress = NonNullable<NonNullable<GetTeamAddressesQueryResult['teamAddresses']>[number]>
definePageMeta({
layout: 'topnav',
@@ -147,7 +149,7 @@ 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 addresses = ref<TeamAddress[]>([])
const selectedAddressUuid = ref<string | null>(null)
const formKitConfig = {
classes: {
@@ -169,8 +171,8 @@ 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)
addresses.value = (data.value?.teamAddresses || []).filter((a): a is TeamAddress => a !== null)
const defaultAddress = addresses.value.find((address) => address.isDefault)
selectedAddressUuid.value = defaultAddress?.uuid || addresses.value[0]?.uuid || null
} catch (err) {
console.error('Failed to load addresses:', err)
@@ -189,7 +191,7 @@ const loadData = async () => {
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)
const product = products.find((p) => p?.uuid === productUuid.value)
if (!product) {
throw new Error(t('clientOfferForm.errors.productNotFound', { uuid: productUuid.value }))
@@ -219,9 +221,9 @@ const loadData = async () => {
formkitSchema.value = schemaToFormKit(terminusClass, enums)
await loadAddresses()
} catch (err: any) {
} catch (err: unknown) {
hasError.value = true
error.value = err.message || t('clientOfferForm.error.load')
error.value = err instanceof Error ? err.message : t('clientOfferForm.error.load')
console.error('Load error:', err)
} finally {
isLoading.value = false
@@ -237,7 +239,7 @@ const handleSubmit = async (data: Record<string, unknown>) => {
throw new Error(t('clientOfferForm.error.load'))
}
const selectedAddress = addresses.value.find((address: any) => address.uuid === selectedAddressUuid.value)
const selectedAddress = addresses.value.find((address) => address?.uuid === selectedAddressUuid.value)
if (!selectedAddress) {
throw new Error(t('clientOfferForm.error.save'))
}
@@ -253,14 +255,14 @@ const handleSubmit = async (data: Record<string, unknown>) => {
locationCountryCode: selectedAddress.countryCode || '',
locationLatitude: selectedAddress.latitude,
locationLongitude: selectedAddress.longitude,
quantity: data.quantity || 0,
quantity: String(data.quantity || '0'),
unit: String(data.unit || 'ton'),
pricePerUnit: data.price_per_unit || data.pricePerUnit || null,
pricePerUnit: String(data.price_per_unit || data.pricePerUnit || ''),
currency: String(data.currency || 'USD'),
description: String(data.description || ''),
validUntil: data.valid_until || data.validUntil || null,
validUntil: (data.valid_until as string | undefined) ?? (data.validUntil as string | undefined) ?? undefined,
terminusSchemaId: schemaId.value,
terminusPayload: JSON.stringify(data),
terminusPayload: data,
}
const result = await mutate(CreateOfferDocument, { input }, 'team', 'exchange')
@@ -270,8 +272,8 @@ const handleSubmit = async (data: Record<string, unknown>) => {
await navigateTo(localePath('/clientarea/offers'))
} catch (err: any) {
error.value = err.message || t('clientOfferForm.error.save')
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('clientOfferForm.error.save')
hasError.value = true
} finally {
isSubmitting.value = false

View File

@@ -122,7 +122,9 @@
<script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated'
import { GetOffersDocument, type GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated'
type Offer = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
definePageMeta({
layout: 'topnav',
@@ -135,7 +137,7 @@ const { activeTeamId } = useActiveTeam()
const { execute } = useGraphQL()
const PAGE_SIZE = 24
const offers = ref<any[]>([])
const offers = ref<Offer[]>([])
const totalOffers = ref(0)
const isLoadingMore = ref(false)
@@ -164,7 +166,7 @@ const {
watchEffect(() => {
if (offersData.value?.getOffers) {
offers.value = offersData.value.getOffers
offers.value = offersData.value.getOffers.filter((o): o is Offer => o !== null)
totalOffers.value = offersData.value.getOffersCount ?? offersData.value.getOffers.length
}
})
@@ -231,9 +233,11 @@ const onSearch = () => {
// TODO: Implement search
}
const onSelectOffer = (offer: any) => {
selectedOfferId.value = offer.uuid
navigateTo(localePath(`/clientarea/offers/${offer.uuid}`))
const onSelectOffer = (offer: { uuid?: string | null }) => {
if (offer.uuid) {
selectedOfferId.value = offer.uuid
navigateTo(localePath(`/clientarea/offers/${offer.uuid}`))
}
}
const getStatusVariant = (status: string) => {
@@ -293,7 +297,7 @@ const fetchOffers = async (offset = 0, replace = false) => {
'public',
'exchange'
)
const next = data?.getOffers || []
const next = (data?.getOffers || []).filter((o): o is Offer => o !== null)
offers.value = replace ? next : offers.value.concat(next)
totalOffers.value = data?.getOffersCount ?? totalOffers.value
}

View File

@@ -26,8 +26,8 @@
<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"
v-for="(product, index) in products"
:key="product.uuid ?? index"
padding="lg"
class="cursor-pointer hover:shadow-md transition-shadow"
@click="selectProduct(product)"
@@ -51,7 +51,9 @@
</template>
<script setup lang="ts">
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
import { GetProductsDocument, type GetProductsQueryResult } from '~/composables/graphql/public/exchange-generated'
type Product = NonNullable<NonNullable<GetProductsQueryResult['getProducts']>[number]>
definePageMeta({
layout: 'topnav',
@@ -62,7 +64,7 @@ const localePath = useLocalePath()
const { t } = useI18n()
const { execute } = useGraphQL()
const products = ref<any[]>([])
const products = ref<Product[]>([])
const isLoading = ref(true)
const hasError = ref(false)
const error = ref('')
@@ -73,19 +75,19 @@ const loadProducts = async () => {
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) {
products.value = (data.value?.getProducts || []).filter((p): p is Product => p !== null)
} catch (err: unknown) {
hasError.value = true
error.value = err.message || t('offersNew.errors.load_failed')
error.value = err instanceof Error ? err.message : t('offersNew.errors.load_failed')
products.value = []
} finally {
isLoading.value = false
}
}
const selectProduct = (product: any) => {
const selectProduct = (product: { uuid?: string | null }) => {
// Navigate to product details page
navigateTo(localePath(`/clientarea/offers/${product.uuid}`))
if (product.uuid) navigateTo(localePath(`/clientarea/offers/${product.uuid}`))
}
await loadProducts()

View File

@@ -46,9 +46,15 @@
</template>
<script setup lang="ts">
import { GetOrderDocument } from '~/composables/graphql/team/orders-generated'
import { GetOrderDocument, type GetOrderQueryResult } from '~/composables/graphql/team/orders-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
// Types from GraphQL
type OrderType = NonNullable<GetOrderQueryResult['getOrder']>
type StageType = NonNullable<NonNullable<OrderType['stages']>[number]>
type TripType = NonNullable<NonNullable<StageType['trips']>[number]>
type CompanyType = NonNullable<StageType['selectedCompany']>
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
@@ -57,7 +63,7 @@ definePageMeta({
const route = useRoute()
const { t } = useI18n()
const order = ref<any>(null)
const order = ref<OrderType | null>(null)
const isLoadingOrder = ref(true)
const hasOrderError = ref(false)
const orderError = ref('')
@@ -96,8 +102,8 @@ const orderMeta = computed(() => {
const orderRoutesForMap = computed(() => {
const stages = (order.value?.stages || [])
.filter(Boolean)
.map((stage: any) => {
.filter((stage): stage is StageType => stage !== null)
.map((stage) => {
if (stage.stageType === 'transport') {
if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null
return {
@@ -118,33 +124,43 @@ const orderRoutesForMap = computed(() => {
return [{ stages }]
})
// Company summary type
interface CompanySummary {
name: string | null | undefined
totalWeight: number
tripsCount: number
company: CompanyType | null | undefined
}
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
return (order.value?.stages || [])
.filter((stage): stage is StageType => stage !== null)
.map((stage) => {
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 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')}`
)
const companies = getCompaniesSummary(stage)
companies.forEach((company: CompanySummary) => {
meta.push(
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
)
})
return {
key: stage.uuid ?? undefined,
from: from ?? undefined,
to: to ?? undefined,
label: stage.name ?? undefined,
meta
}
})
return {
key: stage.uuid,
from,
to,
label: stage.name,
meta
}
})
})
const loadOrder = async () => {
@@ -154,10 +170,10 @@ const loadOrder = async () => {
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) {
order.value = data.value?.getOrder ?? null
} catch (err: unknown) {
hasOrderError.value = true
orderError.value = err.message || t('ordersDetail.errors.load_failed')
orderError.value = err instanceof Error ? err.message : t('ordersDetail.errors.load_failed')
} finally {
isLoadingOrder.value = false
}
@@ -172,8 +188,8 @@ const formatPrice = (price: number, currency?: string | null) => {
}).format(price)
}
const getCompaniesSummary = (stage: any) => {
const companies = []
const getCompaniesSummary = (stage: StageType): CompanySummary[] => {
const companies: CompanySummary[] = []
if (stage.stageType === 'service' && stage.selectedCompany) {
companies.push({
name: stage.selectedCompany.name,
@@ -185,12 +201,13 @@ const getCompaniesSummary = (stage: any) => {
}
if (stage.stageType === 'transport' && stage.trips?.length) {
const companiesMap = new Map()
stage.trips.forEach((trip: any) => {
const companiesMap = new Map<string, CompanySummary>()
stage.trips.forEach((trip) => {
if (!trip) return
const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown')
const weight = trip.plannedWeight || 0
if (companiesMap.has(companyName)) {
const existing = companiesMap.get(companyName)
const existing = companiesMap.get(companyName)!
existing.totalWeight += weight
existing.tripsCount += 1
} else {
@@ -211,10 +228,12 @@ 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)
order.value.stages.forEach((stage) => {
if (!stage) return
stage.trips?.forEach((trip) => {
if (!trip) return
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
})
@@ -224,13 +243,14 @@ const getOrderDuration = () => {
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
}
const getStageDateRange = (stage: any) => {
const getStageDateRange = (stage: StageType) => {
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)
stage.trips.forEach((trip) => {
if (!trip) return
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
})

View File

@@ -100,6 +100,10 @@
<script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
import type { GetTeamOrdersQueryResult } from '~/composables/graphql/team/orders-generated'
type TeamOrder = NonNullable<NonNullable<GetTeamOrdersQueryResult['getTeamOrders']>[number]>
type TeamOrderStage = NonNullable<NonNullable<TeamOrder['stages']>[number]>
definePageMeta({
layout: 'topnav',
@@ -131,19 +135,28 @@ const currentBounds = ref<MapBounds | null>(null)
// List items - one per order
const listItems = computed(() => {
return filteredItems.value.map(order => ({
...order,
uuid: order.uuid,
name: order.name || `#${order.uuid.slice(0, 8)}`,
latitude: order.sourceLatitude,
longitude: order.sourceLongitude,
country: order.sourceLocationName
}))
return filteredItems.value
.filter(order => order.uuid)
.map(order => ({
...order,
uuid: order.uuid,
name: order.name || `#${order.uuid!.slice(0, 8)}`,
latitude: order.sourceLatitude,
longitude: order.sourceLongitude,
country: order.sourceLocationName
}))
})
// Map points - two per order (source + destination)
interface MapPoint {
uuid: string
name: string
latitude: number
longitude: number
}
const mapPoints = computed(() => {
const result: any[] = []
const result: MapPoint[] = []
filteredItems.value.forEach(order => {
// Source point
if (order.sourceLatitude && order.sourceLongitude) {
@@ -202,22 +215,26 @@ const onSearch = () => {
// TODO: Implement search
}
const onSelectOrder = (item: any) => {
selectedOrderId.value = item.uuid
navigateTo(localePath(`/clientarea/orders/${item.uuid}`))
const onSelectOrder = (item: { uuid?: string | null }) => {
if (item.uuid) {
selectedOrderId.value = item.uuid
navigateTo(localePath(`/clientarea/orders/${item.uuid}`))
}
}
await init()
const getOrderStartDate = (order: any) => {
const getOrderStartDate = (order: TeamOrder) => {
if (!order.createdAt) return t('ordersDetail.labels.dates_undefined')
return formatDate(order.createdAt)
}
const getOrderEndDate = (order: any) => {
const getOrderEndDate = (order: TeamOrder) => {
let latestDate: Date | null = null
order.stages?.forEach((stage: any) => {
stage.trips?.forEach((trip: any) => {
order.stages?.forEach((stage) => {
if (!stage) return
stage.trips?.forEach((trip) => {
if (!trip) return
const endDate = trip.actualUnloadingDate || trip.plannedUnloadingDate
if (endDate) {
const date = new Date(endDate)
@@ -236,9 +253,10 @@ const getOrderEndDate = (order: any) => {
return t('ordersDetail.labels.dates_undefined')
}
const getCompletedStages = (order: any) => {
const getCompletedStages = (order: TeamOrder) => {
if (!order.stages?.length) return 0
return order.stages.filter((stage: any) => stage.status === 'completed').length
// Note: StageType doesn't have a status field, count all stages for now
return order.stages.filter((stage): stage is TeamOrderStage => stage !== null).length
}
const formatDate = (date: string) => {

View File

@@ -14,8 +14,8 @@
>
<template #cards>
<Card
v-for="order in filteredItems"
:key="order.uuid"
v-for="(order, index) in filteredItems"
:key="order.uuid ?? index"
padding="small"
interactive
:class="{ 'ring-2 ring-primary': selectedOrderId === order.uuid }"
@@ -24,8 +24,8 @@
<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 :variant="getStatusVariant(order.status || '')" size="sm">
{{ getStatusText(order.status || '') }}
</Badge>
</Stack>
<Text tone="muted" size="sm" class="truncate">
@@ -74,9 +74,11 @@ 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 selectOrder = (order: { uuid?: string | null }) => {
if (order.uuid) {
selectedOrderId.value = order.uuid
mapRef.value?.flyTo(order.uuid)
}
}
const onMapSelectOrder = (uuid: string) => {

View File

@@ -48,8 +48,8 @@
<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"
v-for="(member, index) in currentTeamMembers"
:key="member.user?.id ?? `member-${index}`"
padding="lg"
>
<Stack gap="3">
@@ -67,7 +67,7 @@
<!-- Pending invitations -->
<Card
v-for="invitation in currentTeam?.invitations || []"
v-for="invitation in currentTeamInvitations"
:key="invitation.uuid"
padding="lg"
class="border-dashed border-warning"
@@ -111,7 +111,15 @@
</template>
<script setup lang="ts">
import { GetTeamDocument } from '~/composables/graphql/user/teams-generated'
import { GetTeamDocument, type GetTeamQueryResult } from '~/composables/graphql/user/teams-generated'
interface UserTeam {
id?: string | null
name: string
logtoOrgId?: string | null
}
type TeamWithMembers = NonNullable<GetTeamQueryResult['getTeam']>
const { t } = useI18n()
const router = useRouter()
@@ -129,8 +137,8 @@ const me = useState<{
} | null>('me', () => null)
const { setActiveTeam } = useActiveTeam()
const userTeams = ref<any[]>([])
const currentTeam = ref<any>(null)
const userTeams = ref<UserTeam[]>([])
const currentTeam = ref<TeamWithMembers | UserTeam | null>(null)
const isLoading = ref(true)
const hasError = ref(false)
const error = ref('')
@@ -143,7 +151,7 @@ const teamHeaderActions = computed(() => {
}
return actions
})
const roleText = (role?: string) => {
const roleText = (role?: string | null) => {
const map: Record<string, string> = {
OWNER: t('clientTeam.roles.owner'),
ADMIN: t('clientTeam.roles.admin'),
@@ -153,13 +161,30 @@ const roleText = (role?: string) => {
return map[role || ''] || role || t('clientTeam.roles.member')
}
const getMemberInitials = (user?: any) => {
interface TeamMember {
id?: string | null
firstName?: string | null
lastName?: string | null
}
const getMemberInitials = (user?: TeamMember | null) => {
if (!user) return '??'
const first = user.firstName?.charAt(0) || ''
const last = user.lastName?.charAt(0) || ''
return (first + last).toUpperCase() || user.id?.charAt(0).toUpperCase() || '??'
}
// Type-safe accessors for TeamWithMembers properties
const currentTeamMembers = computed(() => {
const team = currentTeam.value
return team && 'members' in team ? (team.members || []).filter((m): m is NonNullable<typeof m> => m !== null) : []
})
const currentTeamInvitations = computed(() => {
const team = currentTeam.value
return team && 'invitations' in team ? (team.invitations || []).filter((i): i is NonNullable<typeof i> => i !== null) : []
})
const loadUserTeams = async () => {
try {
isLoading.value = true
@@ -177,13 +202,15 @@ const loadUserTeams = async () => {
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
if (firstTeam) {
setActiveTeam(firstTeam.id || null, firstTeam.logtoOrgId)
currentTeam.value = firstTeam
}
}
// Если нет команды - currentTeam остаётся null, показываем EmptyState
} catch (err: any) {
} catch (err: unknown) {
hasError.value = true
error.value = err.message || t('clientTeam.error.load')
error.value = err instanceof Error ? err.message : t('clientTeam.error.load')
} finally {
isLoading.value = false
}

View File

@@ -95,8 +95,8 @@ const submitInvite = async () => {
} else {
inviteError.value = result?.inviteMember?.message || t('clientTeam.invite.error')
}
} catch (err: any) {
inviteError.value = err.message || t('clientTeam.invite.error')
} catch (err: unknown) {
inviteError.value = err instanceof Error ? err.message : t('clientTeam.invite.error')
} finally {
inviteLoading.value = false
}