@@ -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
4446const 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
5149const 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-
144115function 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 >
0 commit comments