index.vue 9.73 KB
<script setup lang="ts">
import { h, resolveComponent } from 'vue'
import { format, sub } from 'date-fns'
import type { TableColumn } from '@nuxt/ui'
import type {
  DashboardOverviewResponse,
  DashboardWorkOrderStatus,
  Range
} from '~/types'

const { isNotificationsSlideoverOpen } = useDashboard()
const { t } = useAppI18n()
const toast = useToast()
const dashboardApi = useDashboardDataApi()

const UBadge = resolveComponent('UBadge')

const range = shallowRef<Range>({
  start: sub(new Date(), { days: 14 }),
  end: new Date()
})

const selectedLine = ref<'all' | string>('all')
const selectedWorkOrderStatus = ref<'all' | DashboardWorkOrderStatus>('all')

const loading = ref(false)
const exporting = ref(false)
const requestError = ref('')

const overview = ref<DashboardOverviewResponse>({
  query: {},
  filters: {
    lines: [],
    workOrderStatuses: []
  },
  workOrders: {
    total: 0,
    running: 0,
    pendingQc: 0,
    inException: 0
  },
  sn: {
    completed: 0,
    inProcess: 0,
    frozen: 0
  },
  quality: {
    firstPassRate: 0,
    retestPassRate: 0,
    exceptionRate: 0
  },
  latestWorkOrders: []
})

const lineItems = computed(() => {
  return [{
    label: t('home.filters.lineAll'),
    value: 'all'
  }, ...overview.value.filters.lines.map(line => ({
    label: line,
    value: line
  }))]
})

const statusItems = computed(() => {
  return [{
    label: t('home.filters.workOrderStatusAll'),
    value: 'all'
  }, ...overview.value.filters.workOrderStatuses.map(status => ({
    label: t(`home.workOrderStatus.${status}`),
    value: status
  }))]
})

const summaryCards = computed(() => [{
  title: t('home.metrics.workOrders.total'),
  value: overview.value.workOrders.total,
  icon: 'i-lucide-clipboard-list'
}, {
  title: t('home.metrics.workOrders.running'),
  value: overview.value.workOrders.running,
  icon: 'i-lucide-play-circle'
}, {
  title: t('home.metrics.workOrders.pendingQc'),
  value: overview.value.workOrders.pendingQc,
  icon: 'i-lucide-flask-conical'
}, {
  title: t('home.metrics.workOrders.inException'),
  value: overview.value.workOrders.inException,
  icon: 'i-lucide-alert-triangle'
}, {
  title: t('home.metrics.sn.completed'),
  value: overview.value.sn.completed,
  icon: 'i-lucide-check-circle-2'
}, {
  title: t('home.metrics.sn.inProcess'),
  value: overview.value.sn.inProcess,
  icon: 'i-lucide-loader-circle'
}, {
  title: t('home.metrics.sn.frozen'),
  value: overview.value.sn.frozen,
  icon: 'i-lucide-snowflake'
}, {
  title: t('home.metrics.quality.firstPassRate'),
  value: `${overview.value.quality.firstPassRate.toFixed(2)}%`,
  icon: 'i-lucide-badge-check'
}, {
  title: t('home.metrics.quality.retestPassRate'),
  value: `${overview.value.quality.retestPassRate.toFixed(2)}%`,
  icon: 'i-lucide-rotate-cw'
}, {
  title: t('home.metrics.quality.exceptionRate'),
  value: `${overview.value.quality.exceptionRate.toFixed(2)}%`,
  icon: 'i-lucide-octagon-alert'
}])

function formatDateInput(value: Date) {
  return format(value, 'yyyy-MM-dd')
}

const query = computed(() => ({
  start: formatDateInput(range.value.start),
  end: formatDateInput(range.value.end),
  line: selectedLine.value === 'all' ? undefined : selectedLine.value,
  workOrderStatus: selectedWorkOrderStatus.value === 'all'
    ? undefined
    : selectedWorkOrderStatus.value
}))

const columns = computed<TableColumn<DashboardOverviewResponse['latestWorkOrders'][number]>[]>(() => [{
  accessorKey: 'orderNo',
  header: t('home.table.orderNo')
}, {
  accessorKey: 'line',
  header: t('home.table.line')
}, {
  accessorKey: 'status',
  header: t('home.table.status'),
  cell: ({ row }) => {
    const status = row.getValue('status') as DashboardWorkOrderStatus
    const color = ({
      pending_dispatch: 'neutral',
      running: 'primary',
      pending_qc: 'warning',
      in_exception: 'error',
      completed: 'success'
    } as const)[status]

    return h(UBadge, { color, variant: 'subtle' }, () => t(`home.workOrderStatus.${status}`))
  }
}, {
  accessorKey: 'plannedDate',
  header: t('home.table.plannedDate')
}, {
  id: 'progress',
  header: t('home.table.progress'),
  cell: ({ row }) => {
    const item = row.original
    return `${item.completedSn}/${item.totalSn}`
  }
}])

async function loadOverview() {
  loading.value = true
  requestError.value = ''

  try {
    overview.value = await dashboardApi.getDashboardOverview(query.value)
  } catch {
    requestError.value = t('common.requestFailed')
  } finally {
    loading.value = false
  }
}

async function onManualRefresh() {
  await loadOverview()
}

async function onExport() {
  if (exporting.value) {
    return
  }

  exporting.value = true

  try {
    const result = await dashboardApi.exportDashboardOverview(query.value)

    if (!result.success || typeof result.content !== 'string' || !result.fileName) {
      toast.add({
        title: t('common.error'),
        description: result.message || t('common.requestFailed'),
        icon: 'i-lucide-circle-alert',
        color: 'error'
      })
      return
    }

    if (import.meta.client) {
      const bytes = new TextEncoder().encode(result.content)
      const blob = new Blob([bytes], {
        type: result.contentType || 'text/csv;charset=utf-8'
      })
      const url = URL.createObjectURL(blob)
      const anchor = document.createElement('a')
      anchor.href = url
      anchor.download = result.fileName
      anchor.click()
      URL.revokeObjectURL(url)
    }

    toast.add({
      title: t('home.export.successTitle'),
      description: t('home.export.successDescription'),
      icon: 'i-lucide-check',
      color: 'success'
    })
  } catch {
    toast.add({
      title: t('common.error'),
      description: t('common.requestFailed'),
      icon: 'i-lucide-circle-alert',
      color: 'error'
    })
  } finally {
    exporting.value = false
  }
}

watch(
  [
    () => range.value.start,
    () => range.value.end,
    selectedLine,
    selectedWorkOrderStatus
  ],
  () => {
    loadOverview()
  },
  { immediate: true }
)
</script>

<template>
  <UDashboardPanel id="home">
    <template #header>
      <UDashboardNavbar :title="t('home.title')" :ui="{ right: 'gap-2' }">
        <template #leading>
          <UDashboardSidebarCollapse />
        </template>

        <template #right>
          <UTooltip :text="t('notifications.title')" :shortcuts="['N']">
            <UButton
              color="neutral"
              variant="ghost"
              square
              @click="isNotificationsSlideoverOpen = true"
            >
              <UChip color="error" inset>
                <UIcon name="i-lucide-bell" class="size-5 shrink-0" />
              </UChip>
            </UButton>
          </UTooltip>

          <UButton
            color="neutral"
            variant="outline"
            icon="i-lucide-refresh-cw"
            :label="t('home.actions.refresh')"
            :loading="loading"
            @click="onManualRefresh"
          />

          <UButton
            icon="i-lucide-download"
            :label="t('home.actions.export')"
            :loading="exporting"
            @click="onExport"
          />
        </template>
      </UDashboardNavbar>

      <UDashboardToolbar>
        <template #left>
          <HomeDateRangePicker v-model="range" class="-ms-1" />

          <USelect
            v-model="selectedLine"
            :items="lineItems"
            value-key="value"
            class="w-44"
          />

          <USelect
            v-model="selectedWorkOrderStatus"
            :items="statusItems"
            value-key="value"
            class="w-44"
          />
        </template>
      </UDashboardToolbar>
    </template>

    <template #body>
      <UAlert
        v-if="requestError"
        color="error"
        variant="subtle"
        icon="i-lucide-circle-alert"
        :title="t('common.error')"
        :description="requestError"
      />

      <UPageGrid class="md:grid-cols-2 xl:grid-cols-5 gap-4 sm:gap-4">
        <UPageCard
          v-for="item in summaryCards"
          :key="item.title"
          :icon="item.icon"
          :title="item.title"
          variant="subtle"
          :ui="{
            container: 'gap-y-1.5',
            wrapper: 'items-start',
            leading: 'p-2.5 rounded-full bg-primary/10 ring ring-inset ring-primary/25 flex-col',
            title: 'font-normal text-muted text-xs uppercase'
          }"
        >
          <div class="text-2xl font-semibold text-highlighted">
            {{ item.value }}
          </div>
        </UPageCard>
      </UPageGrid>

      <UCard>
        <template #header>
          <div class="flex items-center justify-between gap-2">
            <div>
              <p class="text-sm font-semibold text-highlighted">
                {{ t('home.table.title') }}
              </p>
              <p class="text-xs text-muted">
                {{ t('home.table.description') }}
              </p>
            </div>
            <UBadge color="neutral" variant="subtle">
              {{ t('home.table.count', { count: overview.latestWorkOrders.length }) }}
            </UBadge>
          </div>
        </template>

        <UTable
          :data="overview.latestWorkOrders"
          :columns="columns"
          :loading="loading"
          class="shrink-0"
          :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'
          }"
        />

        <div
          v-if="!loading && overview.latestWorkOrders.length === 0"
          class="text-sm text-muted pt-3"
        >
          {{ t('home.table.empty') }}
        </div>
      </UCard>
    </template>
  </UDashboardPanel>
</template>