add customer and providers
This commit is contained in:
@@ -1,75 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { CustomerResponse } from '#open-fetch'
|
||||
defineProps<{
|
||||
customer: CustomerResponse<any>
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="bg-gradient-to-b from-white to-slate-50
|
||||
border border-slate-200 shadow-sm rounded-xl"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
<!-- Avatar + Name -->
|
||||
<div class="flex items-center gap-3">
|
||||
<UAvatar
|
||||
:alt="customer.first_name"
|
||||
size="lg"
|
||||
class="bg-primary-100 text-primary-700"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900 text-lg">
|
||||
{{ customer.first_name }} {{ customer.last_name }}
|
||||
</div>
|
||||
<div class="text-sm text-slate-500">
|
||||
{{ customer.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3 text-sm mt-4">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-500">Birth Date</span>
|
||||
<span class="text-slate-700">{{ customer.birth_date }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-500">Gender</span>
|
||||
<span class="text-slate-700 capitalize">
|
||||
{{ customer.gender }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-500">Phone</span>
|
||||
<span class="text-slate-700">
|
||||
{{ customer.phone }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between pt-4 border-t border-slate-100">
|
||||
<UButton
|
||||
as="NuxtLink"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
:to="{ name: 'customers-id', params: { id: customer.id } }"
|
||||
>
|
||||
View
|
||||
</UButton>
|
||||
|
||||
<UButton size="xs" variant="ghost" color="primary">
|
||||
Policies
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -26,7 +26,7 @@
|
||||
<NuxtLink
|
||||
to="/customers"
|
||||
class="group flex items-center px-3 py-2 rounded-lg text-sm font-medium
|
||||
bg-primary-50 text-primary-700 border border-primary-100"
|
||||
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition"
|
||||
>
|
||||
Customers
|
||||
</NuxtLink>
|
||||
@@ -39,6 +39,22 @@
|
||||
Policies
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
to="/providers"
|
||||
class="group flex items-center px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition"
|
||||
>
|
||||
Providers
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
to="/tasks"
|
||||
class="group flex items-center px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition"
|
||||
>
|
||||
Tasks
|
||||
</NuxtLink>
|
||||
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -1,99 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { data, error, pending } = useCustomer(route.params.id)
|
||||
const id = route.params.id as string
|
||||
|
||||
const { data, error, pending, refresh } = useCustomer(`/customers/${id}`)
|
||||
const customer = computed(() => data.value?.data)
|
||||
|
||||
const isIndividual = computed(() => customer.value?.customer_type !== 'corporate')
|
||||
|
||||
const customerName = computed(() => {
|
||||
if (!customer.value) return ''
|
||||
return isIndividual.value
|
||||
? `${customer.value.first_name} ${customer.value.last_name}`
|
||||
: (customer.value.commercial_name || customer.value.legal_name)
|
||||
})
|
||||
|
||||
// policies — individual uses document_id, corporate uses ruc
|
||||
const policyFilterValue = computed(() =>
|
||||
isIndividual.value ? customer.value?.document_id : customer.value?.ruc
|
||||
)
|
||||
const policyFilterField = computed(() =>
|
||||
isIndividual.value ? 'applicant_document' : 'applicant_document'
|
||||
)
|
||||
|
||||
const { data: policiesData } = usePolicy('/car-policies', {
|
||||
query: computed(() => policyFilterValue.value ? {
|
||||
'filters[0][field]': policyFilterField.value,
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': policyFilterValue.value
|
||||
} : {})
|
||||
})
|
||||
|
||||
const customerPolicies = computed(() => policiesData.value?.data ?? [])
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return '—'
|
||||
return new Date(date).toLocaleDateString('es-PA', {
|
||||
day: '2-digit', month: 'short', year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const statusColor = (status: string) => ({
|
||||
quote_requested: 'yellow',
|
||||
quotes_received: 'blue',
|
||||
solicitation_sent: 'purple',
|
||||
active: 'green'
|
||||
}[status] ?? 'gray')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 bg-slate-100 min-h-screen">
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<NuxtLink to="/customers">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back to Customers</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pending" class="max-w-3xl">
|
||||
<UCard class="bg-white shadow-sm border border-slate-200">
|
||||
<div class="h-48 animate-pulse bg-slate-200 rounded" />
|
||||
<UAlert v-if="error" color="red" variant="soft" title="Failed to load customer" :description="error.message" />
|
||||
|
||||
<div v-else-if="pending" class="space-y-4">
|
||||
<UCard v-for="n in 2" :key="n">
|
||||
<div class="h-32 animate-pulse bg-gray-200 rounded" />
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<UAlert
|
||||
v-else-if="error"
|
||||
color="red"
|
||||
variant="soft"
|
||||
title="Failed to load customer"
|
||||
:description="error.message"
|
||||
/>
|
||||
|
||||
<!-- Customer View -->
|
||||
<div v-else-if="data" class="max-w-4xl space-y-6">
|
||||
|
||||
<!-- Header Card -->
|
||||
<UCard class="bg-white border border-slate-200 shadow-sm rounded-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<UAvatar size="xl" :alt="data.first_name" />
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">
|
||||
{{ data.first_name }} {{ data.last_name }}
|
||||
</h1>
|
||||
<p class="text-slate-500 text-sm">
|
||||
{{ data.email }}
|
||||
</p>
|
||||
<template v-else-if="customer">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-center gap-4">
|
||||
<UAvatar :alt="customerName" size="xl" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ customerName }}</h1>
|
||||
<UBadge :color="isIndividual ? 'blue' : 'purple'" variant="soft" size="sm">
|
||||
{{ isIndividual ? 'Individual' : 'Corporate' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-gray-500">{{ customer.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Details Card -->
|
||||
<UCard class="bg-white border border-slate-200 shadow-sm rounded-xl">
|
||||
<h2 class="text-lg font-semibold text-slate-800 mb-4">
|
||||
Personal Information
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 text-sm">
|
||||
|
||||
<div>
|
||||
<div class="text-slate-500">Birth Date</div>
|
||||
<div class="text-slate-800 font-medium">
|
||||
{{ data.birth_date }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-slate-500">Gender</div>
|
||||
<div class="text-slate-800 font-medium capitalize">
|
||||
{{ data.gender }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-slate-500">Phone</div>
|
||||
<div class="text-slate-800 font-medium">
|
||||
{{ data.phone }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-slate-500">Customer ID</div>
|
||||
<div class="text-slate-800 font-mono text-xs">
|
||||
{{ data.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<UButton icon="i-heroicons-arrow-path" color="gray" variant="soft" :loading="pending" @click="refresh()" />
|
||||
<NuxtLink to="/policies/new">
|
||||
<UButton icon="i-heroicons-plus" color="primary" size="sm">New Policy</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for Policies -->
|
||||
<UCard class="bg-white border border-slate-200 shadow-sm rounded-xl">
|
||||
<h2 class="text-lg font-semibold text-slate-800 mb-4">
|
||||
Policies
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<div class="text-slate-500 text-sm">
|
||||
No policies yet.
|
||||
</div>
|
||||
</UCard>
|
||||
<!-- Individual details -->
|
||||
<UCard v-if="isIndividual" class="lg:col-span-1">
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user" class="w-4 h-4" /> Customer Details
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">ID</span>
|
||||
<span class="font-mono text-xs">{{ customer.id?.slice(0, 8) }}...</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Email</span>
|
||||
<span>{{ customer.email ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Phone</span>
|
||||
<span>{{ customer.phone ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Document ID</span>
|
||||
<span class="font-mono">{{ customer.document_id ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Birth Date</span>
|
||||
<span>{{ formatDate(customer.birth_date) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Gender</span>
|
||||
<span class="capitalize">{{ customer.gender ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
</div>
|
||||
<!-- Corporate details -->
|
||||
<UCard v-else class="lg:col-span-1">
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4" /> Company Details
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">ID</span>
|
||||
<span class="font-mono text-xs">{{ customer.id?.slice(0, 8) }}...</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Legal Name</span>
|
||||
<span class="text-right max-w-44 truncate">{{ customer.legal_name ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Commercial Name</span>
|
||||
<span>{{ customer.commercial_name ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">RUC</span>
|
||||
<span class="font-mono">{{ customer.ruc ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Legal Rep</span>
|
||||
<span>{{ customer.legal_rep_name ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Rep Document</span>
|
||||
<span class="font-mono">{{ customer.legal_rep_document_id ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Email</span>
|
||||
<span>{{ customer.email ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Phone</span>
|
||||
<span>{{ customer.phone ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Address</span>
|
||||
<span class="text-right max-w-44 truncate">{{ customer.address ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Policies -->
|
||||
<UCard class="lg:col-span-2">
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-document-text" class="w-4 h-4" />
|
||||
Policies
|
||||
<UBadge color="gray" variant="soft" size="xs">{{ customerPolicies.length }}</UBadge>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<NuxtLink
|
||||
v-for="policy in customerPolicies"
|
||||
:key="policy.application_id"
|
||||
:to="`/policies/${policy.application_id}`"
|
||||
>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-truck" class="w-5 h-5 text-blue-400" />
|
||||
<div>
|
||||
<p class="font-medium text-sm text-slate-800">
|
||||
{{ policy.plate }} — {{ policy.year }} {{ policy.make }} {{ policy.model }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 font-mono">{{ policy.application_id?.slice(0, 8) }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :color="statusColor(policy.status)" variant="soft" size="xs">
|
||||
{{ policy.status?.replace(/_/g, ' ') }}
|
||||
</UBadge>
|
||||
<UIcon name="i-heroicons-chevron-right" class="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="customerPolicies.length === 0" class="text-center py-8 text-gray-400">
|
||||
<UIcon name="i-heroicons-document-text" class="w-8 h-8 mx-auto mb-2" />
|
||||
<p class="text-sm">No policies yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,65 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
const { data, error, pending } = useCustomer('/')
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
|
||||
const page = ref(1)
|
||||
const search = ref('')
|
||||
const customerTypeFilter = ref<string | null>(null)
|
||||
const debouncedSearch = refDebounced(search, 300)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!data.value) return []
|
||||
if (!search.value) return data.value
|
||||
const customerTypeItems = [
|
||||
{ label: 'All Types', value: null },
|
||||
{ label: 'Individual', value: 'individual' },
|
||||
{ label: 'Corporate', value: 'corporate' }
|
||||
]
|
||||
|
||||
return data.value.filter((c: any) =>
|
||||
`${c.first_name} ${c.last_name} ${c.email}`
|
||||
.toLowerCase()
|
||||
.includes(search.value.toLowerCase())
|
||||
)
|
||||
watch([debouncedSearch, customerTypeFilter], () => { page.value = 1 })
|
||||
|
||||
const { data, pending, refresh } = useCustomer('/customers', {
|
||||
query: computed(() => {
|
||||
const filters: Record<string, string> = {}
|
||||
let i = 0
|
||||
|
||||
if (debouncedSearch.value) {
|
||||
filters[`filters[${i}][field]`] = 'search'
|
||||
filters[`filters[${i}][op]`] = '=='
|
||||
filters[`filters[${i}][value]`] = debouncedSearch.value
|
||||
i++
|
||||
}
|
||||
|
||||
if (customerTypeFilter.value) {
|
||||
filters[`filters[${i}][field]`] = 'customer_type'
|
||||
filters[`filters[${i}][op]`] = '=='
|
||||
filters[`filters[${i}][value]`] = customerTypeFilter.value
|
||||
i++
|
||||
}
|
||||
|
||||
return {
|
||||
page_size: 20,
|
||||
page: page.value,
|
||||
...filters
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const total = computed(() => data.value?.length ?? 0)
|
||||
const customers = computed(() => data.value?.data ?? [])
|
||||
const meta = computed(() => data.value?.meta)
|
||||
|
||||
// display helpers
|
||||
const customerName = (c: any) =>
|
||||
c.customer_type === 'corporate'
|
||||
? (c.commercial_name || c.legal_name)
|
||||
: `${c.first_name} ${c.last_name}`
|
||||
|
||||
const customerSubtitle = (c: any) =>
|
||||
c.customer_type === 'corporate' ? c.ruc : c.email
|
||||
|
||||
const customerTypeColor = (type: string) =>
|
||||
type === 'corporate' ? 'purple' : 'blue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl text-slate-900 font-bold">Customers</h1>
|
||||
<p class="text-gray-500 text-sm">Customer Relationship Management</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge color="gray" variant="soft" size="lg">
|
||||
{{ meta?.total_count ?? 0 }} customers
|
||||
</UBadge>
|
||||
<NuxtLink to="/customers/new">
|
||||
<UButton icon="i-heroicons-plus" color="primary">New Customer</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton icon="i-heroicons-plus" color="primary" to="/customers/new">
|
||||
New Customer
|
||||
<!-- Search + Filters -->
|
||||
<div class="flex gap-4 items-center flex-wrap">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
class="w-80"
|
||||
/>
|
||||
<USelect
|
||||
v-model="customerTypeFilter"
|
||||
:items="customerTypeItems"
|
||||
class="w-44"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
:loading="pending"
|
||||
@click="refresh()"
|
||||
>
|
||||
Refresh
|
||||
</UButton>
|
||||
</div>
|
||||
<!-- Error -->
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="red"
|
||||
variant="soft"
|
||||
title="Failed to load customers"
|
||||
:description="error.message"
|
||||
/>
|
||||
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-else-if="pending"
|
||||
class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div v-if="pending" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<UCard v-for="n in 6" :key="n">
|
||||
<div class="h-32 animate-pulse bg-gray-200 rounded" />
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Customer Grid -->
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<CustomerCard
|
||||
v-for="customer in filtered"
|
||||
:key="customer.id"
|
||||
:customer="customer"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink v-for="c in customers" :key="c.id" :to="`/customers/${c.id}`">
|
||||
<UCard class="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<UAvatar :alt="customerName(c)" size="md" />
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-slate-900 truncate">{{ customerName(c) }}</p>
|
||||
<p class="text-sm text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs" class="flex-shrink-0">
|
||||
{{ c.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Individual fields -->
|
||||
<div v-if="c.customer_type !== 'corporate'" class="space-y-1 text-sm pt-2 border-t">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Phone</span>
|
||||
<span>{{ c.phone ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Birth Date</span>
|
||||
<span>{{ c.birth_date ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Gender</span>
|
||||
<span class="capitalize">{{ c.gender ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corporate fields -->
|
||||
<div v-else class="space-y-1 text-sm pt-2 border-t">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Legal Name</span>
|
||||
<span class="truncate max-w-40 text-right">{{ c.legal_name ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">RUC</span>
|
||||
<span class="font-mono">{{ c.ruc ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Legal Rep</span>
|
||||
<span>{{ c.legal_rep_name ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="customers.length === 0" class="col-span-3 text-center py-16 text-gray-400">
|
||||
<UIcon name="i-heroicons-users" class="w-12 h-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No customers found</p>
|
||||
<p class="text-sm">Try adjusting your search or create a new customer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="meta && meta.total_pages > 1" class="flex justify-center">
|
||||
<UPagination
|
||||
v-model="page"
|
||||
:total="meta.total_count"
|
||||
:page-count="meta.page_size"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,126 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import * as z from 'zod'
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
import type { SelectItem } from '@nuxt/ui'
|
||||
|
||||
const schema = z.object({
|
||||
first_name: z.string().min(1, 'First name is required'),
|
||||
last_name: z.string().min(1, 'Last name is required'),
|
||||
email: z.string().email('Invalid email'),
|
||||
phone: z.string().min(1, 'Phone is required'),
|
||||
birth_date: z.string().min(1, 'Birth date is required'),
|
||||
gender: z.enum(['male', 'female'])
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const toast = useToast()
|
||||
const { $customer } = useNuxtApp()
|
||||
|
||||
const customerType = ref<'individual' | 'corporate'>('individual')
|
||||
|
||||
// individual form
|
||||
const individualForm = ref({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
birth_date: '',
|
||||
gender: '',
|
||||
document_id: ''
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive<Partial<Schema>>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
birth_date: '',
|
||||
gender: 'male'
|
||||
// corporate form
|
||||
const corporateForm = ref({
|
||||
legal_name: '',
|
||||
commercial_name: '',
|
||||
ruc: '',
|
||||
legal_rep_name: '',
|
||||
legal_rep_document_id: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const genderItems = ref<SelectItem[]>([
|
||||
{ label: 'Male', value: 'male' },
|
||||
{ label: 'Female', value: 'female' }
|
||||
])
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||
const isValid = computed(() => {
|
||||
if (customerType.value === 'individual') {
|
||||
return individualForm.value.first_name &&
|
||||
individualForm.value.last_name &&
|
||||
individualForm.value.email &&
|
||||
individualForm.value.document_id
|
||||
}
|
||||
return corporateForm.value.legal_name && corporateForm.value.ruc
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await useCustomer('/', {
|
||||
const isIndividual = customerType.value === 'individual'
|
||||
const data = await $customer(isIndividual ? '/customers' : '/customers/corporate', {
|
||||
method: 'POST',
|
||||
body: event.data
|
||||
})
|
||||
|
||||
body: isIndividual ? individualForm.value : corporateForm.value
|
||||
}) as any
|
||||
toast.add({ title: 'Customer created successfully', color: 'green' })
|
||||
router.push(`/customers/${data.data.id}`)
|
||||
} catch (e: any) {
|
||||
toast.add({
|
||||
title: 'Customer created',
|
||||
description: 'The customer was successfully created.',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
router.push('/customers')
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err?.message || 'Failed to create customer',
|
||||
color: 'error'
|
||||
title: 'Failed to create customer',
|
||||
description: e?.data?.errors ? JSON.stringify(e.data.errors) : e.message,
|
||||
color: 'red'
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 p-10">
|
||||
<div class="max-w-5xl mx-auto space-y-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink to="/customers">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">
|
||||
Back to Customers
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">New Customer</h1>
|
||||
<p class="text-gray-500 mt-1">Create a new customer profile</p>
|
||||
<h1 class="text-3xl text-slate-900 font-bold">New Customer</h1>
|
||||
<p class="text-gray-500 text-sm">Create a new customer record</p>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||
<UForm
|
||||
:schema="schema"
|
||||
:state="state"
|
||||
class="space-y-6"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<!-- Grid -->
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
|
||||
<UFormField label="First Name" name="first_name">
|
||||
<UInput v-model="state.first_name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Last Name" name="last_name">
|
||||
<UInput v-model="state.last_name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Email" name="email">
|
||||
<UInput v-model="state.email" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Phone" name="phone">
|
||||
<UInput v-model="state.phone" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Birth Date" name="birth_date">
|
||||
<UInput v-model="state.birth_date" type="date" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Gender" name="gender">
|
||||
<USelect
|
||||
v-model="state.gender"
|
||||
:items="[
|
||||
{ label: 'Male', value: 'male' },
|
||||
{ label: 'Female', value: 'female' }
|
||||
]"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-4 pt-4">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
@click="$router.push('/customers')"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
|
||||
<UButton type="submit" color="primary">
|
||||
Create Customer
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
</UForm>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Type selector -->
|
||||
<div class="flex gap-4 max-w-2xl">
|
||||
<div
|
||||
v-for="type in ['individual', 'corporate']"
|
||||
:key="type"
|
||||
class="flex-1 border-2 rounded-xl p-4 text-center transition-all cursor-pointer"
|
||||
:class="customerType === type
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'"
|
||||
@click="customerType = type as any"
|
||||
>
|
||||
<UIcon
|
||||
:name="type === 'individual' ? 'i-heroicons-user' : 'i-heroicons-building-office'"
|
||||
class="w-8 h-8 mx-auto mb-2"
|
||||
:class="customerType === type ? 'text-primary-500' : 'text-gray-400'"
|
||||
/>
|
||||
<p
|
||||
class="font-medium text-sm"
|
||||
:class="customerType === type ? 'text-primary-700' : 'text-gray-600'"
|
||||
>
|
||||
{{ type === 'individual' ? 'Individual' : 'Corporate' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual form -->
|
||||
<UCard v-if="customerType === 'individual'" class="max-w-2xl">
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user-plus" class="w-4 h-4" />
|
||||
Individual Customer
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="First Name" required>
|
||||
<UInput v-model="individualForm.first_name" placeholder="Juan" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Last Name" required>
|
||||
<UInput v-model="individualForm.last_name" placeholder="Pérez" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="Document ID" required>
|
||||
<UInput
|
||||
v-model="individualForm.document_id"
|
||||
placeholder="V-12345678"
|
||||
icon="i-heroicons-identification"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Email" required>
|
||||
<UInput
|
||||
v-model="individualForm.email"
|
||||
type="email"
|
||||
placeholder="juan@example.com"
|
||||
icon="i-heroicons-envelope"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Phone">
|
||||
<UInput
|
||||
v-model="individualForm.phone"
|
||||
placeholder="+507 6000-0000"
|
||||
icon="i-heroicons-phone"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Birth Date">
|
||||
<UInput v-model="individualForm.birth_date" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Gender">
|
||||
<USelect v-model="individualForm.gender" :items="genderItems" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NuxtLink to="/customers">
|
||||
<UButton color="gray" variant="soft">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-heroicons-check"
|
||||
:loading="submitting"
|
||||
:disabled="!isValid"
|
||||
@click="submit"
|
||||
>
|
||||
Create Customer
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<!-- Corporate form -->
|
||||
<UCard v-else class="max-w-2xl">
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4" />
|
||||
Corporate Customer
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Legal Name" required>
|
||||
<UInput
|
||||
v-model="corporateForm.legal_name"
|
||||
placeholder="Empresa S.A."
|
||||
icon="i-heroicons-building-office"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Commercial Name">
|
||||
<UInput
|
||||
v-model="corporateForm.commercial_name"
|
||||
placeholder="Empresa"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="RUC" required>
|
||||
<UInput
|
||||
v-model="corporateForm.ruc"
|
||||
placeholder="1234567-1-123456"
|
||||
icon="i-heroicons-identification"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Legal Representative">
|
||||
<UInput
|
||||
v-model="corporateForm.legal_rep_name"
|
||||
placeholder="Juan Pérez"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Legal Rep Document ID">
|
||||
<UInput
|
||||
v-model="corporateForm.legal_rep_document_id"
|
||||
placeholder="V-12345678"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="Email">
|
||||
<UInput
|
||||
v-model="corporateForm.email"
|
||||
type="email"
|
||||
placeholder="contacto@empresa.com"
|
||||
icon="i-heroicons-envelope"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Phone">
|
||||
<UInput
|
||||
v-model="corporateForm.phone"
|
||||
placeholder="+507 300-0000"
|
||||
icon="i-heroicons-phone"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Address">
|
||||
<UInput
|
||||
v-model="corporateForm.address"
|
||||
placeholder="Av. Balboa, Panama City"
|
||||
icon="i-heroicons-map-pin"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NuxtLink to="/customers">
|
||||
<UButton color="gray" variant="soft">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-heroicons-check"
|
||||
:loading="submitting"
|
||||
:disabled="!isValid"
|
||||
@click="submit"
|
||||
>
|
||||
Create Corporate Customer
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
454
app/pages/policies/[application_id].vue
Normal file
454
app/pages/policies/[application_id].vue
Normal file
@@ -0,0 +1,454 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const applicationId = route.params.application_id as string
|
||||
|
||||
const { data, error, pending, refresh } = usePolicy(`/car-policies/${applicationId}`)
|
||||
const policy = computed(() => data.value?.data)
|
||||
|
||||
// ── Accept plan ──────────────────────────────────────────────────────────────
|
||||
const isAcceptOpen = ref(false)
|
||||
const accepting = ref(false)
|
||||
const selectedQuote = ref<any>(null)
|
||||
const selectedPlan = ref<any>(null)
|
||||
const solicitationFields = ref<Record<string, string>>({})
|
||||
const toast = useToast()
|
||||
const { $policy } = useNuxtApp()
|
||||
|
||||
function openAccept(quote: any, plan: any) {
|
||||
selectedQuote.value = quote
|
||||
selectedPlan.value = plan
|
||||
solicitationFields.value = {}
|
||||
isAcceptOpen.value = true
|
||||
}
|
||||
|
||||
async function submitAccept() {
|
||||
accepting.value = true
|
||||
try {
|
||||
await $policy(`/car-policies/${applicationId}/accept`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
quote_id: selectedQuote.value.quote_id,
|
||||
plan_id: selectedPlan.value.plan_id,
|
||||
solicitation_fields: solicitationFields.value
|
||||
}
|
||||
})
|
||||
toast.add({ title: 'Plan accepted — solicitation in progress', color: 'green' })
|
||||
isAcceptOpen.value = false
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed to accept plan', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
accepting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Solicitation PDF ─────────────────────────────────────────────────────────
|
||||
const solicitationUrl = ref<string | null>(null)
|
||||
const loadingPdf = ref(false)
|
||||
const pdfError = ref<string | null>(null)
|
||||
|
||||
async function loadSolicitationUrl() {
|
||||
if (!policy.value?.solicitation_id) return
|
||||
loadingPdf.value = true
|
||||
pdfError.value = null
|
||||
try {
|
||||
const res = await $policy(`/car-policies/${applicationId}/solicitation-url`) as any
|
||||
solicitationUrl.value = res.download_url
|
||||
} catch (e: any) {
|
||||
pdfError.value = e?.data?.error ?? 'Failed to load document'
|
||||
} finally {
|
||||
loadingPdf.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => policy.value?.solicitation_id, (id) => {
|
||||
if (id) loadSolicitationUrl()
|
||||
}, { immediate: true })
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const quotes = computed(() => {
|
||||
if (!policy.value?.quotes) return []
|
||||
return Object.entries(policy.value.quotes).map(([provider_id, q]: [string, any]) => ({
|
||||
provider_id, ...q
|
||||
}))
|
||||
})
|
||||
|
||||
const allPlans = computed(() =>
|
||||
quotes.value.flatMap((q: any) =>
|
||||
(q.plans ?? []).map((p: any) => ({
|
||||
...p,
|
||||
provider_id: q.provider_id,
|
||||
valid_until: q.valid_until,
|
||||
quote_id: q.quote_id
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
const canAccept = computed(() => policy.value?.status === 'quotes_received')
|
||||
|
||||
const statusColor = (s: string) => ({
|
||||
quote_requested: 'yellow',
|
||||
quotes_received: 'blue',
|
||||
solicitation_sent: 'purple',
|
||||
active: 'green'
|
||||
}[s] ?? 'gray')
|
||||
|
||||
const statusLabel = (s: string) => ({
|
||||
quote_requested: 'Quote Requested',
|
||||
quotes_received: 'Quotes Received',
|
||||
solicitation_sent: 'Solicitation Sent',
|
||||
active: 'Active'
|
||||
}[s] ?? s)
|
||||
|
||||
const clientTypeLabel = (ct: string) => ct === 'juridico' ? 'Jurídico' : 'Natural'
|
||||
const clientTypeColor = (ct: string) => ct === 'juridico' ? 'purple' : 'blue'
|
||||
|
||||
const formatDate = (d: string) =>
|
||||
d ? new Date(d).toLocaleDateString('es-PA', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
|
||||
|
||||
const formatDateTime = (d: string) =>
|
||||
d ? new Date(d).toLocaleString('es-PA') : '—'
|
||||
|
||||
// ── Applicant display — handles both natural and juridico ────────────────────
|
||||
const applicantRows = computed(() => {
|
||||
const info = policy.value?.applicant_info ?? {}
|
||||
const ct = policy.value?.client_type
|
||||
|
||||
if (ct === 'juridico') {
|
||||
return [
|
||||
{ label: 'Company', value: info.company_name ?? info['company_name'] },
|
||||
{ label: 'RUC', value: info.ruc ?? info['ruc'] },
|
||||
{ label: 'Legal Rep', value: info.legal_rep_name ?? info['legal_rep_name'] },
|
||||
{ label: 'Rep Document', value: info.legal_rep_document ?? info['legal_rep_document'] }
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: 'Name', value: info.name ?? info['name'] },
|
||||
{ label: 'DOB', value: formatDate(info.date_of_birth ?? info['date_of_birth']) },
|
||||
{ label: 'Document', value: info.document_id ?? info['document_id'] }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<NuxtLink to="/policies">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back to Policies</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<UAlert v-if="error" color="red" variant="soft" title="Failed to load policy" :description="error.message" />
|
||||
|
||||
<div v-else-if="pending" class="space-y-4">
|
||||
<UCard v-for="n in 4" :key="n"><div class="h-32 animate-pulse bg-gray-200 rounded" /></UCard>
|
||||
</div>
|
||||
|
||||
<template v-else-if="policy">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :color="statusColor(policy.status)" variant="soft">
|
||||
{{ statusLabel(policy.status) }}
|
||||
</UBadge>
|
||||
<UBadge :color="clientTypeColor(policy.client_type)" variant="outline">
|
||||
{{ clientTypeLabel(policy.client_type) }}
|
||||
</UBadge>
|
||||
<UBadge color="gray" variant="outline">CAR</UBadge>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ policy.applicant_display_name }}</h1>
|
||||
<p class="text-gray-500 text-sm font-mono">{{ policy.application_id }}</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-arrow-path" color="gray" variant="soft" :loading="pending" @click="refresh()" />
|
||||
</div>
|
||||
|
||||
<!-- Info grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Applicant — dynamic rows based on client_type -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user" class="w-4 h-4" />
|
||||
{{ policy.client_type === 'juridico' ? 'Legal Entity' : 'Applicant' }}
|
||||
<UBadge :color="clientTypeColor(policy.client_type)" variant="soft" size="xs">
|
||||
{{ clientTypeLabel(policy.client_type) }}
|
||||
</UBadge>
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-for="row in applicantRows" :key="row.label" class="flex justify-between">
|
||||
<span class="text-gray-500">{{ row.label }}</span>
|
||||
<span class="font-medium font-mono text-xs">{{ row.value ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Vehicle -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-truck" class="w-4 h-4" /> Vehicle
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">Plate</span><span class="font-mono font-medium">{{ policy.plate }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Vehicle</span><span>{{ policy.year }} {{ policy.make }} {{ policy.model }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Value</span><span>${{ Number(policy.car_value).toLocaleString() }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Type</span><span class="capitalize">{{ policy.car_type }} / {{ policy.use_type }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Chassis</span><span class="font-mono text-xs">{{ policy.chassis_number }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Engine</span><span class="font-mono text-xs">{{ policy.engine_number }}</span></div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Issued policy -->
|
||||
<UCard v-if="policy.policy_number">
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-check-badge" class="w-4 h-4 text-green-500" /> Policy
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">Policy #</span><span class="font-mono font-medium text-green-600">{{ policy.policy_number }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Premium</span><span class="font-semibold">${{ policy.premium }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Effective</span><span>{{ formatDate(policy.effective_date) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Expires</span><span>{{ formatDate(policy.expiry_date) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Issued</span><span>{{ formatDateTime(policy.issued_at) }}</span></div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Providers -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4" /> Providers
|
||||
<UBadge color="gray" variant="soft" size="xs">{{ policy.selected_providers?.length ?? 0 }}</UBadge>
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="pid in policy.selected_providers" :key="pid"
|
||||
class="flex justify-between items-center text-sm p-2 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<span class="font-mono text-xs text-gray-600">{{ pid }}</span>
|
||||
<UBadge :color="policy.quotes?.[pid] ? 'green' : 'yellow'" variant="soft" size="xs">
|
||||
{{ policy.quotes?.[pid] ? 'Quote received' : 'Pending' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Quote comparison + accept -->
|
||||
<UCard v-if="quotes.length > 0">
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-table-cells" class="w-4 h-4" /> Quote Comparison
|
||||
<UBadge color="gray" variant="soft" size="xs">{{ allPlans.length }} plans</UBadge>
|
||||
</p>
|
||||
<UBadge v-if="policy.accepted_plan_id" color="green" variant="soft">Plan Accepted</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="text-left py-3 px-4 text-gray-500 font-medium w-36">Feature</th>
|
||||
<th
|
||||
v-for="plan in allPlans" :key="plan.plan_id"
|
||||
class="py-3 px-4 text-center min-w-44"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<UBadge v-if="plan.plan_id === policy.accepted_plan_id" color="green" variant="soft" size="xs">
|
||||
Selected
|
||||
</UBadge>
|
||||
<p class="font-semibold text-slate-800">{{ plan.name }}</p>
|
||||
<p class="text-xs font-mono text-gray-400">{{ plan.provider_id?.slice(0, 8) }}...</p>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b bg-gray-50">
|
||||
<td class="py-3 px-4 font-medium text-gray-600">Premium</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-3 px-4 text-center"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
<span class="font-bold text-lg text-slate-900">${{ Number(plan.premium).toLocaleString() }}</span>
|
||||
<span class="text-xs text-gray-400 block">/year</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="py-3 px-4 font-medium text-gray-600">Deductible</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-3 px-4 text-center"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
<span v-if="plan.deductible">${{ Number(plan.deductible).toLocaleString() }}</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b bg-gray-50">
|
||||
<td class="py-3 px-4 font-medium text-gray-600">Coverage Limit</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-3 px-4 text-center"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
<span v-if="plan.coverage_limit">${{ Number(plan.coverage_limit).toLocaleString() }}</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="py-3 px-4 font-medium text-gray-600">Valid Until</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-3 px-4 text-center"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
{{ formatDate(plan.valid_until) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b bg-gray-50">
|
||||
<td class="py-3 px-4 font-medium text-gray-600 align-top pt-4">Coverage</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id"
|
||||
class="py-3 px-4 text-center align-top pt-4"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
<p class="text-xs text-gray-600 leading-relaxed">{{ plan.coverage_details }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Accept row -->
|
||||
<tr v-if="canAccept">
|
||||
<td class="py-4 px-4" />
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-4 px-4 text-center">
|
||||
<UButton
|
||||
color="primary" size="sm" icon="i-heroicons-check"
|
||||
@click="openAccept({ quote_id: plan.quote_id, provider_id: plan.provider_id, valid_until: plan.valid_until }, plan)"
|
||||
>
|
||||
Accept
|
||||
</UButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Solicitation PDF -->
|
||||
<UCard v-if="policy.solicitation_id">
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-document-text" class="w-4 h-4" /> Solicitation Document
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge color="purple" variant="soft" size="xs">{{ policy.solicitation_id?.slice(0, 8) }}...</UBadge>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path" color="gray" variant="ghost" size="xs"
|
||||
:loading="loadingPdf" @click="loadSolicitationUrl()"
|
||||
>
|
||||
Refresh URL
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="solicitationUrl"
|
||||
icon="i-heroicons-arrow-top-right-on-square"
|
||||
color="gray" variant="soft" size="xs"
|
||||
:to="solicitationUrl" target="_blank"
|
||||
>
|
||||
Open
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loadingPdf" class="h-64 flex items-center justify-center text-gray-400">
|
||||
<UIcon name="i-heroicons-document-arrow-down" class="w-8 h-8 animate-pulse" />
|
||||
</div>
|
||||
<UAlert v-else-if="pdfError" color="red" variant="soft" :description="pdfError" />
|
||||
<iframe
|
||||
v-else-if="solicitationUrl"
|
||||
:src="solicitationUrl"
|
||||
class="w-full rounded-lg border"
|
||||
style="height: 600px;"
|
||||
/>
|
||||
<div v-else class="h-32 flex items-center justify-center text-gray-400 text-sm">
|
||||
Solicitation not yet generated
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Accept Slideover -->
|
||||
<USlideover v-model:open="isAcceptOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Accept Plan</h2>
|
||||
<p v-if="selectedPlan" class="text-sm text-gray-500">
|
||||
{{ selectedPlan.name }} — ${{ Number(selectedPlan.premium).toLocaleString() }}/yr
|
||||
</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isAcceptOpen = false" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<!-- Plan summary -->
|
||||
<div v-if="selectedPlan" class="bg-primary-50 border border-primary-200 rounded-lg p-4 text-sm space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-primary-600">Plan</span>
|
||||
<span class="font-semibold">{{ selectedPlan.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-primary-600">Premium</span>
|
||||
<span class="font-bold text-primary-900">${{ Number(selectedPlan.premium).toLocaleString() }}/yr</span>
|
||||
</div>
|
||||
<div v-if="selectedPlan.deductible" class="flex justify-between">
|
||||
<span class="text-primary-600">Deductible</span>
|
||||
<span>${{ Number(selectedPlan.deductible).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="selectedPlan.coverage_limit" class="flex justify-between">
|
||||
<span class="text-primary-600">Coverage Limit</span>
|
||||
<span>${{ Number(selectedPlan.coverage_limit).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-primary-600">Provider</span>
|
||||
<span class="font-mono text-xs">{{ selectedQuote?.provider_id?.slice(0, 12) }}...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional solicitation fields -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-medium text-sm text-slate-700">Additional Fields</p>
|
||||
<UButton
|
||||
icon="i-heroicons-plus" color="gray" variant="soft" size="xs"
|
||||
@click="solicitationFields[`field_${Object.keys(solicitationFields).length + 1}`] = ''"
|
||||
>
|
||||
Add Field
|
||||
</UButton>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
Optional provider-specific fields for the solicitation form.
|
||||
</p>
|
||||
<div v-for="(val, key) in solicitationFields" :key="key" class="flex gap-2 items-end">
|
||||
<UFormField :label="String(key)" class="flex-1">
|
||||
<UInput v-model="solicitationFields[key]" class="w-full" />
|
||||
</UFormField>
|
||||
<UButton icon="i-heroicons-trash" color="red" variant="ghost" size="sm"
|
||||
@click="delete solicitationFields[key]" />
|
||||
</div>
|
||||
<p v-if="Object.keys(solicitationFields).length === 0" class="text-xs text-gray-400 italic">
|
||||
No additional fields — the PDF will be filled from policy data automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t flex justify-end gap-3">
|
||||
<UButton color="gray" variant="soft" @click="isAcceptOpen = false">Cancel</UButton>
|
||||
<UButton
|
||||
color="primary" icon="i-heroicons-check"
|
||||
:loading="accepting"
|
||||
@click="submitAccept"
|
||||
>
|
||||
Confirm & Generate Solicitation
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
202
app/pages/policies/index.vue
Normal file
202
app/pages/policies/index.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import type { SelectItem } from '@nuxt/ui'
|
||||
|
||||
const page = ref(1)
|
||||
const search = ref('')
|
||||
const statusFilter = ref<string | null>(null)
|
||||
const debouncedSearch = refDebounced(search, 300)
|
||||
|
||||
const statusItems = ref<SelectItem[]>([
|
||||
{ label: 'All Statuses', value: null },
|
||||
{ label: 'Quote Requested', value: 'quote_requested' },
|
||||
{ label: 'Quotes Received', value: 'quotes_received' },
|
||||
{ label: 'Solicitation Sent', value: 'solicitation_sent' },
|
||||
{ label: 'Active', value: 'active' }
|
||||
])
|
||||
|
||||
watch(debouncedSearch, () => { page.value = 1 })
|
||||
watch(statusFilter, () => { page.value = 1 })
|
||||
|
||||
const { data, error, pending, refresh } = usePolicy('/car-policies', {
|
||||
query: computed(() => ({
|
||||
page_size: 20,
|
||||
page: page.value,
|
||||
...(statusFilter.value && {
|
||||
'filters[0][field]': 'status',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': statusFilter.value
|
||||
}),
|
||||
...(debouncedSearch.value && {
|
||||
[`filters[${statusFilter.value ? 1 : 0}][field]`]: 'search',
|
||||
[`filters[${statusFilter.value ? 1 : 0}][op]`]: '==',
|
||||
[`filters[${statusFilter.value ? 1 : 0}][value]`]: debouncedSearch.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
const policies = computed(() => data.value?.data ?? [])
|
||||
const meta = computed(() => data.value?.meta)
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'quote_requested': return 'yellow'
|
||||
case 'quotes_received': return 'blue'
|
||||
case 'solicitation_sent': return 'purple'
|
||||
case 'active': return 'green'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'quote_requested': return 'Quote Requested'
|
||||
case 'quotes_received': return 'Quotes Received'
|
||||
case 'solicitation_sent': return 'Solicitation Sent'
|
||||
case 'active': return 'Active'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const statusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'quote_requested': return 'i-heroicons-clock'
|
||||
case 'quotes_received': return 'i-heroicons-inbox'
|
||||
case 'solicitation_sent': return 'i-heroicons-paper-airplane'
|
||||
case 'active': return 'i-heroicons-check-badge'
|
||||
default: return 'i-heroicons-document'
|
||||
}
|
||||
}
|
||||
|
||||
const clientTypeLabel = (ct: string) =>
|
||||
ct === 'juridico' ? 'Jurídico' : 'Natural'
|
||||
|
||||
const clientTypeColor = (ct: string) =>
|
||||
ct === 'juridico' ? 'purple' : 'blue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl text-slate-900 font-bold">Policies</h1>
|
||||
<p class="text-gray-500 text-sm">Car Insurance Policy Management</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge color="gray" variant="soft" size="lg">
|
||||
{{ meta?.total_count ?? 0 }} policies
|
||||
</UBadge>
|
||||
<NuxtLink to="/policies/new">
|
||||
<UButton icon="i-heroicons-plus" color="primary">New Policy</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex gap-4 items-center flex-wrap">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, plate, policy #..."
|
||||
class="w-80"
|
||||
/>
|
||||
<USelect v-model="statusFilter" :items="statusItems" class="w-52" />
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="gray" variant="soft"
|
||||
:loading="pending"
|
||||
@click="refresh()"
|
||||
>
|
||||
Refresh
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UAlert v-if="error" color="red" variant="soft" title="Failed to load policies" :description="error.message" />
|
||||
|
||||
<div v-else-if="pending" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<UCard v-for="n in 6" :key="n">
|
||||
<div class="h-48 animate-pulse bg-gray-200 rounded" />
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="policy in policies"
|
||||
:key="policy.application_id"
|
||||
:to="`/policies/${policy.application_id}`"
|
||||
>
|
||||
<UCard class="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<div class="space-y-4">
|
||||
<!-- Status + type badges -->
|
||||
<div class="flex justify-between items-start">
|
||||
<UBadge :color="statusColor(policy.status)" variant="soft" class="flex items-center gap-1">
|
||||
<UIcon :name="statusIcon(policy.status)" class="w-3 h-3" />
|
||||
{{ statusLabel(policy.status) }}
|
||||
</UBadge>
|
||||
<div class="flex gap-1">
|
||||
<UBadge :color="clientTypeColor(policy.client_type)" variant="outline" size="xs">
|
||||
{{ clientTypeLabel(policy.client_type) }}
|
||||
</UBadge>
|
||||
<UBadge color="gray" variant="outline" size="xs">CAR</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Applicant -->
|
||||
<div>
|
||||
<p class="font-semibold text-slate-900 text-lg leading-tight">
|
||||
{{ policy.applicant_display_name }}
|
||||
</p>
|
||||
<p class="text-gray-400 text-sm">{{ policy.applicant_document }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle -->
|
||||
<div class="bg-gray-50 rounded-lg p-3 space-y-1.5 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Plate</span>
|
||||
<span class="font-medium font-mono">{{ policy.plate }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Vehicle</span>
|
||||
<span class="font-medium">{{ policy.year }} {{ policy.make }} {{ policy.model }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Value</span>
|
||||
<span class="font-medium">${{ Number(policy.car_value).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="policy.policy_number" class="flex justify-between">
|
||||
<span class="text-gray-500">Policy #</span>
|
||||
<span class="font-medium font-mono text-green-600">{{ policy.policy_number }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-between items-center text-xs text-gray-400 pt-1 border-t">
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon name="i-heroicons-chat-bubble-left-right" class="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{{ Object.keys(policy.quotes ?? {}).length }} /
|
||||
{{ (policy.selected_providers ?? []).length }} quotes
|
||||
</span>
|
||||
</div>
|
||||
<span>{{ new Date(policy.submitted_at).toLocaleDateString('es-PA') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="policies.length === 0" class="col-span-3 text-center py-16 text-gray-400">
|
||||
<UIcon name="i-heroicons-document-text" class="w-12 h-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No policies found</p>
|
||||
<p class="text-sm">Create a new policy or adjust your filters</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="meta && meta.total_pages > 1" class="flex justify-center">
|
||||
<UPagination v-model="page" :total="meta.total_count" :page-count="meta.page_size" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
498
app/pages/policies/new.vue
Normal file
498
app/pages/policies/new.vue
Normal file
@@ -0,0 +1,498 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItem } from '@nuxt/ui'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
|
||||
const router = useRouter()
|
||||
const policyType = ref<'car' | 'life' | 'fire'>('car')
|
||||
const submitting = ref(false)
|
||||
const toast = useToast()
|
||||
const { $policy } = useNuxtApp()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Customer selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const customerSearch = ref('')
|
||||
const debouncedCustomerSearch = refDebounced(customerSearch, 300)
|
||||
const customerPage = ref(1)
|
||||
const selectedCustomer = ref<any>(null)
|
||||
|
||||
const { data: customersData, pending: customersPending } = useCustomer('/customers', {
|
||||
query: computed(() => ({
|
||||
'page_size': 12,
|
||||
'page': customerPage.value,
|
||||
...(debouncedCustomerSearch.value && {
|
||||
'filters[0][field]': 'search',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': debouncedCustomerSearch.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
watch(debouncedCustomerSearch, () => { customerPage.value = 1 })
|
||||
|
||||
const customerItems = computed(() => customersData.value?.data ?? [])
|
||||
const customerMeta = computed(() => customersData.value?.meta)
|
||||
|
||||
function selectCustomer(customer: any) {
|
||||
selectedCustomer.value = customer
|
||||
}
|
||||
|
||||
const customerDisplayName = (c: any) =>
|
||||
c.customer_type === 'corporate'
|
||||
? (c.commercial_name || c.legal_name)
|
||||
: `${c.first_name} ${c.last_name}`
|
||||
|
||||
const customerSubtitle = (c: any) =>
|
||||
c.customer_type === 'corporate' ? c.ruc : c.email
|
||||
|
||||
// Build applicant_info from selected customer — shape varies by type
|
||||
const applicantInfo = computed(() => {
|
||||
const c = selectedCustomer.value
|
||||
if (!c) return null
|
||||
if (c.customer_type === 'corporate') {
|
||||
return {
|
||||
company_name: c.legal_name,
|
||||
ruc: c.ruc,
|
||||
legal_rep_name: c.legal_rep_name,
|
||||
legal_rep_document: c.legal_rep_document_id
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: `${c.first_name} ${c.last_name}`.trim(),
|
||||
date_of_birth: c.birth_date,
|
||||
document_id: c.document_id
|
||||
}
|
||||
})
|
||||
|
||||
const isApplicantValid = computed(() => {
|
||||
const c = selectedCustomer.value
|
||||
if (!c) return false
|
||||
if (c.customer_type === 'corporate') {
|
||||
return !!(c.legal_name && c.ruc)
|
||||
}
|
||||
return !!(c.birth_date && c.document_id)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const policyTypeItems = ref<SelectItem[]>([
|
||||
{ label: 'Car Insurance', value: 'car' },
|
||||
{ label: 'Life Insurance', value: 'life', disabled: true },
|
||||
{ label: 'Fire Insurance', value: 'fire', disabled: true }
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Car form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const carForm = ref({
|
||||
car_details: {
|
||||
plate: '',
|
||||
make: '',
|
||||
model: '',
|
||||
year: new Date().getFullYear(),
|
||||
car_value: '',
|
||||
use_type: 'private',
|
||||
car_type: 'sedan',
|
||||
chassis_number: '',
|
||||
engine_number: ''
|
||||
},
|
||||
selected_providers: [] as { id: string, email: string }[]
|
||||
})
|
||||
|
||||
const useTypeItems = ref<SelectItem[]>([
|
||||
{ label: 'Private', value: 'private' },
|
||||
{ label: 'Commercial', value: 'commercial' },
|
||||
{ label: 'Bus', value: 'bus' },
|
||||
{ label: 'Taxi', value: 'taxi' },
|
||||
{ label: 'School', value: 'school' }
|
||||
])
|
||||
|
||||
const carTypeItems = ref<SelectItem[]>([
|
||||
{ label: 'Sedan', value: 'sedan' },
|
||||
{ label: 'SUV', value: 'suv' },
|
||||
{ label: 'Hatchback', value: 'hatchback' },
|
||||
{ label: 'Coupe', value: 'coupe' },
|
||||
{ label: 'Convertible', value: 'convertible' },
|
||||
{ label: 'Pickup', value: 'pickup' },
|
||||
{ label: 'Van', value: 'van' },
|
||||
{ label: 'Minivan', value: 'minivan' },
|
||||
{ label: 'Truck', value: 'truck' }
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const providerSearch = ref('')
|
||||
const debouncedProviderSearch = refDebounced(providerSearch, 300)
|
||||
|
||||
const { data: providersData, pending: providersPending } = useProviders('/providers', {
|
||||
query: computed(() => ({
|
||||
...(debouncedProviderSearch.value && {
|
||||
'filters[0][field]': 'search',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': debouncedProviderSearch.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
const providerItems = computed(() => providersData.value?.data ?? [])
|
||||
|
||||
function toggleProvider(provider: any) {
|
||||
const idx = carForm.value.selected_providers.findIndex(p => p.id === provider.provider_id)
|
||||
if (idx >= 0) {
|
||||
carForm.value.selected_providers.splice(idx, 1)
|
||||
} else {
|
||||
carForm.value.selected_providers.push({ id: provider.provider_id, email: provider.email })
|
||||
}
|
||||
}
|
||||
|
||||
function isProviderSelected(provider: any) {
|
||||
return carForm.value.selected_providers.some(p => p.id === provider.provider_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function submitCarPolicy() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = await $policy('/car-policies', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
applicant_info: applicantInfo.value,
|
||||
car_details: carForm.value.car_details,
|
||||
selected_providers: carForm.value.selected_providers
|
||||
}
|
||||
}) as any
|
||||
|
||||
toast.add({ title: 'Policy submitted successfully', color: 'green' })
|
||||
router.push(`/policies/${data.application_id}`)
|
||||
} catch (e: any) {
|
||||
toast.add({
|
||||
title: 'Failed to submit policy',
|
||||
description: e?.data?.errors ? JSON.stringify(e.data.errors) : e.message,
|
||||
color: 'red'
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isCarFormValid = computed(() => {
|
||||
const { car_details, selected_providers } = carForm.value
|
||||
return (
|
||||
isApplicantValid.value &&
|
||||
car_details.plate &&
|
||||
car_details.make &&
|
||||
car_details.model &&
|
||||
car_details.year &&
|
||||
car_details.car_value &&
|
||||
car_details.chassis_number &&
|
||||
car_details.engine_number &&
|
||||
selected_providers.length > 0
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink to="/policies">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">
|
||||
Back to Policies
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<div>
|
||||
<h1 class="text-3xl text-slate-900 font-bold">New Policy</h1>
|
||||
<p class="text-gray-500 text-sm">Submit a new insurance policy quote request</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Selection -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-users" class="w-4 h-4" />
|
||||
Select Customer
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UInput
|
||||
v-model="customerSearch"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
class="w-full max-w-sm"
|
||||
/>
|
||||
|
||||
<div v-if="customersPending" class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="n in 3" :key="n" class="h-16 animate-pulse bg-gray-100 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-72 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="c in customerItems"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="selectedCustomer?.id === c.id
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'"
|
||||
@click="selectCustomer(c)"
|
||||
>
|
||||
<UAvatar :alt="customerDisplayName(c)" size="sm" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="font-medium text-sm text-slate-800 truncate">{{ customerDisplayName(c) }}</p>
|
||||
<UBadge
|
||||
:color="c.customer_type === 'corporate' ? 'purple' : 'blue'"
|
||||
variant="soft" size="xs" class="flex-shrink-0"
|
||||
>
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
|
||||
</div>
|
||||
<UIcon
|
||||
v-if="selectedCustomer?.id === c.id"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-5 h-5 text-primary-500 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="customerItems.length === 0" class="col-span-3 text-center py-6 text-gray-400 text-sm">
|
||||
No customers found.
|
||||
<NuxtLink to="/customers/new" class="text-primary-500 underline ml-1">Create one</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customerMeta && customerMeta.total_pages > 1" class="flex justify-between items-center text-sm text-gray-500">
|
||||
<span>{{ customerMeta.total_count }} customers</span>
|
||||
<UPagination
|
||||
v-model="customerPage"
|
||||
:total="customerMeta.total_count"
|
||||
:page-count="customerMeta.page_size"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected summary -->
|
||||
<div
|
||||
v-if="selectedCustomer"
|
||||
class="flex items-center gap-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm"
|
||||
>
|
||||
<UAvatar :alt="customerDisplayName(selectedCustomer)" size="sm" />
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium text-primary-800">{{ customerDisplayName(selectedCustomer) }}</p>
|
||||
<UBadge
|
||||
:color="selectedCustomer.customer_type === 'corporate' ? 'purple' : 'blue'"
|
||||
variant="soft" size="xs"
|
||||
>
|
||||
{{ selectedCustomer.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-primary-600 text-xs">{{ selectedCustomer.email }}</p>
|
||||
</div>
|
||||
<div class="text-xs text-primary-600 text-right space-y-0.5">
|
||||
<template v-if="selectedCustomer.customer_type === 'corporate'">
|
||||
<p>RUC: {{ selectedCustomer.ruc ?? '—' }}</p>
|
||||
<p>Rep: {{ selectedCustomer.legal_rep_name ?? '—' }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>DOB: {{ selectedCustomer.birth_date ?? '—' }}</p>
|
||||
<p>Doc: {{ selectedCustomer.document_id ?? '—' }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<UButton size="xs" color="gray" variant="ghost" @click="selectedCustomer = null">Change</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Warn if individual customer missing required fields -->
|
||||
<UAlert
|
||||
v-if="selectedCustomer && selectedCustomer.customer_type !== 'corporate' && (!selectedCustomer.birth_date || !selectedCustomer.document_id)"
|
||||
color="yellow" variant="soft" icon="i-heroicons-exclamation-triangle"
|
||||
title="Incomplete customer record"
|
||||
description="This customer is missing date of birth or document ID. Please update their record before submitting."
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Policy Type -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700">Policy Type</p>
|
||||
</template>
|
||||
<div class="flex gap-4">
|
||||
<div
|
||||
v-for="item in policyTypeItems"
|
||||
:key="item.value"
|
||||
class="flex-1 border-2 rounded-xl p-4 text-center transition-all"
|
||||
:class="[
|
||||
item.disabled
|
||||
? 'border-gray-100 bg-gray-50 opacity-40 cursor-not-allowed'
|
||||
: policyType === item.value
|
||||
? 'border-primary-500 bg-primary-50 cursor-pointer'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 cursor-pointer'
|
||||
]"
|
||||
@click="!item.disabled && (policyType = item.value as any)"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.value === 'car' ? 'i-heroicons-truck' : item.value === 'life' ? 'i-heroicons-heart' : 'i-heroicons-home'"
|
||||
class="w-8 h-8 mx-auto mb-2"
|
||||
:class="policyType === item.value ? 'text-primary-500' : 'text-gray-400'"
|
||||
/>
|
||||
<p class="font-medium text-sm" :class="policyType === item.value ? 'text-primary-700' : 'text-gray-600'">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p v-if="item.disabled" class="text-xs text-gray-400 mt-1">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Car Form -->
|
||||
<template v-if="policyType === 'car'">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-truck" class="w-4 h-4" />
|
||||
Vehicle Details
|
||||
</p>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<UFormField label="Plate" required>
|
||||
<UInput v-model="carForm.car_details.plate" placeholder="ABC-1234" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Make" required>
|
||||
<UInput v-model="carForm.car_details.make" placeholder="Toyota" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Model" required>
|
||||
<UInput v-model="carForm.car_details.model" placeholder="Corolla" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Year" required>
|
||||
<UInput
|
||||
v-model.number="carForm.car_details.year"
|
||||
type="number"
|
||||
:min="1886"
|
||||
:max="new Date().getFullYear() + 1"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Car Value (USD)" required>
|
||||
<UInput v-model="carForm.car_details.car_value" type="number" placeholder="18000" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Use Type" required>
|
||||
<USelect v-model="carForm.car_details.use_type" :items="useTypeItems" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Car Type" required>
|
||||
<USelect v-model="carForm.car_details.car_type" :items="carTypeItems" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Chassis Number" required>
|
||||
<UInput v-model="carForm.car_details.chassis_number" placeholder="9BWZZZ377VT004251" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Engine Number" required>
|
||||
<UInput v-model="carForm.car_details.engine_number" placeholder="1NZ-FE-1234567" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Provider Selection -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4" />
|
||||
Selected Providers
|
||||
<UBadge color="gray" variant="soft" size="xs">
|
||||
{{ carForm.selected_providers.length }} selected
|
||||
</UBadge>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UInput
|
||||
v-model="providerSearch"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search providers..."
|
||||
class="w-full max-w-sm"
|
||||
/>
|
||||
|
||||
<div v-if="providersPending" class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="n in 3" :key="n" class="h-16 animate-pulse bg-gray-100 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-72 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="p in providerItems"
|
||||
:key="p.provider_id"
|
||||
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="isProviderSelected(p)
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'"
|
||||
@click="toggleProvider(p)"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-sm text-slate-800 truncate">{{ p.name }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ p.email }}</p>
|
||||
</div>
|
||||
<UIcon
|
||||
v-if="isProviderSelected(p)"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-5 h-5 text-primary-500 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="providerItems.length === 0" class="col-span-3 text-center py-6 text-gray-400 text-sm">
|
||||
No providers found.
|
||||
<NuxtLink to="/providers/new" class="text-primary-500 underline ml-1">Create one</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="carForm.selected_providers.length > 0" class="flex flex-wrap gap-2 pt-2 border-t">
|
||||
<div
|
||||
v-for="p in carForm.selected_providers"
|
||||
:key="p.id"
|
||||
class="flex items-center gap-1.5 px-3 py-1 bg-primary-50 border border-primary-200 rounded-full text-sm"
|
||||
>
|
||||
<UIcon name="i-heroicons-building-office" class="w-3.5 h-3.5 text-primary-500" />
|
||||
<span class="text-primary-800 font-medium">
|
||||
{{ providerItems.find(x => x.provider_id === p.id)?.name ?? p.id }}
|
||||
</span>
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
size="xs" color="gray" variant="ghost"
|
||||
class="w-4 h-4 p-0"
|
||||
@click="carForm.selected_providers = carForm.selected_providers.filter(x => x.id !== p.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<NuxtLink to="/policies">
|
||||
<UButton color="gray" variant="soft">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-heroicons-paper-airplane"
|
||||
:loading="submitting"
|
||||
:disabled="!isCarFormValid"
|
||||
@click="submitCarPolicy"
|
||||
>
|
||||
Submit Quote Request
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
301
app/pages/providers/[provider_id].vue
Normal file
301
app/pages/providers/[provider_id].vue
Normal file
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const providerId = route.params.provider_id as string
|
||||
const toast = useToast()
|
||||
const { $providers } = useNuxtApp()
|
||||
|
||||
const { data, pending, error, refresh } = useProviders(`/providers/${providerId}`)
|
||||
const provider = computed(() => data.value?.data)
|
||||
|
||||
// templates and default_templates come directly from provider
|
||||
const templates = computed(() => provider.value?.templates ?? {})
|
||||
const defaultTemplates = computed(() => provider.value?.default_templates ?? {})
|
||||
|
||||
// ── Template upload ──────────────────────────────────────────────────────────
|
||||
const isUploadOpen = ref(false)
|
||||
const uploadFile = ref<File | null>(null)
|
||||
const uploadPolicyType = ref('car')
|
||||
const uploadClientType = ref('natural')
|
||||
const uploading = ref(false)
|
||||
|
||||
async function handleUpload() {
|
||||
if (!uploadFile.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.value)
|
||||
formData.append('policy_type', uploadPolicyType.value)
|
||||
formData.append('client_type', uploadClientType.value)
|
||||
|
||||
await $providers(`/providers/${providerId}/templates`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
toast.add({ title: 'Template uploaded', color: 'green' })
|
||||
isUploadOpen.value = false
|
||||
uploadFile.value = null
|
||||
await refresh() // single refresh — gets updated templates + defaults together
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Upload failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
uploadFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function setDefault(templateId: string, policyType: string, clientType: string) {
|
||||
try {
|
||||
await $providers(`/providers/${providerId}/templates/${templateId}/set-default`, {
|
||||
method: 'POST',
|
||||
body: { policy_type: policyType, client_type: clientType }
|
||||
})
|
||||
toast.add({ title: 'Default template updated', color: 'green' })
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTemplate(templateId: string, active: boolean, policyType: string, clientType: string) {
|
||||
const path = active ? 'deactivate' : 'activate'
|
||||
try {
|
||||
await $providers(`/providers/${providerId}/templates/${templateId}/${path}`, {
|
||||
method: 'POST',
|
||||
body: { policy_type: policyType, client_type: clientType }
|
||||
})
|
||||
toast.add({ title: `Template ${active ? 'deactivated' : 'activated'}`, color: 'green' })
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleProvider() {
|
||||
const path = provider.value?.active ? 'deactivate' : 'reactivate'
|
||||
try {
|
||||
await $providers(`/providers/${providerId}/${path}`, { method: 'POST' })
|
||||
toast.add({ title: `Provider ${provider.value?.active ? 'deactivated' : 'reactivated'}`, color: 'green' })
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
}
|
||||
}
|
||||
|
||||
const policyTypeItems = [
|
||||
{ label: 'Car', value: 'car' },
|
||||
{ label: 'Life', value: 'life' },
|
||||
{ label: 'Fire', value: 'fire' }
|
||||
]
|
||||
|
||||
const clientTypeItems = [
|
||||
{ label: 'Natural', value: 'natural' },
|
||||
{ label: 'Jurídico', value: 'juridico' }
|
||||
]
|
||||
|
||||
const clientTypeLabel = (ct: string) =>
|
||||
ct === 'natural' ? 'Natural' : ct === 'juridico' ? 'Jurídico' : ct
|
||||
|
||||
const clientTypeColor = (ct: string) =>
|
||||
ct === 'natural' ? 'blue' : 'purple'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<NuxtLink to="/providers">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back to Providers</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<UAlert v-if="error" color="red" variant="soft" title="Failed to load provider" :description="error.message" />
|
||||
|
||||
<div v-else-if="pending" class="space-y-4">
|
||||
<UCard v-for="n in 2" :key="n"><div class="h-32 animate-pulse bg-gray-200 rounded" /></UCard>
|
||||
</div>
|
||||
|
||||
<template v-else-if="provider">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :color="provider.active ? 'green' : 'gray'" variant="soft">
|
||||
{{ provider.active ? 'Active' : 'Inactive' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ provider.name }}</h1>
|
||||
<p class="text-gray-500 text-sm font-mono">{{ provider.provider_id }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton icon="i-heroicons-arrow-path" color="gray" variant="soft" :loading="pending" @click="refresh()" />
|
||||
<UButton
|
||||
:icon="provider.active ? 'i-heroicons-pause' : 'i-heroicons-play'"
|
||||
:color="provider.active ? 'red' : 'green'"
|
||||
variant="soft"
|
||||
@click="toggleProvider"
|
||||
>
|
||||
{{ provider.active ? 'Deactivate' : 'Reactivate' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4" /> Provider Details
|
||||
</p>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div><p class="text-gray-500 text-xs">Email</p><p class="font-medium">{{ provider.email }}</p></div>
|
||||
<div><p class="text-gray-500 text-xs">Phone</p><p>{{ provider.phone ?? '—' }}</p></div>
|
||||
<div><p class="text-gray-500 text-xs">Contact</p><p>{{ provider.contact_name ?? '—' }}</p></div>
|
||||
<div><p class="text-gray-500 text-xs">RUC</p><p class="font-mono">{{ provider.ruc ?? '—' }}</p></div>
|
||||
<div class="col-span-2"><p class="text-gray-500 text-xs">Address</p><p>{{ provider.address ?? '—' }}</p></div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Templates — grouped by policy_type → client_type -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-document" class="w-4 h-4" /> Solicitation Templates
|
||||
</p>
|
||||
<UButton icon="i-heroicons-arrow-up-tray" color="primary" size="sm" @click="isUploadOpen = true">
|
||||
Upload Template
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(templates).length === 0" class="text-center py-10 text-gray-400">
|
||||
<UIcon name="i-heroicons-document-plus" class="w-10 h-10 mx-auto mb-2" />
|
||||
<p class="text-sm">No templates yet. Upload a provider PDF form.</p>
|
||||
</div>
|
||||
|
||||
<!-- policy_type level -->
|
||||
<div v-else class="space-y-8">
|
||||
<div v-for="(clientMap, policyType) in templates" :key="policyType">
|
||||
<p class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-4">
|
||||
{{ String(policyType) }}
|
||||
</p>
|
||||
|
||||
<!-- client_type level -->
|
||||
<div class="space-y-6 pl-2">
|
||||
<div v-for="(tmplList, clientType) in clientMap" :key="clientType">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<UBadge :color="clientTypeColor(String(clientType))" variant="soft" size="sm">
|
||||
{{ clientTypeLabel(String(clientType)) }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-400">{{ tmplList?.length ?? 0 }} template(s)</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="t in tmplList"
|
||||
:key="t.template_id"
|
||||
class="flex items-center justify-between p-3 border rounded-lg text-sm"
|
||||
:class="t.active ? 'border-gray-200 bg-white' : 'border-gray-100 bg-gray-50 opacity-60'"
|
||||
>
|
||||
<div class="space-y-0.5 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-mono text-xs text-gray-500">{{ t.template_id?.slice(0, 8) }}...</span>
|
||||
<UBadge
|
||||
v-if="defaultTemplates?.[policyType]?.[clientType] === t.template_id"
|
||||
color="blue" variant="soft" size="xs"
|
||||
>
|
||||
Default
|
||||
</UBadge>
|
||||
<UBadge :color="t.active ? 'green' : 'gray'" variant="soft" size="xs">
|
||||
{{ t.active ? 'Active' : 'Inactive' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 font-mono truncate">{{ t.s3_key }}</p>
|
||||
<p class="text-xs text-gray-400">v{{ t.version }} · {{ t.fields?.length ?? 0 }} fields</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<UButton
|
||||
size="xs" color="gray" variant="soft"
|
||||
:disabled="defaultTemplates?.[policyType]?.[clientType] === t.template_id"
|
||||
@click="setDefault(t.template_id, String(policyType), String(clientType))"
|
||||
>
|
||||
Set Default
|
||||
</UButton>
|
||||
<UButton
|
||||
size="xs" :color="t.active ? 'red' : 'green'" variant="ghost"
|
||||
@click="toggleTemplate(t.template_id, t.active, String(policyType), String(clientType))"
|
||||
>
|
||||
{{ t.active ? 'Deactivate' : 'Activate' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Upload Slideover -->
|
||||
<USlideover v-model:open="isUploadOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Upload Template</h2>
|
||||
<p class="text-sm text-gray-500">Upload a fillable PDF solicitation form</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isUploadOpen = false" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-6 space-y-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Policy Type" required>
|
||||
<USelect v-model="uploadPolicyType" :items="policyTypeItems" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Client Type" required>
|
||||
<USelect v-model="uploadClientType" :items="clientTypeItems" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="PDF Template File" required>
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 transition-colors cursor-pointer"
|
||||
@click="($refs.fileInput as HTMLInputElement).click()"
|
||||
>
|
||||
<UIcon name="i-heroicons-document-arrow-up" class="w-10 h-10 mx-auto mb-2 text-gray-400" />
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ uploadFile ? uploadFile.name : 'Click to select a PDF file' }}
|
||||
</p>
|
||||
<p v-if="uploadFile" class="text-xs text-gray-400 mt-1">
|
||||
{{ (uploadFile.size / 1024).toFixed(1) }} KB
|
||||
</p>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept=".pdf" class="hidden" @change="onFileChange" />
|
||||
</UFormField>
|
||||
|
||||
<UAlert
|
||||
color="blue" variant="soft" icon="i-heroicons-information-circle"
|
||||
description="The PDF must be an AcroForm (fillable PDF). Fields will be auto-discovered after upload."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t flex justify-end gap-3">
|
||||
<UButton color="gray" variant="soft" @click="isUploadOpen = false">Cancel</UButton>
|
||||
<UButton
|
||||
color="primary" icon="i-heroicons-arrow-up-tray"
|
||||
:loading="uploading" :disabled="!uploadFile"
|
||||
@click="handleUpload"
|
||||
>
|
||||
Upload
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
75
app/pages/providers/index.vue
Normal file
75
app/pages/providers/index.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
const search = ref('')
|
||||
const { data, pending, refresh } = useProviders('/providers')
|
||||
const providers = computed(() => {
|
||||
const list = data.value?.data ?? []
|
||||
if (!search.value) return list
|
||||
return list.filter((p: any) =>
|
||||
`${p.name} ${p.email} ${p.contact_name}`.toLowerCase().includes(search.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-900">Providers</h1>
|
||||
<p class="text-gray-500 text-sm">Insurance carrier management</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge color="gray" variant="soft" size="lg">{{ providers.length }} providers</UBadge>
|
||||
<NuxtLink to="/providers/new">
|
||||
<UButton icon="i-heroicons-plus" color="primary">New Provider</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<UInput v-model="search" icon="i-heroicons-magnifying-glass" placeholder="Search providers..." class="w-72" />
|
||||
<UButton icon="i-heroicons-arrow-path" color="gray" variant="soft" :loading="pending" @click="refresh()">Refresh</UButton>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<UCard v-for="n in 6" :key="n"><div class="h-32 animate-pulse bg-gray-200 rounded" /></UCard>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink v-for="p in providers" :key="p.provider_id" :to="`/providers/${p.provider_id}`">
|
||||
<UCard class="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<p class="font-semibold text-slate-900 text-lg">{{ p.name }}</p>
|
||||
<UBadge :color="p.active ? 'green' : 'red'" variant="soft" size="xs">
|
||||
{{ p.active ? 'Active' : 'Inactive' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<UIcon name="i-heroicons-envelope" class="w-4 h-4" />
|
||||
<span>{{ p.email }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<UIcon name="i-heroicons-phone" class="w-4 h-4" />
|
||||
<span>{{ p.phone ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<UIcon name="i-heroicons-user" class="w-4 h-4" />
|
||||
<span>{{ p.contact_name ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-2 border-t text-xs text-gray-400">
|
||||
<span>RUC: {{ p.ruc ?? '—' }}</span>
|
||||
<UIcon name="i-heroicons-chevron-right" class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="providers.length === 0" class="col-span-3 text-center py-16 text-gray-400">
|
||||
<UIcon name="i-heroicons-building-office" class="w-12 h-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No providers found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
79
app/pages/providers/new.vue
Normal file
79
app/pages/providers/new.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const toast = useToast()
|
||||
const { $providers } = useNuxtApp()
|
||||
|
||||
const form = ref({
|
||||
name: '', email: '', phone: '', contact_name: '', ruc: '', address: ''
|
||||
})
|
||||
|
||||
const isValid = computed(() => form.value.name && form.value.email)
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = await $providers('/providers', { method: 'POST', body: form.value }) as any
|
||||
toast.add({ title: 'Provider created', color: 'green' })
|
||||
router.push(`/providers/${data.provider_id}`)
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed to create provider', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink to="/providers">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back</UButton>
|
||||
</NuxtLink>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-900">New Provider</h1>
|
||||
<p class="text-gray-500 text-sm">Register a new insurance carrier</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UCard class="max-w-2xl">
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4" /> Provider Information
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Company Name" required class="col-span-2">
|
||||
<UInput v-model="form.name" placeholder="Seguros Panama S.A." class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Email" required>
|
||||
<UInput v-model="form.email" type="email" placeholder="cotizaciones@seguros.com" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Phone">
|
||||
<UInput v-model="form.phone" placeholder="+507 300-0000" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Contact Name">
|
||||
<UInput v-model="form.contact_name" placeholder="María García" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="RUC">
|
||||
<UInput v-model="form.ruc" placeholder="1234567-1-123456" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Address" class="col-span-2">
|
||||
<UInput v-model="form.address" placeholder="Av. Balboa, Panama City" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NuxtLink to="/providers"><UButton color="gray" variant="soft">Cancel</UButton></NuxtLink>
|
||||
<UButton color="primary" icon="i-heroicons-check" :loading="submitting" :disabled="!isValid" @click="submit">
|
||||
Create Provider
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
304
app/pages/tasks/[id].vue
Normal file
304
app/pages/tasks/[id].vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const id = route.params.id as string
|
||||
|
||||
const { data, pending, error, refresh } = useTasks(`/tasks/${id}`)
|
||||
const task = computed(() => data.value.task)
|
||||
|
||||
const payload = computed(() => data.value?.payload)
|
||||
|
||||
const isSolicitation = computed(() => task.value?.comm_type === 'solicitation')
|
||||
const isQuote = computed(() => task.value?.comm_type === 'quote')
|
||||
|
||||
// ── Respond (quote) ──────────────────────────────────────────────────────────
|
||||
const isRespondOpen = ref(false)
|
||||
const submitting = ref(false)
|
||||
const toast = useToast()
|
||||
const { $tasks } = useNuxtApp()
|
||||
|
||||
const plans = ref([{ name: '', premium: '', coverage_details: '', deductible: '', coverage_limit: '' }])
|
||||
const respondForm = ref({ valid_until: '', entered_by: '' })
|
||||
|
||||
function addPlan() { plans.value.push({ name: '', premium: '', coverage_details: '', deductible: '', coverage_limit: '' }) }
|
||||
function removePlan(i: number) { plans.value.splice(i, 1) }
|
||||
|
||||
async function submitResponse() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await $tasks(`/tasks/${id}/respond`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
valid_until: respondForm.value.valid_until,
|
||||
entered_by: respondForm.value.entered_by,
|
||||
plans: plans.value.map(p => ({
|
||||
name: p.name, premium: parseFloat(p.premium), coverage_details: p.coverage_details,
|
||||
deductible: p.deductible ? parseFloat(p.deductible) : 0,
|
||||
coverage_limit: p.coverage_limit ? parseFloat(p.coverage_limit) : 0
|
||||
}))
|
||||
}
|
||||
})
|
||||
toast.add({ title: 'Response submitted', color: 'green' })
|
||||
isRespondOpen.value = false
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const isRespondFormValid = computed(() =>
|
||||
respondForm.value.valid_until && respondForm.value.entered_by &&
|
||||
plans.value.every(p => p.name && p.premium && p.coverage_details)
|
||||
)
|
||||
|
||||
// ── Confirm delivery (solicitation) ─────────────────────────────────────────
|
||||
const confirmingDelivery = ref(false)
|
||||
|
||||
async function confirmDelivery() {
|
||||
confirmingDelivery.value = true
|
||||
try {
|
||||
await $tasks(`/tasks/${id}/confirm-delivery`, { method: 'POST' })
|
||||
toast.add({ title: 'Delivery confirmed', color: 'green' })
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally { confirmingDelivery.value = false }
|
||||
}
|
||||
|
||||
// ── Issue policy (solicitation) ──────────────────────────────────────────────
|
||||
const isIssueOpen = ref(false)
|
||||
const issuing = ref(false)
|
||||
const issueForm = ref({ policy_number: '', effective_date: '', expiry_date: '' })
|
||||
|
||||
const isIssueFormValid = computed(() =>
|
||||
issueForm.value.policy_number && issueForm.value.effective_date && issueForm.value.expiry_date
|
||||
)
|
||||
|
||||
async function submitIssuePolicy() {
|
||||
issuing.value = true
|
||||
try {
|
||||
await $task(`/tasks/${id}/issue-policy`, { method: 'POST', body: issueForm.value })
|
||||
toast.add({ title: 'Policy issued successfully', color: 'green' })
|
||||
isIssueOpen.value = false
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally { issuing.value = false }
|
||||
}
|
||||
|
||||
// ── Solicitation PDF ─────────────────────────────────────────────────────────
|
||||
const pdfUrl = computed(() => task.value?.download_url ?? null)
|
||||
const showPdf = ref(false)
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const statusColor = (s: string) =>
|
||||
({ pending: 'yellow', responded: 'blue', delivered: 'purple', issued: 'green' }[s] ?? 'gray')
|
||||
|
||||
const formatDate = (d: string) => d
|
||||
? new Date(d).toLocaleDateString('es-PA', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '—'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<NuxtLink to="/tasks">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back to Tasks</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<UAlert v-if="error" color="red" variant="soft" title="Failed to load task" :description="error.message" />
|
||||
<div v-else-if="pending" class="space-y-4"><UCard v-for="n in 3" :key="n"><div class="h-32 animate-pulse bg-gray-200 rounded" /></UCard></div>
|
||||
|
||||
<template v-else-if="task">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<UBadge :color="statusColor(task.status)" variant="soft">{{ task.status }}</UBadge>
|
||||
<UBadge color="blue" variant="outline">{{ task.policy_type?.toUpperCase() }}</UBadge>
|
||||
<UBadge color="gray" variant="outline">{{ task.comm_type }}</UBadge>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-slate-900 font-mono">{{ task.application_id }}</h1>
|
||||
<p class="text-gray-500 text-sm">Received {{ formatDate(task.created_at) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 flex-wrap justify-end">
|
||||
<UButton icon="i-heroicons-arrow-path" color="gray" variant="soft" :loading="pending" @click="refresh()" />
|
||||
|
||||
<!-- Quote actions -->
|
||||
<UButton v-if="isQuote && task.status === 'pending'"
|
||||
icon="i-heroicons-chat-bubble-left-right" color="primary" @click="isRespondOpen = true">
|
||||
Record Response
|
||||
</UButton>
|
||||
|
||||
<!-- Solicitation actions -->
|
||||
<template v-if="isSolicitation">
|
||||
<UButton v-if="task.status === 'pending'"
|
||||
icon="i-heroicons-check" color="green" variant="soft"
|
||||
:loading="confirmingDelivery" @click="confirmDelivery">
|
||||
Confirm Delivery
|
||||
</UButton>
|
||||
<UButton v-if="task.status === 'delivered'"
|
||||
icon="i-heroicons-document-check" color="primary" @click="isIssueOpen = true">
|
||||
Issue Policy
|
||||
</UButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Task info -->
|
||||
<UCard>
|
||||
<template #header><p class="font-semibold text-slate-700">Task Info</p></template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">Task ID</span><span class="font-mono text-xs">{{ task.id }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Application ID</span><span class="font-mono text-xs">{{ task.application_id }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Provider ID</span><span class="font-mono text-xs">{{ task.provider_id }}</span></div>
|
||||
<div v-if="task.provider_name" class="flex justify-between"><span class="text-gray-500">Provider</span><span>{{ task.provider_name }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Org</span><span class="font-mono text-xs">{{ task.org_id }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Created</span><span>{{ formatDate(task.created_at) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Updated</span><span>{{ formatDate(task.updated_at) }}</span></div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Payload -->
|
||||
<UCard>
|
||||
<template #header><p class="font-semibold text-slate-700">Request Payload</p></template>
|
||||
<div class="space-y-4 text-sm">
|
||||
<div v-if="payload?.applicant_info">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase mb-2">Applicant</p>
|
||||
<div class="bg-gray-50 rounded-lg p-3 space-y-1.5">
|
||||
<div class="flex justify-between"><span class="text-gray-500">Name</span><span>{{ payload.applicant_info.name }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">DOB</span><span>{{ payload.applicant_info.date_of_birth }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Document</span><span class="font-mono text-xs">{{ payload.applicant_info.document_id }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="payload?.car_details">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase mb-2">Vehicle</p>
|
||||
<div class="bg-gray-50 rounded-lg p-3 space-y-1.5">
|
||||
<div class="flex justify-between"><span class="text-gray-500">Plate</span><span class="font-mono font-medium">{{ payload.car_details.plate }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Vehicle</span><span>{{ payload.car_details.year }} {{ payload.car_details.make }} {{ payload.car_details.model }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Value</span><span>${{ Number(payload.car_details.car_value).toLocaleString() }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Solicitation PDF section -->
|
||||
<UCard v-if="isSolicitation">
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-document-text" class="w-4 h-4" /> Solicitation Document
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<UButton v-if="pdfUrl" icon="i-heroicons-eye" color="gray" variant="soft" size="xs"
|
||||
@click="showPdf = !showPdf">{{ showPdf ? 'Hide' : 'Preview' }}</UButton>
|
||||
<UButton v-if="pdfUrl" icon="i-heroicons-arrow-top-right-on-square"
|
||||
color="gray" variant="soft" size="xs" :to="pdfUrl" target="_blank">Open</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!pdfUrl" class="text-center py-10 text-gray-400">
|
||||
<UIcon name="i-heroicons-document" class="w-10 h-10 mx-auto mb-2" />
|
||||
<p class="text-sm">No solicitation document available</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showPdf">
|
||||
<iframe :src="pdfUrl" class="w-full rounded-lg border" style="height: 600px;" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<UIcon name="i-heroicons-document-text" class="w-8 h-8 text-red-400 flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-slate-800">Solicitation v{{ task.version ?? 1 }}</p>
|
||||
<p class="text-xs text-gray-400 font-mono truncate">{{ task.s3_key }}</p>
|
||||
</div>
|
||||
<UBadge :color="statusColor(task.status)" variant="soft" size="sm">{{ task.status }}</UBadge>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Quote respond slideover -->
|
||||
<USlideover v-model:open="isRespondOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<div><h2 class="text-lg font-semibold text-slate-900">Record Quote Response</h2></div>
|
||||
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isRespondOpen = false" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Valid Until" required>
|
||||
<UInput v-model="respondForm.valid_until" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Entered By" required>
|
||||
<UInput v-model="respondForm.entered_by" placeholder="Your name" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-medium text-sm text-slate-700">Plans <UBadge color="gray" variant="soft" size="xs" class="ml-1">{{ plans.length }}</UBadge></p>
|
||||
<UButton icon="i-heroicons-plus" size="xs" color="gray" variant="soft" @click="addPlan">Add Plan</UButton>
|
||||
</div>
|
||||
<div v-for="(plan, i) in plans" :key="i" class="border rounded-lg p-4 space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="text-sm font-semibold text-slate-700">Plan {{ i + 1 }}</p>
|
||||
<UButton v-if="plans.length > 1" icon="i-heroicons-trash" size="xs" color="red" variant="ghost" @click="removePlan(i)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Name" required><UInput v-model="plan.name" placeholder="Basic / Standard / Premium" class="w-full" /></UFormField>
|
||||
<UFormField label="Premium (USD)" required><UInput v-model="plan.premium" type="number" class="w-full" /></UFormField>
|
||||
<UFormField label="Deductible"><UInput v-model="plan.deductible" type="number" class="w-full" /></UFormField>
|
||||
<UFormField label="Coverage Limit"><UInput v-model="plan.coverage_limit" type="number" class="w-full" /></UFormField>
|
||||
</div>
|
||||
<UFormField label="Coverage Details" required>
|
||||
<UTextarea v-model="plan.coverage_details" :rows="2" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t flex justify-end gap-3">
|
||||
<UButton color="gray" variant="soft" @click="isRespondOpen = false">Cancel</UButton>
|
||||
<UButton color="primary" icon="i-heroicons-paper-airplane" :loading="submitting" :disabled="!isRespondFormValid" @click="submitResponse">
|
||||
Submit Response
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
|
||||
<!-- Issue policy slideover -->
|
||||
<USlideover v-model:open="isIssueOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Issue Policy</h2>
|
||||
<p class="text-sm text-gray-500">Enter the policy details from the provider</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isIssueOpen = false" />
|
||||
</div>
|
||||
<div class="flex-1 p-6 space-y-4">
|
||||
<UFormField label="Policy Number" required>
|
||||
<UInput v-model="issueForm.policy_number" placeholder="POL-2026-001" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Effective Date" required>
|
||||
<UInput v-model="issueForm.effective_date" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Expiry Date" required>
|
||||
<UInput v-model="issueForm.expiry_date" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<div class="p-6 border-t flex justify-end gap-3">
|
||||
<UButton color="gray" variant="soft" @click="isIssueOpen = false">Cancel</UButton>
|
||||
<UButton color="primary" icon="i-heroicons-document-check" :loading="issuing" :disabled="!isIssueFormValid" @click="submitIssuePolicy">
|
||||
Issue Policy
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
176
app/pages/tasks/index.vue
Normal file
176
app/pages/tasks/index.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItem } from '@nuxt/ui'
|
||||
|
||||
const page = ref(1)
|
||||
const statusFilter = ref<string | null>(null)
|
||||
const policyTypeFilter = ref<string | null>(null)
|
||||
const commTypeFilter = ref<string | null>(null)
|
||||
|
||||
const statusItems = ref<SelectItem[]>([
|
||||
{ label: 'All Statuses', value: null },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Responded', value: 'responded' }
|
||||
])
|
||||
|
||||
const policyTypeItems = ref<SelectItem[]>([
|
||||
{ label: 'All Types', value: null },
|
||||
{ label: 'Car', value: 'car' },
|
||||
{ label: 'Life', value: 'life' },
|
||||
{ label: 'Fire', value: 'fire' }
|
||||
])
|
||||
|
||||
const commTypeItems = ref<SelectItem[]>([
|
||||
{ label: 'All Comm Types', value: null },
|
||||
{ label: 'Quote', value: 'quote' },
|
||||
{ label: 'Solicitation', value: 'solicitation' }
|
||||
])
|
||||
|
||||
watch([statusFilter, policyTypeFilter, commTypeFilter], () => { page.value = 1 })
|
||||
|
||||
const { data, pending, error, refresh } = useTasks('/tasks', {
|
||||
query: computed(() => ({
|
||||
page: page.value,
|
||||
limit: 20,
|
||||
...(statusFilter.value && { status: statusFilter.value }),
|
||||
...(policyTypeFilter.value && { policy_type: policyTypeFilter.value }),
|
||||
...(commTypeFilter.value && { comm_type: commTypeFilter.value })
|
||||
}))
|
||||
})
|
||||
|
||||
const tasks = computed(() => data.value?.tasks ?? [])
|
||||
const total = computed(() => data.value?.total ?? 0)
|
||||
const totalPages = computed(() => Math.ceil(total.value / 20))
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'yellow'
|
||||
case 'responded': return 'green'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
const policyTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'car': return 'blue'
|
||||
case 'life': return 'purple'
|
||||
case 'fire': return 'orange'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return '—'
|
||||
return new Date(date).toLocaleDateString('es-PA', {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl text-slate-900 font-bold">Tasks</h1>
|
||||
<p class="text-gray-500 text-sm">Carrier Inbox — Quote & Solicitation Requests</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge color="gray" variant="soft" size="lg">{{ total }} tasks</UBadge>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
:loading="pending"
|
||||
@click="refresh()"
|
||||
>
|
||||
Refresh
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex gap-4 items-center flex-wrap">
|
||||
<USelect v-model="statusFilter" :items="statusItems" class="w-44" />
|
||||
<USelect v-model="policyTypeFilter" :items="policyTypeItems" class="w-44" />
|
||||
<USelect v-model="commTypeFilter" :items="commTypeItems" class="w-44" />
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="red"
|
||||
variant="soft"
|
||||
title="Failed to load tasks"
|
||||
:description="error.message"
|
||||
/>
|
||||
|
||||
<div v-else-if="pending && tasks.length === 0" class="grid gap-4">
|
||||
<UCard v-for="n in 5" :key="n">
|
||||
<div class="h-20 animate-pulse bg-gray-200 rounded" />
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-3" :class="pending ? 'opacity-60 pointer-events-none' : ''">
|
||||
<NuxtLink
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:to="`/tasks/${task.id}`"
|
||||
>
|
||||
<UCard class="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<!-- Left -->
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<div class="flex flex-col gap-1">
|
||||
<UBadge :color="statusColor(task.status)" variant="soft" size="xs">
|
||||
{{ task.status }}
|
||||
</UBadge>
|
||||
<UBadge :color="policyTypeColor(task.policy_type)" variant="outline" size="xs">
|
||||
{{ task.policy_type?.toUpperCase() }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="font-mono text-sm font-medium text-slate-800 truncate">
|
||||
{{ task.application_id }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Provider: <span class="font-mono">{{ task.provider_id }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right -->
|
||||
<div class="flex items-center gap-6 flex-shrink-0 text-sm text-gray-500">
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-gray-400">Comm Type</p>
|
||||
<UBadge color="gray" variant="soft" size="xs">{{ task.comm_type }}</UBadge>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-gray-400">Received</p>
|
||||
<p>{{ formatDate(task.created_at) }}</p>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-chevron-right" class="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="tasks.length === 0 && !pending" class="text-center py-16 text-gray-400">
|
||||
<UIcon name="i-heroicons-inbox" class="w-12 h-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No tasks found</p>
|
||||
<p class="text-sm">Adjust your filters or wait for new requests</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex justify-center">
|
||||
<UPagination
|
||||
v-model="page"
|
||||
:total="total"
|
||||
:page-count="20"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,9 +8,21 @@ export default defineNuxtConfig({
|
||||
openFetch: {
|
||||
clients: {
|
||||
customer: {
|
||||
baseURL: 'http://localhost:4000/api/customers',
|
||||
baseURL: 'http://localhost:4000/api/v1',
|
||||
schema: 'http://localhost:4000/api/openapi'
|
||||
}
|
||||
},
|
||||
policy: {
|
||||
baseURL: 'http://localhost:4001/api/v1',
|
||||
schema: 'http://localhost:4001/api/openapi'
|
||||
},
|
||||
providers: {
|
||||
baseURL: 'http://localhost:4002/api/v1',
|
||||
schema: 'http://localhost:4002/api/openapi'
|
||||
},
|
||||
tasks: {
|
||||
baseURL: 'http://localhost:8080/api/v1',
|
||||
schema: 'http://localhost:8080/openapi3.json'
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
226
package-lock.json
generated
226
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^4.5.0",
|
||||
"jspdf": "^4.2.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-open-fetch": "^0.13.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
@@ -394,6 +395,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -4847,12 +4857,32 @@
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
@@ -5694,6 +5724,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -5959,6 +5999,26 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
@@ -6166,6 +6226,18 @@
|
||||
"iconv-lite": "^0.4.8"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
|
||||
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -6268,6 +6340,16 @@
|
||||
"postcss": "^8.0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
@@ -6640,6 +6722,16 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -7034,6 +7126,17 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -7060,6 +7163,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
@@ -7436,6 +7545,20 @@
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -7596,6 +7719,12 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz",
|
||||
@@ -7919,6 +8048,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
|
||||
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -9385,6 +9531,12 @@
|
||||
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
@@ -9481,6 +9633,13 @@
|
||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -10284,6 +10443,16 @@
|
||||
"integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -10398,6 +10567,13 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regexp-tree": {
|
||||
"version": "0.1.27",
|
||||
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
|
||||
@@ -10495,6 +10671,16 @@
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
@@ -10907,6 +11093,16 @@
|
||||
"node": ">=20.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
@@ -11090,6 +11286,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz",
|
||||
@@ -11267,6 +11473,16 @@
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
@@ -11981,6 +12197,16 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul-vue": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vaul-vue/-/vaul-vue-0.4.1.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^4.5.0",
|
||||
"jspdf": "^4.2.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-open-fetch": "^0.13.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
||||
Reference in New Issue
Block a user