316 lines
11 KiB
Vue
316 lines
11 KiB
Vue
<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 (A–Z)', 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
|
||
}
|
||
})
|
||
})
|
||
|
||
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>
|