Files
policy-ui/app/pages/customers/index.vue
HaimKortovich 53bbdca525
All checks were successful
Build and Publish / build-release (push) Successful in 4m19s
bug fixes
2026-04-30 16:41:34 -05:00

321 lines
11 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">
usePageTitle('Customers')
const page = ref(1)
const search = ref('')
const customerTypeFilter = 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 sortItems = [
{ label: 'Name (AZ)', value: 'name_asc' }
]
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
}
})
})
// Ensure data is refreshed when page loads
onMounted(() => {
refresh()
})
const customers = computed(() => data.value?.data ?? [])
const meta = computed(() => data.value?.meta)
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'
}
function customerSubtitle(c: any) {
return c.customer_type === 'corporate' ? c.ruc : c.email
}
function customerTypeColor(type: string) {
return type === 'corporate' ? 'purple' : 'blue'
}
</script>
<template>
<div class="ci-root mx-auto max-w-7xl pb-12">
<div class="ci-header">
<div class="ci-header-left">
<h1 class="ci-title">Customers</h1>
<span class="ci-count-badge">{{ 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>
<div class="ci-filter-bar">
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="Search by name, email, RUC..."
class="ci-filter-search"
/>
<USelect v-model="customerTypeFilter" :items="customerTypeItems" 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>
<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>
<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>
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs">
{{ c.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
</UBadge>
</div>
<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>
<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-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>
</tr>
</thead>
<tbody>
<tr v-if="customers.length === 0">
<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>
</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>
</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>
.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; }
.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; }
.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);
}
.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>