big refactor

This commit is contained in:
2026-04-29 16:25:11 -05:00
parent 6c411ce2b6
commit 8265fb689a
156 changed files with 15845 additions and 50373 deletions

View File

@@ -1,43 +1,24 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
const { isFavorite, toggleFavorite } = useClientFavorites()
usePageTitle('Customers')
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' },
{ label: 'All Types', value: null },
{ label: 'Individual', value: 'individual' },
{ label: 'Corporate', value: 'corporate' }
]
const sortItems = [
{ label: 'Name (AZ)', value: 'name_asc' },
{ label: 'Premium (highlow)', value: 'premium_desc' },
{ label: 'Policies (most)', value: 'policies_desc' },
{ label: 'Name (AZ)', value: 'name_asc' }
]
watch([debouncedSearch, customerTypeFilter, agentFilter, paymentFilter], () => { page.value = 1 })
watch([debouncedSearch, customerTypeFilter], () => { page.value = 1 })
const { data, pending, refresh } = useCustomer('/customers', {
query: computed(() => {
@@ -46,120 +27,50 @@ const { data, pending, refresh } = useCustomer('/customers', {
if (debouncedSearch.value) {
filters[`filters[${i}][field]`] = 'search'
filters[`filters[${i}][op]`] = '=='
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}][op]`] = '=='
filters[`filters[${i}][value]`] = customerTypeFilter.value
i++
}
return {
'page[number]': page.value,
'page[size]': 20,
'page_size': 20,
'page': page.value,
...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 customers = computed(() => data.value?.data ?? [])
const meta = computed(() => data.value?.meta)
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) => {
function 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`
}
function customerSubtitle(c: any) {
return c.customer_type === 'corporate' ? c.ruc : c.email
}
const customerTypeColor = (type: string) =>
type === 'corporate' ? 'purple' : 'blue'
function customerTypeColor(type: string) {
return 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>
<span class="ci-count-badge">{{ meta?.total_count ?? 0 }}</span>
</div>
<div class="ci-header-right">
<NuxtLink to="/customers/new">
@@ -171,17 +82,14 @@ const customerTypeColor = (type: string) =>
</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..."
placeholder="Search by name, email, RUC..."
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">
@@ -194,15 +102,13 @@ const customerTypeColor = (type: string) =>
</div>
</div>
<!-- Loading -->
<div v-if="pending && !usingMock" 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>
<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">
@@ -215,48 +121,11 @@ const customerTypeColor = (type: string) =>
<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>
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs">
{{ c.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
</UBadge>
</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>
@@ -272,7 +141,6 @@ const customerTypeColor = (type: string) =>
</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>
@@ -287,7 +155,6 @@ const customerTypeColor = (type: string) =>
<span>{{ c.legal_rep_name ?? '—' }}</span>
</div>
</div>
</template>
</div>
</UCard>
</NuxtLink>
@@ -299,7 +166,6 @@ const customerTypeColor = (type: string) =>
</div>
</div>
<!-- List View -->
<div v-else class="ci-list-card">
<table class="ci-list-table">
<thead>
@@ -308,16 +174,11 @@ const customerTypeColor = (type: string) =>
<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">
<td colspan="4" 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>
@@ -344,32 +205,6 @@ const customerTypeColor = (type: string) =>
</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>
@@ -388,7 +223,6 @@ const customerTypeColor = (type: string) =>
</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; }
@@ -398,12 +232,10 @@ const customerTypeColor = (type: string) =>
.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;
@@ -434,7 +266,6 @@ const customerTypeColor = (type: string) =>
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* ── List view card ── */
.ci-list-card {
background: #fff;
border-radius: 12px;