Skip to content

Commit afb5823

Browse files
AchoArnoldCopilot
andcommitted
feat(web): refine contacts table
Improve the contacts table layout, actions, relative timestamps, and header emphasis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 48597e9 commit afb5823

3 files changed

Lines changed: 157 additions & 168 deletions

File tree

web/app/composables/useFilters.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function useFilters() {
1919
formatMoney,
2020
formatDecimal,
2121
formatBillingPeriod,
22+
humanizeTimeShort,
2223
formatBillingPeriodDateOrdinal,
2324
humanizeTime,
2425
startsWithLetter,

web/app/pages/contacts/index.vue

Lines changed: 135 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
mdiEmailOutline,
1111
mdiFileUpload,
1212
mdiMagnify,
13-
mdiPencil,
13+
mdiPhoneCheck,
14+
mdiEmailCheckOutline,
15+
mdiSquareEditOutline,
1416
mdiPhone,
1517
mdiPlus,
1618
} from '@mdi/js'
@@ -42,11 +44,7 @@ interface ContactForm {
4244
}
4345
4446
const contactsStore = useContactsStore()
45-
const { formatPhoneNumber, humanizeTime, formatTimestamp } = useFilters()
46-
47-
// Avatar backgrounds are picked deterministically from the Vuetify theme
48-
// palette so the table reads like an address book without inventing colors.
49-
const avatarPalette = ['primary', 'secondary', 'info', 'success']
47+
const { formatPhoneNumber, formatTimestamp, humanizeTimeShort } = useFilters()
5048
5149
const headers = [
5250
{ title: 'Name', key: 'name', sortable: false },
@@ -114,33 +112,6 @@ function emptyForm(): ContactForm {
114112
}
115113
}
116114
117-
function initials(name: string): string {
118-
const parts = name.trim().split(/\s+/).filter(Boolean)
119-
if (parts.length === 0) {
120-
return ''
121-
}
122-
const first = parts[0] ?? ''
123-
const last = parts.length > 1 ? (parts[parts.length - 1] ?? '') : ''
124-
return (first.charAt(0) + last.charAt(0)).toUpperCase()
125-
}
126-
127-
function avatarColor(name: string): string {
128-
const key = name.trim()
129-
if (!key) {
130-
return 'primary'
131-
}
132-
let hash = 0
133-
for (let index = 0; index < key.length; index += 1) {
134-
hash = (hash + key.charCodeAt(index)) % avatarPalette.length
135-
}
136-
return avatarPalette[hash] ?? 'primary'
137-
}
138-
139-
function relativeTime(value: string): string {
140-
const humanized = humanizeTime(value)
141-
return humanized ? `${humanized} ago` : 'just now'
142-
}
143-
144115
function openAdd() {
145116
editingId.value = null
146117
form.value = emptyForm()
@@ -381,7 +352,7 @@ onBeforeUnmount(() => {
381352

382353
<VContainer class="pt-0">
383354
<VRow>
384-
<VCol cols="12" md="10" offset-md="1" xxl="8" offset-xxl="2">
355+
<VCol cols="12">
385356
<div class="d-flex flex-column flex-md-row align-md-center mb-6 mt-3">
386357
<div>
387358
<h1 class="text-display-large mb-1">Contacts</h1>
@@ -397,8 +368,7 @@ onBeforeUnmount(() => {
397368
<VSpacer />
398369
<div class="d-flex flex-column flex-sm-row ga-3 mt-4 mt-md-0">
399370
<VBtn
400-
variant="outlined"
401-
color="primary"
371+
variant="tonal"
402372
:prepend-icon="mdiFileUpload"
403373
@click="openImport"
404374
>
@@ -420,150 +390,148 @@ onBeforeUnmount(() => {
420390
:prepend-inner-icon="mdiMagnify"
421391
label="Search by name, phone number or email"
422392
variant="outlined"
423-
density="comfortable"
393+
density="compact"
424394
clearable
425395
hide-details
426396
class="mb-4"
427397
/>
428398

429-
<VCard variant="outlined">
430-
<VDataTableServer
431-
v-model:page="page"
432-
v-model:items-per-page="itemsPerPage"
433-
:headers="headers"
434-
:items="contactsStore.contacts"
435-
:items-length="contactsStore.total"
436-
:loading="contactsStore.loading"
437-
:items-per-page-options="itemsPerPageOptions"
438-
item-value="id"
439-
hover
440-
loading-text="Loading contacts…"
441-
@update:options="onUpdateOptions"
442-
>
443-
<template #[`item.name`]="{ item }">
444-
<div class="d-flex align-center py-2">
445-
<VAvatar
446-
:color="avatarColor(item.name)"
447-
size="40"
448-
class="mr-3"
449-
>
450-
<span
451-
v-if="initials(item.name)"
452-
class="text-body-1 font-weight-medium"
453-
>{{ initials(item.name) }}</span
454-
>
455-
<VIcon v-else :icon="mdiAccount" />
456-
</VAvatar>
457-
<span class="font-weight-medium">{{ item.name }}</span>
458-
</div>
459-
</template>
460-
461-
<template #[`item.phone_numbers`]="{ item }">
399+
<VDataTableServer
400+
v-model:page="page"
401+
v-model:items-per-page="itemsPerPage"
402+
:headers="headers"
403+
:header-props="{
404+
class: 'text-uppercase text-medium-emphasis',
405+
}"
406+
:items="contactsStore.contacts"
407+
:items-length="contactsStore.total"
408+
:loading="contactsStore.loading"
409+
:items-per-page-options="itemsPerPageOptions"
410+
item-value="id"
411+
hover
412+
loading-text="Loading contacts…"
413+
@update:options="onUpdateOptions"
414+
>
415+
<template #[`item.name`]="{ item }">
416+
<div class="d-flex align-center py-2">
417+
<span class="font-weight-medium">{{ item.name }}</span>
418+
</div>
419+
</template>
420+
421+
<template #[`item.phone_numbers`]="{ item }">
422+
<div
423+
v-if="item.phone_numbers?.length"
424+
class="d-flex flex-column ga-1 py-2"
425+
>
462426
<div
463-
v-if="item.phone_numbers?.length"
464-
class="d-flex flex-column ga-1 py-2"
427+
v-for="phone in item.phone_numbers ?? []"
428+
:key="phone"
429+
class="d-flex"
465430
>
466-
<VChip
467-
v-for="phone in item.phone_numbers ?? []"
468-
:key="phone"
431+
<VIcon
432+
:icon="mdiPhoneCheck"
469433
size="small"
470-
variant="tonal"
471-
color="primary"
472-
:prepend-icon="mdiPhone"
473-
>
474-
{{ formatPhoneNumber(phone) }}
475-
</VChip>
434+
color="info"
435+
class="mr-1 text-medium-emphasis"
436+
/>
437+
{{ formatPhoneNumber(phone) }}
476438
</div>
477-
<span v-else class="text-medium-emphasis">—</span>
478-
</template>
479-
480-
<template #[`item.emails`]="{ item }">
481-
<div
482-
v-if="item.emails?.length"
483-
class="d-flex flex-column ga-1 py-2"
439+
</div>
440+
<span v-else class="text-medium-emphasis">—</span>
441+
</template>
442+
443+
<template #[`item.emails`]="{ item }">
444+
<div
445+
v-if="item.emails?.length"
446+
class="d-flex flex-column ga-1 py-2"
447+
>
448+
<span
449+
v-for="email in item.emails ?? []"
450+
:key="email"
451+
class="d-flex align-center text-body-2"
484452
>
485-
<span
486-
v-for="email in item.emails ?? []"
487-
:key="email"
488-
class="d-flex align-center text-body-2"
489-
>
490-
<VIcon
491-
:icon="mdiEmailOutline"
492-
size="x-small"
493-
class="mr-1 text-medium-emphasis"
494-
/>
495-
{{ email }}
496-
</span>
497-
</div>
498-
<span v-else class="text-medium-emphasis">—</span>
499-
</template>
500-
501-
<template #[`item.created_at`]="{ item }">
502-
<span :title="formatTimestamp(item.created_at)">{{
503-
relativeTime(item.created_at)
504-
}}</span>
505-
</template>
506-
507-
<template #[`item.updated_at`]="{ item }">
508-
<span :title="formatTimestamp(item.updated_at)">{{
509-
relativeTime(item.updated_at)
510-
}}</span>
511-
</template>
512-
513-
<template #[`item.actions`]="{ item }">
514-
<div class="d-flex justify-end">
515-
<VBtn
516-
:icon="mdiPencil"
517-
variant="text"
518-
size="small"
519-
aria-label="Edit contact"
520-
@click="openEdit(item)"
521-
/>
522-
<VBtn
523-
:icon="mdiDelete"
524-
variant="text"
453+
<VIcon
454+
:icon="mdiEmailCheckOutline"
525455
size="small"
526-
color="error"
527-
aria-label="Delete contact"
528-
@click="openDelete(item)"
456+
color="info"
457+
class="mr-1 text-medium-emphasis"
529458
/>
530-
</div>
531-
</template>
459+
{{ email }}
460+
</span>
461+
</div>
462+
<span v-else class="text-medium-emphasis">—</span>
463+
</template>
464+
465+
<template #[`item.created_at`]="{ item }">
466+
<span
467+
class="text-medium-emphasis"
468+
:title="formatTimestamp(item.created_at)"
469+
>{{ humanizeTimeShort(item.created_at) }}</span
470+
>
471+
</template>
532472

533-
<template #no-data>
534-
<div class="text-center py-12">
535-
<VIcon
536-
:icon="mdiAccountGroupOutline"
537-
size="64"
538-
class="text-medium-emphasis mb-3"
539-
/>
540-
<p class="text-title-medium mb-1">
541-
{{
542-
searchTerm
543-
? 'No contacts match your search'
544-
: 'No contacts yet'
545-
}}
546-
</p>
547-
<p class="text-medium-emphasis mb-4">
548-
{{
549-
searchTerm
550-
? 'Try a different name, phone number or email.'
551-
: 'Add your first contact or import them from a CSV file.'
552-
}}
553-
</p>
554-
<VBtn
555-
v-if="!searchTerm"
556-
color="primary"
557-
variant="flat"
558-
:prepend-icon="mdiAccountPlus"
559-
@click="openAdd"
560-
>
561-
Add Contact
562-
</VBtn>
563-
</div>
564-
</template>
565-
</VDataTableServer>
566-
</VCard>
473+
<template #[`item.updated_at`]="{ item }">
474+
<span
475+
class="text-medium-emphasis"
476+
:title="formatTimestamp(item.updated_at)"
477+
>{{ humanizeTimeShort(item.updated_at) }}</span
478+
>
479+
</template>
480+
481+
<template #[`item.actions`]="{ item }">
482+
<div class="d-flex justify-end">
483+
<VBtn
484+
:icon="mdiSquareEditOutline"
485+
variant="text"
486+
class="mr-2"
487+
density="comfortable"
488+
aria-label="Edit contact"
489+
@click="openEdit(item)"
490+
/>
491+
<VBtn
492+
:icon="mdiDelete"
493+
variant="text"
494+
density="comfortable"
495+
color="error"
496+
aria-label="Delete contact"
497+
@click="openDelete(item)"
498+
/>
499+
</div>
500+
</template>
501+
502+
<template #no-data>
503+
<div class="text-center py-12">
504+
<VIcon
505+
:icon="mdiAccountGroupOutline"
506+
size="64"
507+
class="text-medium-emphasis mb-3"
508+
/>
509+
<p class="text-title-medium mb-1">
510+
{{
511+
searchTerm
512+
? 'No contacts match your search'
513+
: 'No contacts yet'
514+
}}
515+
</p>
516+
<p class="text-medium-emphasis mb-4">
517+
{{
518+
searchTerm
519+
? 'Try a different name, phone number or email.'
520+
: 'Add your first contact or import them from a CSV file.'
521+
}}
522+
</p>
523+
<VBtn
524+
v-if="!searchTerm"
525+
color="primary"
526+
variant="flat"
527+
:prepend-icon="mdiAccountPlus"
528+
@click="openAdd"
529+
>
530+
Add Contact
531+
</VBtn>
532+
</div>
533+
</template>
534+
</VDataTableServer>
567535
</VCol>
568536
</VRow>
569537
</VContainer>

web/app/utils/filters.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { intervalToDuration, formatDuration } from 'date-fns'
1+
import {
2+
intervalToDuration,
3+
formatDuration,
4+
differenceInSeconds,
5+
differenceInMinutes,
6+
differenceInDays,
7+
differenceInHours,
8+
} from 'date-fns'
29
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js'
310

411
export function formatPhoneNumber(value: string): string {
@@ -103,3 +110,16 @@ export function humanizeTime(value: string): string {
103110
export function startsWithLetter(value: string): boolean {
104111
return /^[a-zA-Z]/.test(value)
105112
}
113+
114+
export function humanizeTimeShort(date: string) {
115+
const now = new Date()
116+
const seconds = differenceInSeconds(now, new Date(date))
117+
const minutes = differenceInMinutes(now, new Date(date))
118+
const hours = differenceInHours(now, new Date(date))
119+
const days = differenceInDays(now, new Date(date))
120+
121+
if (seconds < 60) return 'just now'
122+
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`
123+
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`
124+
return `${days} day${days !== 1 ? 's' : ''} ago`
125+
}

0 commit comments

Comments
 (0)