first commit
This commit is contained in:
121
app/components/home/HomeChart.client.vue
Normal file
121
app/components/home/HomeChart.client.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { eachDayOfInterval, eachWeekOfInterval, eachMonthOfInterval, format } from 'date-fns'
|
||||
import { VisXYContainer, VisLine, VisAxis, VisArea, VisCrosshair, VisTooltip } from '@unovis/vue'
|
||||
import type { Period, Range } from '~/types'
|
||||
|
||||
const cardRef = useTemplateRef<HTMLElement | null>('cardRef')
|
||||
|
||||
const props = defineProps<{
|
||||
period: Period
|
||||
range: Range
|
||||
}>()
|
||||
|
||||
type DataRecord = {
|
||||
date: Date
|
||||
amount: number
|
||||
}
|
||||
|
||||
const { width } = useElementSize(cardRef)
|
||||
|
||||
const data = ref<DataRecord[]>([])
|
||||
|
||||
watch([() => props.period, () => props.range], () => {
|
||||
const dates = ({
|
||||
daily: eachDayOfInterval,
|
||||
weekly: eachWeekOfInterval,
|
||||
monthly: eachMonthOfInterval
|
||||
} as Record<Period, typeof eachDayOfInterval>)[props.period](props.range)
|
||||
|
||||
const min = 1000
|
||||
const max = 10000
|
||||
|
||||
data.value = dates.map(date => ({ date, amount: Math.floor(Math.random() * (max - min + 1)) + min }))
|
||||
}, { immediate: true })
|
||||
|
||||
const x = (_: DataRecord, i: number) => i
|
||||
const y = (d: DataRecord) => d.amount
|
||||
|
||||
const total = computed(() => data.value.reduce((acc: number, { amount }) => acc + amount, 0))
|
||||
|
||||
const formatNumber = new Intl.NumberFormat('en', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return ({
|
||||
daily: format(date, 'd MMM'),
|
||||
weekly: format(date, 'd MMM'),
|
||||
monthly: format(date, 'MMM yyy')
|
||||
})[props.period]
|
||||
}
|
||||
|
||||
const xTicks = (i: number) => {
|
||||
if (i === 0 || i === data.value.length - 1 || !data.value[i]) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatDate(data.value[i].date)
|
||||
}
|
||||
|
||||
const template = (d: DataRecord) => `${formatDate(d.date)}: ${formatNumber(d.amount)}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard ref="cardRef" :ui="{ root: 'overflow-visible', body: '!px-0 !pt-0 !pb-3' }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs text-muted uppercase mb-1.5">
|
||||
Revenue
|
||||
</p>
|
||||
<p class="text-3xl text-highlighted font-semibold">
|
||||
{{ formatNumber(total) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VisXYContainer
|
||||
:data="data"
|
||||
:padding="{ top: 40 }"
|
||||
class="h-96"
|
||||
:width="width"
|
||||
>
|
||||
<VisLine
|
||||
:x="x"
|
||||
:y="y"
|
||||
color="var(--ui-primary)"
|
||||
/>
|
||||
<VisArea
|
||||
:x="x"
|
||||
:y="y"
|
||||
color="var(--ui-primary)"
|
||||
:opacity="0.1"
|
||||
/>
|
||||
|
||||
<VisAxis
|
||||
type="x"
|
||||
:x="x"
|
||||
:tick-format="xTicks"
|
||||
/>
|
||||
|
||||
<VisCrosshair
|
||||
color="var(--ui-primary)"
|
||||
:template="template"
|
||||
/>
|
||||
|
||||
<VisTooltip />
|
||||
</VisXYContainer>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.unovis-xy-container {
|
||||
--vis-crosshair-line-stroke-color: var(--ui-primary);
|
||||
--vis-crosshair-circle-stroke-color: var(--ui-bg);
|
||||
|
||||
--vis-axis-grid-color: var(--ui-border);
|
||||
--vis-axis-tick-color: var(--ui-border);
|
||||
--vis-axis-tick-label-color: var(--ui-text-dimmed);
|
||||
|
||||
--vis-tooltip-background-color: var(--ui-bg);
|
||||
--vis-tooltip-border-color: var(--ui-border);
|
||||
--vis-tooltip-text-color: var(--ui-text-highlighted);
|
||||
}
|
||||
</style>
|
||||
16
app/components/home/HomeChart.server.vue
Normal file
16
app/components/home/HomeChart.server.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<UCard class="shrink-0" :ui="{ body: '!px-0 !pt-0 !pb-3' }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs text-muted uppercase mb-1.5">
|
||||
Revenue
|
||||
</p>
|
||||
<p class="text-3xl text-highlighted font-semibold">
|
||||
---
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="h-96" />
|
||||
</UCard>
|
||||
</template>
|
||||
132
app/components/home/HomeDateRangePicker.vue
Normal file
132
app/components/home/HomeDateRangePicker.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { DateFormatter, getLocalTimeZone, CalendarDate, today } from '@internationalized/date'
|
||||
import type { Range } from '~/types'
|
||||
|
||||
const df = new DateFormatter('en-US', {
|
||||
dateStyle: 'medium'
|
||||
})
|
||||
|
||||
const selected = defineModel<Range>({ required: true })
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', days: 7 },
|
||||
{ label: 'Last 14 days', days: 14 },
|
||||
{ label: 'Last 30 days', days: 30 },
|
||||
{ label: 'Last 3 months', months: 3 },
|
||||
{ label: 'Last 6 months', months: 6 },
|
||||
{ label: 'Last year', years: 1 }
|
||||
]
|
||||
|
||||
const toCalendarDate = (date: Date) => {
|
||||
return new CalendarDate(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
const calendarRange = computed({
|
||||
get: () => ({
|
||||
start: selected.value.start ? toCalendarDate(selected.value.start) : undefined,
|
||||
end: selected.value.end ? toCalendarDate(selected.value.end) : undefined
|
||||
}),
|
||||
set: (newValue: { start: CalendarDate | null, end: CalendarDate | null }) => {
|
||||
selected.value = {
|
||||
start: newValue.start ? newValue.start.toDate(getLocalTimeZone()) : new Date(),
|
||||
end: newValue.end ? newValue.end.toDate(getLocalTimeZone()) : new Date()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isRangeSelected = (range: { days?: number, months?: number, years?: number }) => {
|
||||
if (!selected.value.start || !selected.value.end) return false
|
||||
|
||||
const currentDate = today(getLocalTimeZone())
|
||||
let startDate = currentDate.copy()
|
||||
|
||||
if (range.days) {
|
||||
startDate = startDate.subtract({ days: range.days })
|
||||
} else if (range.months) {
|
||||
startDate = startDate.subtract({ months: range.months })
|
||||
} else if (range.years) {
|
||||
startDate = startDate.subtract({ years: range.years })
|
||||
}
|
||||
|
||||
const selectedStart = toCalendarDate(selected.value.start)
|
||||
const selectedEnd = toCalendarDate(selected.value.end)
|
||||
|
||||
return selectedStart.compare(startDate) === 0 && selectedEnd.compare(currentDate) === 0
|
||||
}
|
||||
|
||||
const selectRange = (range: { days?: number, months?: number, years?: number }) => {
|
||||
const endDate = today(getLocalTimeZone())
|
||||
let startDate = endDate.copy()
|
||||
|
||||
if (range.days) {
|
||||
startDate = startDate.subtract({ days: range.days })
|
||||
} else if (range.months) {
|
||||
startDate = startDate.subtract({ months: range.months })
|
||||
} else if (range.years) {
|
||||
startDate = startDate.subtract({ years: range.years })
|
||||
}
|
||||
|
||||
selected.value = {
|
||||
start: startDate.toDate(getLocalTimeZone()),
|
||||
end: endDate.toDate(getLocalTimeZone())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPopover :content="{ align: 'start' }" :modal="true">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-calendar"
|
||||
class="data-[state=open]:bg-elevated group"
|
||||
>
|
||||
<span class="truncate">
|
||||
<template v-if="selected.start">
|
||||
<template v-if="selected.end">
|
||||
{{ df.format(selected.start) }} - {{ df.format(selected.end) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ df.format(selected.start) }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
Pick a date
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<template #trailing>
|
||||
<UIcon name="i-lucide-chevron-down" class="shrink-0 text-dimmed size-5 group-data-[state=open]:rotate-180 transition-transform duration-200" />
|
||||
</template>
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="flex items-stretch sm:divide-x divide-default">
|
||||
<div class="hidden sm:flex flex-col justify-center">
|
||||
<UButton
|
||||
v-for="(range, index) in ranges"
|
||||
:key="index"
|
||||
:label="range.label"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="rounded-none px-4"
|
||||
:class="[isRangeSelected(range) ? 'bg-elevated' : 'hover:bg-elevated/50']"
|
||||
truncate
|
||||
@click="selectRange(range)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UCalendar
|
||||
v-model="calendarRange"
|
||||
class="p-2"
|
||||
:number-of-months="2"
|
||||
range
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</template>
|
||||
49
app/components/home/HomePeriodSelect.vue
Normal file
49
app/components/home/HomePeriodSelect.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { eachDayOfInterval } from 'date-fns'
|
||||
import type { Period, Range } from '~/types'
|
||||
|
||||
const model = defineModel<Period>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
range: Range
|
||||
}>()
|
||||
|
||||
const days = computed(() => eachDayOfInterval(props.range))
|
||||
|
||||
const periods = computed<Period[]>(() => {
|
||||
if (days.value.length <= 8) {
|
||||
return [
|
||||
'daily'
|
||||
]
|
||||
}
|
||||
|
||||
if (days.value.length <= 31) {
|
||||
return [
|
||||
'daily',
|
||||
'weekly'
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
'weekly',
|
||||
'monthly'
|
||||
]
|
||||
})
|
||||
|
||||
// Ensure the model value is always a valid period
|
||||
watch(periods, () => {
|
||||
if (!periods.value.includes(model.value)) {
|
||||
model.value = periods.value[0]!
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelect
|
||||
v-model="model"
|
||||
:items="periods"
|
||||
variant="ghost"
|
||||
class="data-[state=open]:bg-elevated"
|
||||
:ui="{ value: 'capitalize', itemLabel: 'capitalize', trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200' }"
|
||||
/>
|
||||
</template>
|
||||
112
app/components/home/HomeSales.vue
Normal file
112
app/components/home/HomeSales.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import type { TableColumn } from '@nuxt/ui'
|
||||
import type { Period, Range, Sale } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
period: Period
|
||||
range: Range
|
||||
}>()
|
||||
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
|
||||
const sampleEmails = [
|
||||
'james.anderson@example.com',
|
||||
'mia.white@example.com',
|
||||
'william.brown@example.com',
|
||||
'emma.davis@example.com',
|
||||
'ethan.harris@example.com'
|
||||
]
|
||||
|
||||
const { data } = await useAsyncData('sales', async () => {
|
||||
const sales: Sale[] = []
|
||||
const currentDate = new Date()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const hoursAgo = randomInt(0, 48)
|
||||
const date = new Date(currentDate.getTime() - hoursAgo * 3600000)
|
||||
|
||||
sales.push({
|
||||
id: (4600 - i).toString(),
|
||||
date: date.toISOString(),
|
||||
status: randomFrom(['paid', 'failed', 'refunded']),
|
||||
email: randomFrom(sampleEmails),
|
||||
amount: randomInt(100, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
return sales.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
}, {
|
||||
watch: [() => props.period, () => props.range],
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const columns: TableColumn<Sale>[] = [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
cell: ({ row }) => `#${row.getValue('id')}`
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: 'Date',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.getValue('date')).toLocaleString('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const color = {
|
||||
paid: 'success' as const,
|
||||
failed: 'error' as const,
|
||||
refunded: 'neutral' as const
|
||||
}[row.getValue('status') as string]
|
||||
|
||||
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
|
||||
row.getValue('status')
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email'
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
||||
cell: ({ row }) => {
|
||||
const amount = Number.parseFloat(row.getValue('amount'))
|
||||
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount)
|
||||
|
||||
return h('div', { class: 'text-right font-medium' }, formatted)
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTable
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
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: 'first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
|
||||
td: 'border-b border-default'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
98
app/components/home/HomeStats.vue
Normal file
98
app/components/home/HomeStats.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import type { Period, Range, Stat } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
period: Period
|
||||
range: Range
|
||||
}>()
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return value.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
})
|
||||
}
|
||||
|
||||
const baseStats = [{
|
||||
title: 'Customers',
|
||||
icon: 'i-lucide-users',
|
||||
minValue: 400,
|
||||
maxValue: 1000,
|
||||
minVariation: -15,
|
||||
maxVariation: 25
|
||||
}, {
|
||||
title: 'Conversions',
|
||||
icon: 'i-lucide-chart-pie',
|
||||
minValue: 1000,
|
||||
maxValue: 2000,
|
||||
minVariation: -10,
|
||||
maxVariation: 20
|
||||
}, {
|
||||
title: 'Revenue',
|
||||
icon: 'i-lucide-circle-dollar-sign',
|
||||
minValue: 200000,
|
||||
maxValue: 500000,
|
||||
minVariation: -20,
|
||||
maxVariation: 30,
|
||||
formatter: formatCurrency
|
||||
}, {
|
||||
title: 'Orders',
|
||||
icon: 'i-lucide-shopping-cart',
|
||||
minValue: 100,
|
||||
maxValue: 300,
|
||||
minVariation: -5,
|
||||
maxVariation: 15
|
||||
}]
|
||||
|
||||
const { data: stats } = await useAsyncData<Stat[]>('stats', async () => {
|
||||
return baseStats.map((stat) => {
|
||||
const value = randomInt(stat.minValue, stat.maxValue)
|
||||
const variation = randomInt(stat.minVariation, stat.maxVariation)
|
||||
|
||||
return {
|
||||
title: stat.title,
|
||||
icon: stat.icon,
|
||||
value: stat.formatter ? stat.formatter(value) : value,
|
||||
variation
|
||||
}
|
||||
})
|
||||
}, {
|
||||
watch: [() => props.period, () => props.range],
|
||||
default: () => []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPageGrid class="lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-px">
|
||||
<UPageCard
|
||||
v-for="(stat, index) in stats"
|
||||
:key="index"
|
||||
:icon="stat.icon"
|
||||
:title="stat.title"
|
||||
to="/customers"
|
||||
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'
|
||||
}"
|
||||
class="lg:rounded-none first:rounded-l-lg last:rounded-r-lg hover:z-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl font-semibold text-highlighted">
|
||||
{{ stat.value }}
|
||||
</span>
|
||||
|
||||
<UBadge
|
||||
:color="stat.variation > 0 ? 'success' : 'error'"
|
||||
variant="subtle"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ stat.variation > 0 ? '+' : '' }}{{ stat.variation }}%
|
||||
</UBadge>
|
||||
</div>
|
||||
</UPageCard>
|
||||
</UPageGrid>
|
||||
</template>
|
||||
Reference in New Issue
Block a user