add customer and providers

This commit is contained in:
2026-03-17 14:50:02 -05:00
parent 7e8025700b
commit 5164590bc9
16 changed files with 2958 additions and 301 deletions

View File

@@ -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>
</template>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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
View 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
View 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>