Some checks failed
Build Docker Image / build (push) Has been cancelled
- Add layout: 'topnav' to all 27 pages that were using default layout - Delete app/layouts/default.vue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
283 lines
9.5 KiB
Vue
283 lines
9.5 KiB
Vue
<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({
|
|
layout: 'topnav',
|
|
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>
|