Files
policy-ui/app/pages/customers/index.vue
Jordan Weingarten 67482f6629 WIP jordan
2026-04-16 11:11:44 -05:00

485 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
const { isFavorite, toggleFavorite } = useClientFavorites()
const page = ref(1)
const search = ref('')
const customerTypeFilter = ref<string | null>(null)
const agentFilter = ref<string | null>(null)
const paymentFilter = ref<string | null>(null)
const sortBy = ref<string>('name_asc')
const viewMode = ref<'card' | 'list'>('card')
const debouncedSearch = refDebounced(search, 300)
const customerTypeItems = [
{ label: 'All Types', value: null },
{ label: 'Individual', value: 'individual' },
{ label: 'Corporate', value: 'corporate' }
]
const agentItems = [
{ label: 'All Agents', value: null },
...([...new Set(MOCK_CUSTOMERS.map(c => c.agent))].sort().map(a => ({ label: a, value: a })))
]
const paymentItems = [
{ label: 'All Payments', value: null },
{ label: 'Current', value: 'Current' },
{ label: 'Overdue', value: 'Overdue' },
{ label: 'Grace period', value: 'Grace period' },
{ label: 'N/A', value: 'N/A' },
]
const sortItems = [
{ label: 'Name (AZ)', value: 'name_asc' },
{ label: 'Premium (highlow)', value: 'premium_desc' },
{ label: 'Policies (most)', value: 'policies_desc' },
]
watch([debouncedSearch, customerTypeFilter, agentFilter, paymentFilter], () => { 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[number]': page.value,
'page[size]': 20,
...filters
}
})
})
/* ── Mock fallback rows (shown when API returns nothing) ── */
const allMockRows = MOCK_CUSTOMERS.map((m) => ({
id: m.id,
customer_type: m.type.toLowerCase(),
first_name: m.name.split(' ')[0],
last_name: m.name.split(' ').slice(1).join(' '),
commercial_name: null,
legal_name: null,
ruc: null,
email: m.email,
phone: m.phone,
birth_date: m.birthDate,
gender: m.gender.toLowerCase(),
legal_rep_name: null,
document_id: m.documentId,
_mock: m
}))
const mockRows = computed(() => {
let rows = allMockRows
// Filter by search
const q = debouncedSearch.value.toLowerCase().trim()
if (q) {
rows = rows.filter(r => {
const name = `${r.first_name} ${r.last_name}`.toLowerCase()
return name.includes(q)
|| (r.email ?? '').toLowerCase().includes(q)
|| (r.phone ?? '').includes(q)
|| (r.document_id ?? '').toLowerCase().includes(q)
|| (r._mock.agent ?? '').toLowerCase().includes(q)
|| r._mock.tags.some(t => t.toLowerCase().includes(q))
})
}
// Filter by type
if (customerTypeFilter.value) {
rows = rows.filter(r => r.customer_type === customerTypeFilter.value)
}
// Filter by agent
if (agentFilter.value) {
rows = rows.filter(r => r._mock.agent === agentFilter.value)
}
// Filter by payment status
if (paymentFilter.value) {
rows = rows.filter(r => r._mock.paymentStatus === paymentFilter.value)
}
// Sort
const sorted = [...rows]
switch (sortBy.value) {
case 'name_asc':
sorted.sort((a, b) => `${a.first_name} ${a.last_name}`.localeCompare(`${b.first_name} ${b.last_name}`))
break
case 'premium_desc':
sorted.sort((a, b) => b._mock.policies.reduce((s: number, p: any) => s + p.premium, 0) - a._mock.policies.reduce((s: number, p: any) => s + p.premium, 0))
break
case 'policies_desc':
sorted.sort((a, b) => b._mock.policies.length - a._mock.policies.length)
break
}
return sorted
})
const apiCustomers = computed(() => data.value?.data ?? [])
const customers = computed(() => apiCustomers.value.length > 0 ? apiCustomers.value : mockRows.value)
const meta = computed(() => data.value?.meta)
const usingMock = computed(() => apiCustomers.value.length === 0 && mockRows.value.length > 0)
// display helpers
const customerName = (c: any) => {
if (c.customer_type === 'corporate') return c.commercial_name || c.legal_name || 'Unnamed company'
const full = [c.first_name, c.last_name].filter(Boolean).join(' ')
return full || 'Unnamed customer'
}
const customerSubtitle = (c: any) => {
if (c._mock) {
const m = c._mock
const total = m.policies.reduce((s: number, p: any) => s + p.premium, 0)
return `${m.policies.length} ${m.policies.length === 1 ? 'policy' : 'policies'} · ${fmtMoney(total)}/yr`
}
return c.customer_type === 'corporate' ? c.ruc : c.email
}
const customerTypeColor = (type: string) =>
type === 'corporate' ? 'purple' : 'blue'
</script>
<template>
<div class="ci-root mx-auto max-w-7xl pb-12">
<!-- Header -->
<div class="ci-header">
<div class="ci-header-left">
<h1 class="ci-title">Customers</h1>
<span class="ci-count-badge">{{ usingMock ? customers.length : (meta?.total_count ?? 0) }}</span>
</div>
<div class="ci-header-right">
<NuxtLink to="/customers/new">
<button class="ci-btn-primary">
<UIcon name="i-heroicons-plus" class="w-3.5 h-3.5" />
New Customer
</button>
</NuxtLink>
</div>
</div>
<!-- Filter bar -->
<div class="ci-filter-bar">
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="Search by name, email, RUC, agent..."
class="ci-filter-search"
/>
<USelect v-model="customerTypeFilter" :items="customerTypeItems" class="ci-filter-select" />
<USelect v-model="agentFilter" :items="agentItems" class="ci-filter-select" />
<USelect v-model="paymentFilter" :items="paymentItems" class="ci-filter-select" />
<USelect v-model="sortBy" :items="sortItems" class="ci-filter-select" />
<UButton icon="i-heroicons-arrow-path" color="neutral" variant="ghost" size="sm" :loading="pending" @click="refresh()">Refresh</UButton>
<div class="ci-view-toggle">
<button type="button" :class="['ci-view-toggle-btn', viewMode === 'card' && 'ci-view-toggle-btn--active']" title="Card view" @click="viewMode = 'card'">
<UIcon name="i-heroicons-squares-2x2" style="width: 16px; height: 16px;" />
</button>
<button type="button" :class="['ci-view-toggle-btn', viewMode === 'list' && 'ci-view-toggle-btn--active']" title="List view" @click="viewMode = 'list'">
<UIcon name="i-heroicons-bars-3" style="width: 16px; height: 16px;" />
</button>
</div>
</div>
<!-- Loading -->
<div v-if="pending && !usingMock" 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>
<template v-else>
<!-- Card View -->
<div v-if="viewMode === 'card'" 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-[var(--text-primary)] truncate">{{ customerName(c) }}</p>
<p class="text-sm text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
</div>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<button
type="button"
class="w-6 h-6 rounded flex items-center justify-center transition-all"
:class="isFavorite(c.id) ? 'text-amber-400 hover:text-amber-500' : 'text-gray-300 hover:text-amber-400'"
title="Toggle favorite"
@click.prevent.stop="toggleFavorite(c.id)"
>
<UIcon :name="isFavorite(c.id) ? 'i-heroicons-star-solid' : 'i-heroicons-star'" style="width: 16px; height: 16px;" />
</button>
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs">
{{ c.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
</UBadge>
</div>
</div>
<!-- Mock client details -->
<template v-if="c._mock">
<div class="space-y-1 text-sm pt-2 border-t">
<div class="flex justify-between">
<span class="text-gray-500">Phone</span>
<span>{{ c._mock.phone }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Agent</span>
<span>{{ c._mock.agent }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-500">Payment</span>
<span
class="text-xs font-medium px-1.5 py-0.5 rounded-full"
:class="c._mock.paymentStatus === 'Current' ? 'bg-emerald-50 text-emerald-700' : c._mock.paymentStatus === 'Overdue' ? 'bg-rose-50 text-rose-700' : 'bg-amber-50 text-amber-700'"
>{{ c._mock.paymentStatus }}</span>
</div>
</div>
<div v-if="c._mock.tags.length" class="flex gap-1 flex-wrap pt-1">
<span v-for="tag in c._mock.tags" :key="tag" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{{ tag }}</span>
</div>
</template>
<!-- Individual fields (API) -->
<template v-else>
<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 (API) -->
<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>
</template>
</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>
<!-- List View -->
<div v-else class="ci-list-card">
<table class="ci-list-table">
<thead>
<tr>
<th class="ci-list-th" style="width: 280px;">Customer</th>
<th class="ci-list-th">Type</th>
<th class="ci-list-th">Email</th>
<th class="ci-list-th">Phone</th>
<th class="ci-list-th">Agent</th>
<th class="ci-list-th" style="text-align: right;">Policies</th>
<th class="ci-list-th" style="text-align: right;">Premium</th>
<th class="ci-list-th">Payment</th>
<th class="ci-list-th" style="width: 40px;" />
</tr>
</thead>
<tbody>
<tr v-if="customers.length === 0">
<td colspan="9" class="ci-list-empty">
<UIcon name="i-heroicons-users" class="w-10 h-10 mx-auto mb-3" />
<p class="text-base font-medium">No customers found</p>
<p class="text-sm">Try adjusting your search or create a new customer</p>
</td>
</tr>
<NuxtLink
v-for="c in customers"
:key="c.id"
:to="`/customers/${c.id}`"
custom
v-slot="{ navigate }"
>
<tr class="ci-list-row" @click="navigate" style="cursor: pointer;">
<td class="ci-list-td">
<div class="flex items-center gap-2.5">
<UAvatar :alt="customerName(c)" size="xs" />
<span class="font-medium text-[var(--text-primary)] truncate">{{ customerName(c) }}</span>
</div>
</td>
<td class="ci-list-td">
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs">
{{ c.customer_type === 'corporate' ? 'Corp' : 'Indiv' }}
</UBadge>
</td>
<td class="ci-list-td ci-list-td--secondary">{{ c.email ?? '—' }}</td>
<td class="ci-list-td ci-list-td--secondary">{{ c.phone ?? '—' }}</td>
<td class="ci-list-td ci-list-td--secondary">{{ c._mock ? c._mock.agent : '—' }}</td>
<td class="ci-list-td" style="text-align: right;">
{{ c._mock ? c._mock.policies.length : '—' }}
</td>
<td class="ci-list-td" style="text-align: right; font-variant-numeric: tabular-nums;">
{{ c._mock ? fmtMoney(c._mock.policies.reduce((s: number, p: any) => s + p.premium, 0)) + '/yr' : '—' }}
</td>
<td class="ci-list-td">
<span
v-if="c._mock"
class="text-xs font-medium px-1.5 py-0.5 rounded-full"
:class="c._mock.paymentStatus === 'Current' ? 'bg-emerald-50 text-emerald-700' : c._mock.paymentStatus === 'Overdue' ? 'bg-rose-50 text-rose-700' : 'bg-amber-50 text-amber-700'"
>{{ c._mock.paymentStatus }}</span>
<span v-else class="ci-list-td--secondary"></span>
</td>
<td class="ci-list-td" style="text-align: center;">
<button
type="button"
class="w-6 h-6 rounded flex items-center justify-center transition-all"
:class="isFavorite(c.id) ? 'text-amber-400 hover:text-amber-500' : 'text-gray-300 hover:text-amber-400'"
title="Toggle favorite"
@click.prevent.stop="toggleFavorite(c.id)"
>
<UIcon :name="isFavorite(c.id) ? 'i-heroicons-star-solid' : 'i-heroicons-star'" style="width: 14px; height: 14px;" />
</button>
</td>
</tr>
</NuxtLink>
</tbody>
</table>
</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>
<style scoped>
/* ── Header ── */
.ci-root { display: flex; flex-direction: column; gap: 16px; }
.ci-header { display: flex; align-items: center; justify-content: space-between; }
.ci-header-left { display: flex; align-items: center; gap: 10px; }
.ci-title { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; color: var(--text-primary); line-height: 1; }
.ci-count-badge { font-size: 11px; font-weight: 700; color: #01696f; background: rgba(1,105,111,0.08); padding: 2px 9px; border-radius: 10px; }
.ci-header-right { display: flex; align-items: center; gap: 8px; }
.ci-btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; font-size: 12px; font-weight: 600; color: #fff; background: #01696f; border: none; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
.ci-btn-primary:hover { background: #015258; }
/* ── Filter bar ── */
.ci-filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ci-filter-search { width: 260px; }
.ci-filter-select { width: auto; min-width: 130px; }
/* ── View toggle ── */
.ci-view-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.04);
}
.ci-view-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
border-radius: 7px;
border: none;
background: transparent;
color: #8a8a86;
cursor: pointer;
transition: all 0.15s ease;
}
.ci-view-toggle-btn:hover {
color: var(--text-primary);
}
.ci-view-toggle-btn--active {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* ── List view card ── */
.ci-list-card {
background: #fff;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
overflow: hidden;
}
.ci-list-table {
width: 100%;
border-collapse: collapse;
}
.ci-list-th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
white-space: nowrap;
}
.ci-list-row {
transition: background 0.1s ease;
}
.ci-list-row:hover {
background: rgba(0, 0, 0, 0.015);
}
.ci-list-row:not(:last-child) .ci-list-td {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.ci-list-td {
font-size: 13px;
color: var(--text-primary);
padding: 10px 14px;
white-space: nowrap;
vertical-align: middle;
}
.ci-list-td--secondary {
color: #8a8a86;
}
.ci-list-empty {
text-align: center;
padding: 48px 16px;
color: #9ca3af;
}
</style>