customers.vue 10.8 KB
<script setup lang="ts">
import type { TableColumn } from '@nuxt/ui'
import { upperFirst } from 'scule'
import { getPaginationRowModel } from '@tanstack/table-core'
import type { Row } from '@tanstack/table-core'
import type { User } from '~/types'
import type { ButtonPermissionKey } from '~/utils/permission'

const UAvatar = resolveComponent('UAvatar')
const UButton = resolveComponent('UButton')
const UBadge = resolveComponent('UBadge')
const UDropdownMenu = resolveComponent('UDropdownMenu')
const UCheckbox = resolveComponent('UCheckbox')

const { t } = useAppI18n()
const toast = useToast()
const { can, getDeniedReason } = usePermission()
const table = useTemplateRef('table')
const dashboardDataApi = useDashboardDataApi()
const pagePath = '/customers'
const canDeleteCustomer = computed(() => can(pagePath, 'customers.delete'))

const columnFilters = ref([{
  id: 'email',
  value: ''
}])
const columnVisibility = ref()
const rowSelection = ref({ 1: true })

const { data, status } = await dashboardDataApi.getCustomers()

function showNoPermissionToast(buttonKey: ButtonPermissionKey) {
  toast.add({
    title: t('permission.toast.title'),
    description: t(getDeniedReason(pagePath, buttonKey)),
    icon: 'i-lucide-shield-alert',
    color: 'warning'
  })
}

function getRowItems(row: Row<User>) {
  return [
    {
      type: 'label',
      label: t('customers.table.actions')
    },
    {
      label: t('customers.table.copyCustomerId'),
      icon: 'i-lucide-copy',
      onSelect() {
        navigator.clipboard.writeText(row.original.id.toString())
        toast.add({
          title: t('customers.table.copiedTitle'),
          description: t('customers.table.copiedDescription')
        })
      }
    },
    {
      type: 'separator'
    },
    {
      label: t('customers.table.viewDetails'),
      icon: 'i-lucide-list'
    },
    {
      label: t('customers.table.viewPayments'),
      icon: 'i-lucide-wallet'
    },
    {
      type: 'separator'
    },
    {
      label: t('customers.table.deleteCustomer'),
      icon: 'i-lucide-trash',
      color: 'error',
      disabled: !canDeleteCustomer.value,
      onSelect() {
        if (!canDeleteCustomer.value) {
          showNoPermissionToast('customers.delete')
          return
        }

        toast.add({
          title: t('customers.table.deletedTitle'),
          description: t('customers.table.deletedDescription')
        })
      }
    }
  ]
}

function onDeleteSelectedClick() {
  if (!canDeleteCustomer.value) {
    showNoPermissionToast('customers.delete')
    return
  }
}

const columns = computed<TableColumn<User>[]>(() => [
  {
    id: 'select',
    header: ({ table }) =>
      h(UCheckbox, {
        'modelValue': table.getIsSomePageRowsSelected()
          ? 'indeterminate'
          : table.getIsAllPageRowsSelected(),
        'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
          table.toggleAllPageRowsSelected(!!value),
        'ariaLabel': t('customers.table.selectAll')
      }),
    cell: ({ row }) =>
      h(UCheckbox, {
        'modelValue': row.getIsSelected(),
        'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
        'ariaLabel': t('customers.table.selectRow')
      })
  },
  {
    accessorKey: 'id',
    header: t('customers.table.id')
  },
  {
    accessorKey: 'name',
    header: t('customers.table.name'),
    cell: ({ row }) => {
      return h('div', { class: 'flex items-center gap-3' }, [
        h(UAvatar, {
          ...row.original.avatar,
          size: 'lg'
        }),
        h('div', undefined, [
          h('p', { class: 'font-medium text-highlighted' }, row.original.name),
          h('p', { class: '' }, `@${row.original.name}`)
        ])
      ])
    }
  },
  {
    accessorKey: 'email',
    header: ({ column }) => {
      const isSorted = column.getIsSorted()

      return h(UButton, {
        color: 'neutral',
        variant: 'ghost',
        label: t('customers.table.email'),
        icon: isSorted
          ? isSorted === 'asc'
            ? 'i-lucide-arrow-up-narrow-wide'
            : 'i-lucide-arrow-down-wide-narrow'
          : 'i-lucide-arrow-up-down',
        class: '-mx-2.5',
        onClick: () => column.toggleSorting(column.getIsSorted() === 'asc')
      })
    }
  },
  {
    accessorKey: 'location',
    header: t('customers.table.location'),
    cell: ({ row }) => row.original.location
  },
  {
    accessorKey: 'status',
    header: t('customers.table.status'),
    filterFn: 'equals',
    cell: ({ row }) => {
      const rawStatus = row.original.status
      const color = {
        subscribed: 'success' as const,
        unsubscribed: 'error' as const,
        bounced: 'warning' as const
      }[rawStatus]

      return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
        t(`customers.status.${rawStatus}`)
      )
    }
  },
  {
    id: 'actions',
    cell: ({ row }) => {
      return h(
        'div',
        { class: 'text-right' },
        h(
          UDropdownMenu,
          {
            content: {
              align: 'end'
            },
            items: getRowItems(row)
          },
          () =>
            h(UButton, {
              icon: 'i-lucide-ellipsis-vertical',
              color: 'neutral',
              variant: 'ghost',
              class: 'ml-auto'
            })
        )
      )
    }
  }
])

const statusFilter = ref('all')

watch(() => statusFilter.value, (newVal) => {
  if (!table?.value?.tableApi) return

  const statusColumn = table.value.tableApi.getColumn('status')
  if (!statusColumn) return

  if (newVal === 'all') {
    statusColumn.setFilterValue(undefined)
  } else {
    statusColumn.setFilterValue(newVal)
  }
})

const email = computed({
  get: (): string => {
    return (table.value?.tableApi?.getColumn('email')?.getFilterValue() as string) || ''
  },
  set: (value: string) => {
    table.value?.tableApi?.getColumn('email')?.setFilterValue(value || undefined)
  }
})

const statusItems = computed(() => [
  { label: t('customers.status.all'), value: 'all' },
  { label: t('customers.status.subscribed'), value: 'subscribed' },
  { label: t('customers.status.unsubscribed'), value: 'unsubscribed' },
  { label: t('customers.status.bounced'), value: 'bounced' }
])

const pagination = ref({
  pageIndex: 0,
  pageSize: 10
})

const columnLabelMap: Record<string, string> = {
  id: 'customers.table.id',
  name: 'customers.table.name',
  email: 'customers.table.email',
  location: 'customers.table.location',
  status: 'customers.table.status'
}
</script>

<template>
  <UDashboardPanel id="customers">
    <template #header>
      <UDashboardNavbar :title="t('customers.title')">
        <template #leading>
          <UDashboardSidebarCollapse />
        </template>

        <template #right>
          <CustomersAddModal page-path="/customers" button-key="customers.create" />
        </template>
      </UDashboardNavbar>
    </template>

    <template #body>
      <div class="flex flex-wrap items-center justify-between gap-1.5">
        <UInput
          v-model="email"
          class="max-w-sm"
          icon="i-lucide-search"
          :placeholder="t('customers.table.filterEmails')"
        />

        <div class="flex flex-wrap items-center gap-1.5">
          <CustomersDeleteModal :count="table?.tableApi?.getFilteredSelectedRowModel().rows.length">
            <UButton
              v-if="table?.tableApi?.getFilteredSelectedRowModel().rows.length"
              :label="t('common.delete')"
              color="error"
              variant="subtle"
              icon="i-lucide-trash"
              :disabled="!canDeleteCustomer"
              @click="onDeleteSelectedClick"
            >
              <template #trailing>
                <UKbd>
                  {{ table?.tableApi?.getFilteredSelectedRowModel().rows.length }}
                </UKbd>
              </template>
            </UButton>
          </CustomersDeleteModal>

          <USelect
            v-model="statusFilter"
            :items="statusItems"
            :ui="{ trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200' }"
            :placeholder="t('customers.table.filterStatus')"
            class="min-w-28"
          />
          <UDropdownMenu
            :items="
              table?.tableApi
                ?.getAllColumns()
                .filter((column: any) => column.getCanHide())
                .map((column: any) => ({
                  label: columnLabelMap[column.id] ? t(columnLabelMap[column.id]!) : upperFirst(column.id),
                  type: 'checkbox' as const,
                  checked: column.getIsVisible(),
                  onUpdateChecked(checked: boolean) {
                    table?.tableApi?.getColumn(column.id)?.toggleVisibility(!!checked)
                  },
                  onSelect(e?: Event) {
                    e?.preventDefault()
                  }
                }))
            "
            :content="{ align: 'end' }"
          >
            <UButton
              :label="t('customers.table.display')"
              color="neutral"
              variant="outline"
              trailing-icon="i-lucide-settings-2"
            />
          </UDropdownMenu>
        </div>
      </div>

      <UTable
        ref="table"
        v-model:column-filters="columnFilters"
        v-model:column-visibility="columnVisibility"
        v-model:row-selection="rowSelection"
        v-model:pagination="pagination"
        :pagination-options="{
          getPaginationRowModel: getPaginationRowModel()
        }"
        class="shrink-0"
        :data="data"
        :columns="columns"
        :loading="status === 'pending'"
        :ui="{
          base: 'table-fixed border-separate border-spacing-0',
          thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
          tbody: '[&>tr]:last:[&>td]:border-b-0',
          th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
          td: 'border-b border-default',
          separator: 'h-0'
        }"
      />

      <div class="flex items-center justify-between gap-3 border-t border-default pt-4 mt-auto">
        <div class="text-sm text-muted">
          {{
            t('customers.table.selectedRows', {
              selected: table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0,
              total: table?.tableApi?.getFilteredRowModel().rows.length || 0
            })
          }}
        </div>

        <div class="flex items-center gap-1.5">
          <UPagination
            :default-page="(table?.tableApi?.getState().pagination.pageIndex || 0) + 1"
            :items-per-page="table?.tableApi?.getState().pagination.pageSize"
            :total="table?.tableApi?.getFilteredRowModel().rows.length"
            @update:page="(p: number) => table?.tableApi?.setPageIndex(p - 1)"
          />
        </div>
      </div>
    </template>
  </UDashboardPanel>
</template>