DONE: cleanup unused files, use component for chart
This commit is contained in:
@@ -1,99 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
import type { Chart as ChartInstance } from 'chart.js'
|
||||
|
||||
// Fake visitor data
|
||||
const chartData = new Array(40).fill(0).map((data, key) => {
|
||||
if (key % 3 == 0 || key % 5 == 0) {
|
||||
return Math.ceil(Math.random() * (0 - 20) + 20)
|
||||
} else {
|
||||
return Math.ceil(Math.random() * (0 - 7) + 7)
|
||||
}
|
||||
})
|
||||
|
||||
// Fake visitor bar color
|
||||
const chartColor = () =>
|
||||
chartData.map((data) => {
|
||||
if (data >= 8 && data <= 14) {
|
||||
return getColor('--color-primary', 0.5)
|
||||
} else if (data >= 15) {
|
||||
return getColor('--color-primary', 0.6)
|
||||
}
|
||||
|
||||
return getColor('--color-primary', 0.4)
|
||||
})
|
||||
|
||||
const getRef = (chart: ChartInstance<'bar'>) => {
|
||||
setInterval(() => {
|
||||
const datasets = chart.data.datasets[0]
|
||||
if (datasets) {
|
||||
// Swap visitor data
|
||||
let newData = datasets.data[0]
|
||||
datasets.data.shift()
|
||||
newData !== undefined && datasets.data.push(newData)
|
||||
|
||||
// Swap visitor bar color
|
||||
if (datasets.backgroundColor && Array.isArray(datasets.backgroundColor)) {
|
||||
let newColor = datasets.backgroundColor[0]
|
||||
datasets.backgroundColor.shift()
|
||||
datasets.backgroundColor.push(newColor)
|
||||
}
|
||||
}
|
||||
|
||||
chart.update()
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: chartData,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Html Template',
|
||||
barThickness: 6,
|
||||
data: chartData,
|
||||
backgroundColor: chartColor(),
|
||||
borderColor: () => getColor('--color-primary', 0.1),
|
||||
borderWidth: 0.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
:get-ref="getRef"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,85 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Html Template',
|
||||
barThickness: 8,
|
||||
maxBarThickness: 6,
|
||||
data: [60, 150, 30, 200, 180, 50, 180, 120, 230, 180, 250, 270],
|
||||
backgroundColor: () => getColor('--color-primary', 0.3),
|
||||
borderColor: () => getColor('--color-primary', 0.6),
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
{
|
||||
label: 'VueJs Template',
|
||||
barThickness: 8,
|
||||
maxBarThickness: 6,
|
||||
data: [50, 135, 40, 180, 190, 60, 150, 90, 250, 170, 240, 250],
|
||||
backgroundColor: () => getColor('--color-foreground', 0.2),
|
||||
borderColor: () => getColor('--color-foreground', 0.3),
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
color: () => getColor('--color-foreground', 0.2),
|
||||
},
|
||||
border: {
|
||||
dash: [2, 2],
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { ChartConfiguration } from 'chart.js'
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
import type { BarChartDataset } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[]
|
||||
datasets: BarChartDataset[]
|
||||
showAxes?: boolean
|
||||
class?: string
|
||||
}>(),
|
||||
{
|
||||
showAxes: true,
|
||||
},
|
||||
)
|
||||
|
||||
const config = computed<ChartConfiguration<'bar'>>(() => ({
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets: props.datasets.map((dataset) => {
|
||||
const color = dataset.color ?? '--color-primary'
|
||||
|
||||
return {
|
||||
label: dataset.label ?? '',
|
||||
data: dataset.data,
|
||||
barThickness: dataset.barThickness ?? 8,
|
||||
maxBarThickness: 6,
|
||||
backgroundColor: () => getColor(color, 0.3),
|
||||
borderColor: () => getColor(color, 0.6),
|
||||
borderWidth: 0.5,
|
||||
}
|
||||
}),
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: props.showAxes,
|
||||
font: { size: 11 },
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: props.showAxes,
|
||||
color: () => getColor('--color-foreground', 0.2),
|
||||
},
|
||||
border: {
|
||||
dash: props.showAxes ? [2, 2] : undefined,
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<Chart :config="config" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Html', 'Vuejs', 'Laravel'],
|
||||
datasets: [
|
||||
{
|
||||
data: [15, 10, 65],
|
||||
backgroundColor: [
|
||||
getColor('--color-pending', 0.4),
|
||||
getColor('--color-primary', 0.4),
|
||||
getColor('--color-warning', 0.4),
|
||||
],
|
||||
borderWidth: 1,
|
||||
borderColor: [
|
||||
getColor('--color-pending', 0.9),
|
||||
getColor('--color-primary', 0.9),
|
||||
getColor('--color-warning', 0.9),
|
||||
],
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
cutout: '85%',
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Yellow', 'Dark'],
|
||||
datasets: [
|
||||
{
|
||||
data: [15, 10, 65],
|
||||
backgroundColor: [
|
||||
getColor('--color-pending', 0.4),
|
||||
getColor('--color-warning', 0.4),
|
||||
getColor('--color-primary', 0.4),
|
||||
],
|
||||
borderWidth: 1,
|
||||
borderColor: [
|
||||
getColor('--color-pending', 0.9),
|
||||
getColor('--color-warning', 0.9),
|
||||
getColor('--color-primary', 0.9),
|
||||
],
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
cutout: '83%',
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { ChartConfiguration } from 'chart.js'
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[]
|
||||
values: number[]
|
||||
/** CSS custom property names, e.g. `--color-primary` */
|
||||
colors?: string[]
|
||||
cutout?: string
|
||||
class?: string
|
||||
}>(),
|
||||
{
|
||||
colors: () => [
|
||||
'--color-primary',
|
||||
'--color-pending',
|
||||
'--color-warning',
|
||||
'--color-success',
|
||||
'--color-danger',
|
||||
],
|
||||
cutout: '85%',
|
||||
},
|
||||
)
|
||||
|
||||
const config = computed<ChartConfiguration<'doughnut'>>(() => {
|
||||
const palette = props.values.map(
|
||||
(_, index) => props.colors[index % props.colors.length] ?? '--color-primary',
|
||||
)
|
||||
|
||||
return {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: props.values,
|
||||
backgroundColor: palette.map((token) => getColor(token, 0.4)),
|
||||
borderWidth: 1,
|
||||
borderColor: palette.map((token) => getColor(token, 0.9)),
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
cutout: props.cutout,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<Chart :config="config" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,92 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 200, 250, 200, 700, 550, 650, 1050, 950, 1100, 900, 1200],
|
||||
borderWidth: 1.5,
|
||||
borderColor: () => getColor('--color-primary', 0.5),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 300, 400, 560, 320, 600, 720, 850, 690, 805, 1200, 1010],
|
||||
borderWidth: 1.5,
|
||||
borderDash: [1, 4],
|
||||
borderColor: () => getColor('--color-foreground'),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
callback: function (value, index, values) {
|
||||
return '$' + value
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: () => getColor('--color-foreground', 0.2),
|
||||
},
|
||||
border: {
|
||||
dash: [2, 2],
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 200, 250, 200, 500, 450, 850, 1050, 950, 1100, 900, 1200],
|
||||
borderWidth: 1,
|
||||
borderColor: () => getColor('--color-primary', 0.8),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 200, 250, 200, 700, 550, 650, 1050, 950, 1100, 900, 1200],
|
||||
borderWidth: 1,
|
||||
borderColor: () => getColor('--color-primary', 0.8),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.4,
|
||||
},
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 300, 400, 560, 320, 600, 720, 850, 690, 805, 1200, 1010],
|
||||
borderDash: [2, 2],
|
||||
borderWidth: 1,
|
||||
borderColor: () => getColor('--color-foreground', 0.8),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { ChartConfiguration } from 'chart.js'
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
import type { LineChartDataset } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[]
|
||||
datasets: LineChartDataset[]
|
||||
showAxes?: boolean
|
||||
yTickPrefix?: string
|
||||
class?: string
|
||||
}>(),
|
||||
{
|
||||
showAxes: true,
|
||||
yTickPrefix: '',
|
||||
},
|
||||
)
|
||||
|
||||
const config = computed<ChartConfiguration<'line'>>(() => ({
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets: props.datasets.map((dataset) => {
|
||||
const color = dataset.color ?? '--color-primary'
|
||||
|
||||
return {
|
||||
label: dataset.label ?? '',
|
||||
data: dataset.data,
|
||||
borderWidth: dataset.borderWidth ?? 1.5,
|
||||
borderDash: dataset.dashed ? [2, 2] : undefined,
|
||||
borderColor: () => getColor(color, props.showAxes ? 0.5 : 0.8),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: dataset.tension ?? 0.3,
|
||||
}
|
||||
}),
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: props.showAxes,
|
||||
font: { size: 12 },
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: props.showAxes,
|
||||
font: { size: 12 },
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
callback(value) {
|
||||
return `${props.yTickPrefix}${value}`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
display: props.showAxes,
|
||||
color: () => getColor('--color-foreground', 0.2),
|
||||
},
|
||||
border: {
|
||||
dash: props.showAxes ? [2, 2] : undefined,
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<Chart :config="config" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: ['31 - 50 Years old', '>= 50 Years old', '17 - 30 Years old'],
|
||||
datasets: [
|
||||
{
|
||||
data: [15, 10, 65],
|
||||
backgroundColor: [
|
||||
getColor('--color-pending', 0.4),
|
||||
getColor('--color-warning', 0.4),
|
||||
getColor('--color-primary', 0.4),
|
||||
],
|
||||
borderWidth: 1,
|
||||
borderColor: [
|
||||
getColor('--color-pending', 0.9),
|
||||
getColor('--color-warning', 0.9),
|
||||
getColor('--color-primary', 0.9),
|
||||
],
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { ChartConfiguration } from 'chart.js'
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[]
|
||||
values: number[]
|
||||
/** CSS custom property names, e.g. `--color-primary` */
|
||||
colors?: string[]
|
||||
class?: string
|
||||
}>(),
|
||||
{
|
||||
colors: () => [
|
||||
'--color-primary',
|
||||
'--color-pending',
|
||||
'--color-warning',
|
||||
'--color-success',
|
||||
'--color-danger',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
const config = computed<ChartConfiguration<'pie'>>(() => {
|
||||
const palette = props.values.map(
|
||||
(_, index) => props.colors[index % props.colors.length] ?? '--color-primary',
|
||||
)
|
||||
|
||||
return {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: props.values,
|
||||
backgroundColor: palette.map((token) => getColor(token, 0.4)),
|
||||
borderWidth: 1,
|
||||
borderColor: palette.map((token) => getColor(token, 0.9)),
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<Chart :config="config" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,6 @@
|
||||
export { default as Line1 } from './Line1.vue' //report-line-chart
|
||||
export { default as Line2 } from './Line2.vue' //simple-line-chart-1
|
||||
export { default as Line3 } from './Line3.vue' //simple-line-chart-3, simple-line-chart-4
|
||||
export { default as Pie1 } from './Pie1.vue' //report-pie-chart
|
||||
export { default as Donut1 } from './Donut1.vue' //report-donut-chart
|
||||
export { default as Donut2 } from './Donut2.vue' //report-donut-chart-1
|
||||
export { default as Bar1 } from './Bar1.vue' //report-bar-chart
|
||||
export { default as Bar2 } from './Bar2.vue' //report-bar-chart-1
|
||||
export { default as DonutChart } from './DonutChart.vue'
|
||||
export { default as PieChart } from './PieChart.vue'
|
||||
export { default as LineChart } from './LineChart.vue'
|
||||
export { default as BarChart } from './BarChart.vue'
|
||||
|
||||
export type { LineChartDataset, BarChartDataset } from './types'
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type LineChartDataset = {
|
||||
label?: string
|
||||
data: number[]
|
||||
/** CSS custom property name, e.g. `--color-primary` */
|
||||
color?: string
|
||||
dashed?: boolean
|
||||
tension?: number
|
||||
borderWidth?: number
|
||||
}
|
||||
|
||||
export type BarChartDataset = {
|
||||
label?: string
|
||||
data: number[]
|
||||
/** CSS custom property name, e.g. `--color-primary` */
|
||||
color?: string
|
||||
barThickness?: number
|
||||
}
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -62,7 +62,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Lucide, type Icon } from '@/components/ui/lucide'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const availableModules = computed(() => [
|
||||
{
|
||||
title: 'Profil',
|
||||
description: 'Kemas kini maklumat peribadi, pekerjaan, bank dan waris.',
|
||||
route: 'profile-overview-2',
|
||||
icon: 'User' as Icon,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: 'Permohonan Keahlian',
|
||||
description: 'Urus dan semak permohonan keahlian KOPKB.',
|
||||
route: 'list-membership-applications',
|
||||
icon: 'ClipboardList' as Icon,
|
||||
visible: hasPermission('lihat permohonan keahlian'),
|
||||
},
|
||||
{
|
||||
title: 'Aktiviti',
|
||||
description: 'Lihat dan urus aktiviti serta laporan berkaitan.',
|
||||
route: 'list-activities',
|
||||
icon: 'CalendarDays' as Icon,
|
||||
visible: hasPermission('lihat aktiviti'),
|
||||
},
|
||||
{
|
||||
title: 'Senarai Daftar Anggota',
|
||||
description: 'Pantau dan urus akaun pengguna sistem.',
|
||||
route: 'list-users',
|
||||
icon: 'Users' as Icon,
|
||||
visible: hasPermission('lihat pengguna'),
|
||||
},
|
||||
].filter((module) => module.visible))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="col-span-12">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Modul Tersedia</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Box
|
||||
v-for="module in availableModules"
|
||||
:key="module.route"
|
||||
class="flex h-full flex-col p-5 transition-colors hover:bg-foreground/2"
|
||||
>
|
||||
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Lucide :icon="module.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="text-base font-medium">{{ module.title }}</div>
|
||||
<p class="mt-2 flex-1 text-sm leading-relaxed opacity-70">
|
||||
{{ module.description }}
|
||||
</p>
|
||||
<Button as-child variant="ghost" class="mt-5 w-full border border-foreground/15">
|
||||
<RouterLink :to="{ name: module.route }">
|
||||
Pergi ke {{ module.title }}
|
||||
<Lucide icon="ArrowRight" class="size-4" />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Selamat pagi'
|
||||
if (hour < 18) return 'Selamat petang'
|
||||
return 'Selamat malam'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="col-span-12">
|
||||
<Box class="relative overflow-hidden p-6 sm:p-8">
|
||||
<div class="pointer-events-none absolute -right-10 -top-10 size-40 rounded-full bg-primary/10 blur-2xl" />
|
||||
<div class="relative">
|
||||
<Badge variant="pending" class="mb-4">Beta</Badge>
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ greeting }}{{ authStore.userName ? `, ${authStore.userName}` : '' }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-2xl text-base leading-relaxed opacity-70">
|
||||
Selamat datang ke {{ appName }}. Berikut ringkasan pelaburan anda dan modul yang
|
||||
tersedia untuk digunakan.
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DonutChart } from '@/components/chart-presets'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useMemberInvestmentSummary } from '@/modules/investment/composables/useMemberInvestmentSummary'
|
||||
import {
|
||||
formatInvestmentAmount,
|
||||
investmentSourceLabel,
|
||||
investmentStatusLabel,
|
||||
investmentStatusVariant,
|
||||
} from '@/modules/investment/utils/investment.utils'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const {
|
||||
summary,
|
||||
loading,
|
||||
error,
|
||||
summaryCards,
|
||||
balanceByTypeChart,
|
||||
fetchSummary,
|
||||
} = useMemberInvestmentSummary()
|
||||
|
||||
const canViewInvestmentDetail = computed(() => hasPermission('lihat pelaburan'))
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function goToInvestment(id: string) {
|
||||
if (!canViewInvestmentDetail.value) return
|
||||
router.push({ name: 'view-investment', params: { id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contents">
|
||||
<div class="col-span-12">
|
||||
<div class="mb-4 flex h-10 items-center justify-between gap-3">
|
||||
<h3 class="text-lg font-medium">Ringkasan Pelaburan Saya</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="border border-foreground/15"
|
||||
:disabled="loading"
|
||||
@click="fetchSummary"
|
||||
>
|
||||
<Lucide
|
||||
icon="RefreshCw"
|
||||
class="size-4"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
Muat Semula
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mb-4" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<Box
|
||||
v-for="card in summaryCards"
|
||||
:key="card.key"
|
||||
class="p-5"
|
||||
:class="{ 'opacity-60': loading && !summary }"
|
||||
>
|
||||
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Lucide :icon="card.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="text-sm opacity-70">{{ card.label }}</div>
|
||||
<div class="mt-1 text-2xl font-semibold tracking-tight">
|
||||
{{ loading && !summary ? '…' : card.value }}
|
||||
</div>
|
||||
<p class="mt-2 text-xs opacity-60">{{ card.hint }}</p>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-5">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Akaun Mengikut Jenis</h3>
|
||||
</div>
|
||||
|
||||
<Box class="p-5">
|
||||
<div v-if="loading && !summary" class="text-sm opacity-60">Memuatkan…</div>
|
||||
<div
|
||||
v-else-if="!summary?.accounts.length"
|
||||
class="text-sm leading-relaxed opacity-70"
|
||||
>
|
||||
Tiada akaun pelaburan lagi.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="balanceByTypeChart.hasData" class="mt-3">
|
||||
<DonutChart
|
||||
:key="balanceByTypeChart.chartKey"
|
||||
class="h-[213px]"
|
||||
:labels="balanceByTypeChart.labels"
|
||||
:values="balanceByTypeChart.values"
|
||||
:colors="balanceByTypeChart.colors"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-[213px] items-center justify-center text-sm opacity-70"
|
||||
>
|
||||
Tiada baki semasa untuk dipaparkan.
|
||||
</div>
|
||||
|
||||
<div class="mx-auto mt-8 w-full max-w-sm space-y-4 sm:max-w-none">
|
||||
<div
|
||||
v-for="segment in balanceByTypeChart.segments"
|
||||
:key="segment.id"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 mt-1.5 size-2 shrink-0 rounded-full border"
|
||||
:style="{ '--color': `var(${segment.color})` }"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm">{{ segment.label }}</div>
|
||||
<div class="mt-0.5 text-xs uppercase opacity-60">{{ segment.code }}</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="text-sm font-medium">{{ segment.formatted }}</div>
|
||||
<div class="mt-0.5 text-xs opacity-60">
|
||||
{{ segment.percent }}%
|
||||
· Pending {{ segment.pendingFormatted }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-7">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Transaksi Terkini</h3>
|
||||
</div>
|
||||
|
||||
<Box class="p-5">
|
||||
<div v-if="loading && !summary" class="text-sm opacity-60">Memuatkan…</div>
|
||||
<div
|
||||
v-else-if="!summary?.recent_investments.length"
|
||||
class="text-sm leading-relaxed opacity-70"
|
||||
>
|
||||
Tiada transaksi pelaburan lagi.
|
||||
</div>
|
||||
<ul v-else class="space-y-4">
|
||||
<li
|
||||
v-for="item in summary.recent_investments"
|
||||
:key="item.id"
|
||||
class="flex flex-col gap-3 border-b border-foreground/8 pb-4 last:border-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ item.reference_no }}</div>
|
||||
<div class="mt-0.5 text-xs opacity-60">
|
||||
{{ item.investment_type?.name ?? '-' }}
|
||||
· {{ investmentSourceLabel(item.source) }}
|
||||
· {{ formatDate(item.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<div class="text-sm font-medium">
|
||||
{{ formatInvestmentAmount(item.amount) }}
|
||||
</div>
|
||||
<Badge look="outline" :variant="investmentStatusVariant(item.status)">
|
||||
{{ investmentStatusLabel(item.status) }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-if="canViewInvestmentDetail"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="border border-foreground/15"
|
||||
title="Lihat butiran"
|
||||
@click="goToInvestment(item.id)"
|
||||
>
|
||||
<Lucide icon="Eye" class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import phoneIllustration from '@/assets/images/phone-illustration.svg'
|
||||
import womanIllustration from '@/assets/images/woman-illustration.svg'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { completeOnboarding } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const open = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
open.value = !authStore.user?.onboarding_completed_at
|
||||
})
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
if (authStore.user?.onboarding_completed_at) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await completeOnboarding()
|
||||
if (res.success) {
|
||||
authStore.setUserProfile(res.data)
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next visit if the request failed
|
||||
}
|
||||
}
|
||||
|
||||
async function finishOnboarding() {
|
||||
open.value = false
|
||||
await markOnboardingComplete()
|
||||
await router.push({ name: 'profile-overview-2' })
|
||||
}
|
||||
|
||||
function handleOpenChange(details: { open: boolean }) {
|
||||
open.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
void markOnboardingComplete()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open" @openChange="handleOpenChange">
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogCloseTrigger />
|
||||
<div class="overflow-hidden">
|
||||
<div class="-my-5 -mx-10">
|
||||
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img
|
||||
class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125"
|
||||
:src="phoneIllustration"
|
||||
alt="MyKOPKB"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Sistem MyKOPKB adalah sistem integrasi bagi ahli Koperasi Permodalan Kelantan Berhad (KOPKB).
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex justify-end px-5">
|
||||
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
|
||||
Seterusnya
|
||||
<Lucide icon="MoveRight" />
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img
|
||||
class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125"
|
||||
:src="womanIllustration"
|
||||
alt="MyKOPKB"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Mulakan dengan Profil Anda</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Lengkapkan maklumat peribadi, pekerjaan dan butiran bank.
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
|
||||
<Lucide icon="MoveLeft" /> Kembali
|
||||
</CarouselPrevTrigger>
|
||||
<a
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 font-semibold text-white shadow-md transition-all duration-200 hover:bg-primary/90 hover:shadow-lg active:scale-95 cursor-pointer"
|
||||
@click.prevent="finishOnboarding"
|
||||
>
|
||||
<span>Selesai</span>
|
||||
<Lucide icon="Check" class="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</CarouselRoot>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -1,552 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { today, getLocalTimeZone } from '@internationalized/date'
|
||||
import phoneIllustration from '@/assets/images/phone-illustration.svg'
|
||||
import womanIllustration from '@/assets/images/woman-illustration.svg'
|
||||
import { completeOnboarding } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import {
|
||||
DatePickerRoot,
|
||||
DatePickerControl,
|
||||
DatePickerInput,
|
||||
DatePickerTrigger,
|
||||
DatePickerPositioner,
|
||||
DatePickerContent,
|
||||
DatePickerYearSelect,
|
||||
DatePickerMonthSelect,
|
||||
DatePickerView,
|
||||
DatePickerViewControl,
|
||||
DatePickerPrevTrigger,
|
||||
DatePickerNextTrigger,
|
||||
DatePickerRangeText,
|
||||
DatePickerTable,
|
||||
DatePickerTableHead,
|
||||
DatePickerTableRow,
|
||||
DatePickerTableHeader,
|
||||
DatePickerTableBody,
|
||||
DatePickerTableCell,
|
||||
DatePickerTableCellTrigger,
|
||||
DatePickerContext,
|
||||
} from '@/components/ui/datepicker'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel, FieldGroup } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
MenuPositioner,
|
||||
MenuContent,
|
||||
MenuCheckboxItem,
|
||||
} from '@/components/ui/menu'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
import { Transactions } from '@/components/transactions'
|
||||
import { RecentActivities } from '@/components/recent-activities'
|
||||
import { DailyNotes } from '@/components/daily-notes'
|
||||
import { Schedules } from '@/components/schedules'
|
||||
import { OfficialStores } from '@/components/official-stores'
|
||||
import { WeeklyBestSellers } from '@/components/weekly-best-sellers'
|
||||
import { WeeklyTopProducts } from '@/components/weekly-top-products'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
const smartphone = ref(false)
|
||||
const photography = ref(false)
|
||||
const sport = ref(false)
|
||||
const onboardingDialog = ref(false)
|
||||
const salesReportDate = ref<any[]>([
|
||||
today(getLocalTimeZone()).subtract({ days: 7 }),
|
||||
today(getLocalTimeZone()),
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
onboardingDialog.value = !authStore.user?.onboarding_completed_at
|
||||
})
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
if (authStore.user?.onboarding_completed_at) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await completeOnboarding()
|
||||
if (res.success) {
|
||||
authStore.setUserProfile(res.data)
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next visit if the request failed
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnboardingOpenChange(details: { open: boolean }) {
|
||||
onboardingDialog.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
void markOnboardingComplete()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12 2xl:col-span-9">
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<!-- BEGIN: General Report -->
|
||||
<div class="col-span-12">
|
||||
<div class="flex h-10 items-center">
|
||||
<h2 class="me-5 truncate text-lg font-medium">General Report</h2>
|
||||
<a class="text-primary ms-auto flex items-center gap-3" href="">
|
||||
<Lucide icon="RefreshCcw" /> Refresh
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-5 grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12 sm:col-span-6 xl:col-span-3">
|
||||
<Box raised="single">
|
||||
<div class="flex">
|
||||
<Lucide class="h-7 w-7 stroke-1 drop-shadow [--color:var(--color-primary)]" icon="CircleGauge" />
|
||||
<div class="ms-auto">
|
||||
<Badge variant="success" look="outline" content="12% Higher than last month">
|
||||
12%
|
||||
<Lucide class="ms-0.5" icon="ChevronUp" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-2xl font-medium leading-8">$724,091.47</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">Item Sales</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 xl:col-span-3">
|
||||
<Box raised="single">
|
||||
<div class="flex">
|
||||
<Lucide class="h-7 w-7 stroke-1 [--color:var(--color-pending)]" icon="PanelBottomClose" />
|
||||
<div class="ms-auto">
|
||||
<Badge variant="success" look="outline" content="9% Higher than last month">
|
||||
9%
|
||||
<Lucide class="ms-0.5" icon="ChevronUp" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-2xl font-medium leading-8">21,546</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">New Orders</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 xl:col-span-3">
|
||||
<Box raised="single">
|
||||
<div class="flex">
|
||||
<Lucide class="h-7 w-7 stroke-1 [--color:var(--color-warning)]" icon="Disc3" />
|
||||
<div class="ms-auto">
|
||||
<Badge variant="danger" look="outline" content="7% Lower than last month">
|
||||
7%
|
||||
<Lucide class="ms-0.5" icon="ChevronDown" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-2xl font-medium leading-8">1,524,091</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">Total Products</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 xl:col-span-3">
|
||||
<Box raised="single">
|
||||
<div class="flex">
|
||||
<Lucide class="h-7 w-7 stroke-1 [--color:var(--color-danger)]" icon="Album" />
|
||||
<div class="ms-auto">
|
||||
<Badge variant="success" look="outline" content="41% Higher than last month">
|
||||
41%
|
||||
<Lucide class="ms-0.5" icon="ChevronUp" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-2xl font-medium leading-8">42,924,091</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">Unique Visitors</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END: General Report -->
|
||||
<!-- BEGIN: Sales Report -->
|
||||
<div class="col-span-12 mt-5 lg:col-span-6">
|
||||
<div class="block h-10 items-center sm:flex">
|
||||
<h2 class="me-5 truncate text-lg font-medium">Sales Report</h2>
|
||||
<div class="relative mt-3 sm:ms-auto sm:mt-0">
|
||||
<DatePickerRoot selection-mode="range" :num-of-months="2" :value="salesReportDate"
|
||||
@value-change="(details) => (salesReportDate = details.value)">
|
||||
<DatePickerControl>
|
||||
<DatePickerInput :index="0" />
|
||||
<DatePickerTrigger />
|
||||
</DatePickerControl>
|
||||
<DatePickerPositioner>
|
||||
<DatePickerContent>
|
||||
<DatePickerYearSelect />
|
||||
<DatePickerMonthSelect />
|
||||
<DatePickerViewControl>
|
||||
<DatePickerPrevTrigger />
|
||||
<DatePickerRangeText />
|
||||
<DatePickerNextTrigger />
|
||||
</DatePickerViewControl>
|
||||
<DatePickerView view="day" class="flex-row">
|
||||
<DatePickerContext v-slot="{ datePicker }">
|
||||
<DatePickerTable>
|
||||
<DatePickerTableHead>
|
||||
<DatePickerTableRow>
|
||||
<DatePickerTableHeader v-for="(weekDay, id) in datePicker?.weekDays" :key="id">
|
||||
{{ weekDay.short }}
|
||||
</DatePickerTableHeader>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableHead>
|
||||
<DatePickerTableBody>
|
||||
<DatePickerTableRow v-for="(week, id) in datePicker?.weeks" :key="id">
|
||||
<DatePickerTableCell v-for="(day, id) in week" :key="id" :value="day">
|
||||
<DatePickerTableCellTrigger>
|
||||
{{ day.day }}
|
||||
</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
</DatePickerTable>
|
||||
</DatePickerContext>
|
||||
<DatePickerContext v-slot="{ datePicker }">
|
||||
<DatePickerTable>
|
||||
<DatePickerTableHead>
|
||||
<DatePickerTableRow>
|
||||
<DatePickerTableHeader v-for="(weekDay, id) in datePicker?.weekDays" :key="id">
|
||||
{{ weekDay.short }}
|
||||
</DatePickerTableHeader>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableHead>
|
||||
<DatePickerTableBody>
|
||||
<DatePickerTableRow v-for="(week, id) in datePicker?.getOffset({
|
||||
months: 1,
|
||||
}).weeks" :key="id">
|
||||
<DatePickerTableCell v-for="(day, id) in week" :key="id" :value="day"
|
||||
:visible-range="datePicker?.getOffset({ months: 1 }).visibleRange">
|
||||
<DatePickerTableCellTrigger>
|
||||
{{ day.day }}
|
||||
</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
</DatePickerTable>
|
||||
</DatePickerContext>
|
||||
</DatePickerView>
|
||||
</DatePickerContent>
|
||||
</DatePickerPositioner>
|
||||
</DatePickerRoot>
|
||||
</div>
|
||||
</div>
|
||||
<Box class="mt-12 p-5 sm:mt-5">
|
||||
<div class="flex flex-col md:flex-row md:items-center">
|
||||
<div class="flex">
|
||||
<div>
|
||||
<div class="text-lg font-medium">$24,100,21</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">This Month</div>
|
||||
</div>
|
||||
<div class="border-foreground/20 mx-4 h-12 w-px border border-r border-dotted xl:mx-6"></div>
|
||||
<div class="text-foreground/80">
|
||||
<div class="text-lg font-medium">$21,023,01</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">Last Month</div>
|
||||
</div>
|
||||
</div>
|
||||
<MenuRoot class="w-auto mt-5 md:ms-auto md:mt-0">
|
||||
<MenuTrigger>Filter by Category</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent>
|
||||
<MenuCheckboxItem :checked="pc" :onCheckedChange="(checked) => (pc = checked)" value="checked">
|
||||
PC & Laptop
|
||||
</MenuCheckboxItem>
|
||||
<MenuCheckboxItem :checked="smartphone" :onCheckedChange="(checked) => (smartphone = checked)"
|
||||
value="checked">
|
||||
Smartphone
|
||||
</MenuCheckboxItem>
|
||||
<MenuCheckboxItem :checked="electronic" :onCheckedChange="(checked) => (electronic = checked)"
|
||||
value="checked">
|
||||
Electronic
|
||||
</MenuCheckboxItem>
|
||||
<MenuCheckboxItem :checked="photography" :onCheckedChange="(checked) => (photography = checked)"
|
||||
value="checked">
|
||||
Photography
|
||||
</MenuCheckboxItem>
|
||||
<MenuCheckboxItem :checked="sport" :onCheckedChange="(checked) => (sport = checked)"
|
||||
value="checked">
|
||||
Sport
|
||||
</MenuCheckboxItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
<Line1 class="mt-6 h-[275px]" />
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Sales Report -->
|
||||
<!-- BEGIN: Weekly Top Seller -->
|
||||
<div class="col-span-12 mt-5 sm:col-span-6 lg:col-span-3">
|
||||
<div class="flex h-10 items-center">
|
||||
<h2 class="me-5 truncate text-lg font-medium">Weekly Top Seller</h2>
|
||||
<a class="text-primary ms-auto truncate" href=""> Show More </a>
|
||||
</div>
|
||||
<Box class="mt-5 p-5">
|
||||
<div class="mt-3">
|
||||
<Pie1 class="h-[213px]" />
|
||||
</div>
|
||||
<div class="mx-auto mt-8 w-52 sm:w-auto">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]">
|
||||
</div>
|
||||
<span class="truncate">17 - 30 Years old</span>
|
||||
<span class="ms-auto">62%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]">
|
||||
</div>
|
||||
<span class="truncate">31 - 50 Years old</span>
|
||||
<span class="ms-auto">33%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]">
|
||||
</div>
|
||||
<span class="truncate">>= 50 Years old</span>
|
||||
<span class="ms-auto">10%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Weekly Top Seller -->
|
||||
<!-- BEGIN: Sales Report -->
|
||||
<div class="col-span-12 mt-5 sm:col-span-6 lg:col-span-3">
|
||||
<div class="flex h-10 items-center">
|
||||
<h2 class="me-5 truncate text-lg font-medium">Sales Report</h2>
|
||||
<a class="text-primary ms-auto truncate" href=""> Show More </a>
|
||||
</div>
|
||||
<Box class="mt-5 p-5">
|
||||
<div class="mt-3">
|
||||
<Donut1 class="h-[213px]" />
|
||||
</div>
|
||||
<div class="mx-auto mt-8 w-52 sm:w-auto">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]">
|
||||
</div>
|
||||
<span class="truncate">17 - 30 Years old</span>
|
||||
<span class="ms-auto">62%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]">
|
||||
</div>
|
||||
<span class="truncate">31 - 50 Years old</span>
|
||||
<span class="ms-auto">33%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]">
|
||||
</div>
|
||||
<span class="truncate">>= 50 Years old</span>
|
||||
<span class="ms-auto">10%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Sales Report -->
|
||||
<!-- BEGIN: Official Store -->
|
||||
<div class="col-span-12 mt-6 xl:col-span-8">
|
||||
<OfficialStores />
|
||||
</div>
|
||||
<!-- END: Official Store -->
|
||||
<!-- BEGIN: Weekly Best Sellers -->
|
||||
<div class="col-span-12 mt-6 xl:col-span-4">
|
||||
<WeeklyBestSellers />
|
||||
</div>
|
||||
<!-- END: Weekly Best Sellers -->
|
||||
<!-- BEGIN: General Report -->
|
||||
<div class="col-span-12 mt-8 grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2/4 flex-none">
|
||||
<div class="truncate text-lg font-medium">Target Sales</div>
|
||||
<div class="mt-1 opacity-70">300 Sales</div>
|
||||
</div>
|
||||
<div class="relative ms-auto flex-none">
|
||||
<Donut2 class="w-[90px] h-[90px]" />
|
||||
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
|
||||
20%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="me-3 truncate text-lg font-medium">Social Media</div>
|
||||
<Badge variant="secondary" look="outline" class="ms-auto whitespace-nowrap">137 Sales</Badge>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<Line2 class="-ms-1 h-[58px]" />
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2/4 flex-none">
|
||||
<div class="truncate text-lg font-medium">New Products</div>
|
||||
<div class="mt-1 opacity-70">1450 Products</div>
|
||||
</div>
|
||||
<div class="relative ms-auto flex-none">
|
||||
<Donut2 class="w-[90px] h-[90px]" />
|
||||
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
|
||||
45%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="me-3 truncate text-lg font-medium">Posted Ads</div>
|
||||
<Badge variant="secondary" look="outline" class="ms-auto whitespace-nowrap">180 Campaign</Badge>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<Line2 class="-ms-1 h-[58px]" />
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END: General Report -->
|
||||
<!-- BEGIN: Weekly Top Products -->
|
||||
<div class="col-span-12 mt-6">
|
||||
<WeeklyTopProducts />
|
||||
</div>
|
||||
<!-- END: Weekly Top Products -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 2xl:col-span-3">
|
||||
<div class="-mb-10 h-full pb-10 2xl:border-l border-foreground/15">
|
||||
<div class="grid grid-cols-12 gap-x-6 gap-y-6 2xl:gap-x-0 2xl:pl-6">
|
||||
<!-- BEGIN: Transactions -->
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4 2xl:col-span-12">
|
||||
<Transactions />
|
||||
</div>
|
||||
<!-- END: Transactions -->
|
||||
<!-- BEGIN: Recent Activities -->
|
||||
<div class="col-span-12 mt-3 md:col-span-6 xl:col-span-4 2xl:col-span-12">
|
||||
<RecentActivities />
|
||||
</div>
|
||||
<!-- END: Recent Activities -->
|
||||
<!-- BEGIN: Daily Notes -->
|
||||
<div
|
||||
class="col-span-12 mt-3 md:col-span-6 xl:col-span-12 xl:col-start-1 xl:row-start-1 2xl:col-start-auto 2xl:row-start-auto">
|
||||
<DailyNotes />
|
||||
</div>
|
||||
<!-- END: Daily Notes -->
|
||||
<!-- BEGIN: Schedules -->
|
||||
<div
|
||||
class="col-span-12 mt-3 md:col-span-6 xl:col-span-4 xl:col-start-1 xl:row-start-2 2xl:col-span-12 2xl:col-start-auto 2xl:row-start-auto">
|
||||
<Schedules />
|
||||
</div>
|
||||
<!-- END: Schedules -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BEGIN: Onboarding Dialog -->
|
||||
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogCloseTrigger />
|
||||
<div class="overflow-hidden">
|
||||
<div class="-my-5 -mx-10">
|
||||
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="phoneIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Sistem MyKOPKB adalah sistem yang membantu pengguna untuk menguruskan permohonan keahlian KOPKB.
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<a class="text-danger flex items-center gap-3 font-medium cursor-pointer"
|
||||
@click.prevent="onboardingDialog = false">
|
||||
Skip Intro
|
||||
</a>
|
||||
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
|
||||
Next
|
||||
<Lucide icon="MoveRight" />
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="womanIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="text-center text-xl font-medium">Example Request Information</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Your premium admin dashboard template.
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<FieldGroup class="px-5">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<Field>
|
||||
<FieldLabel>Full Name</FieldLabel>
|
||||
<Input type="text" placeholder="John Doe" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Event</FieldLabel>
|
||||
<NativeSelect>
|
||||
<NativeSelectOption>Corporate Event</NativeSelectOption>
|
||||
<NativeSelectOption>Wedding</NativeSelectOption>
|
||||
<NativeSelectOption>Birthday</NativeSelectOption>
|
||||
<NativeSelectOption>Other</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
|
||||
<Lucide icon="MoveLeft" /> Previous
|
||||
</CarouselPrevTrigger>
|
||||
<a class="text-primary flex items-center gap-3 font-medium cursor-pointer me-0 px-5"
|
||||
@click.prevent="onboardingDialog = false">
|
||||
Selesai
|
||||
<Lucide icon="MoveRight" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</CarouselRoot>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
<!-- END: Onboarding Dialog -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,111 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import phoneIllustration from '@/assets/images/phone-illustration.svg'
|
||||
import womanIllustration from '@/assets/images/woman-illustration.svg'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Lucide, type Icon } from '@/components/ui/lucide'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { completeOnboarding } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const onboardingDialog = ref(false)
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import AvailableModules from '../components/AvailableModules.vue'
|
||||
import DashboardGreeting from '../components/DashboardGreeting.vue'
|
||||
import MemberInvestmentSummary from '../components/MemberInvestmentSummary.vue'
|
||||
import OnboardingDialog from '../components/OnboardingDialog.vue'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Selamat pagi'
|
||||
if (hour < 18) return 'Selamat petang'
|
||||
return 'Selamat malam'
|
||||
})
|
||||
|
||||
const availableModules = computed(() => [
|
||||
{
|
||||
title: 'Profil',
|
||||
description: 'Kemas kini maklumat peribadi, pekerjaan, bank dan waris.',
|
||||
route: 'profile-overview-2',
|
||||
icon: 'User' as Icon,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: 'Permohonan Keahlian',
|
||||
description: 'Urus dan semak permohonan keahlian KOPKB.',
|
||||
route: 'list-membership-applications',
|
||||
icon: 'ClipboardList' as Icon,
|
||||
visible: hasPermission('lihat permohonan keahlian'),
|
||||
},
|
||||
{
|
||||
title: 'Aktiviti',
|
||||
description: 'Lihat dan urus aktiviti serta laporan berkaitan.',
|
||||
route: 'list-activities',
|
||||
icon: 'CalendarDays' as Icon,
|
||||
visible: hasPermission('lihat aktiviti'),
|
||||
},
|
||||
{
|
||||
title: 'Senarai Daftar Anggota',
|
||||
description: 'Pantau dan urus akaun pengguna sistem.',
|
||||
route: 'list-users',
|
||||
icon: 'Users' as Icon,
|
||||
visible: hasPermission('lihat pengguna'),
|
||||
},
|
||||
].filter((module) => module.visible))
|
||||
|
||||
const upcomingFeatures = [
|
||||
'Ringkasan statistik keahlian',
|
||||
'Carta dan laporan bulanan',
|
||||
'Notifikasi dan aktiviti terkini',
|
||||
'Papan pemuka pentadbir',
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
onboardingDialog.value = !authStore.user?.onboarding_completed_at
|
||||
})
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
if (authStore.user?.onboarding_completed_at) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await completeOnboarding()
|
||||
if (res.success) {
|
||||
authStore.setUserProfile(res.data)
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next visit if the request failed
|
||||
}
|
||||
}
|
||||
|
||||
async function finishOnboarding() {
|
||||
onboardingDialog.value = false
|
||||
await markOnboardingComplete()
|
||||
await router.push({ name: 'profile-overview-2' })
|
||||
}
|
||||
|
||||
function handleOnboardingOpenChange(details: { open: boolean }) {
|
||||
onboardingDialog.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
void markOnboardingComplete()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -120,139 +22,11 @@ function handleOnboardingOpenChange(details: { open: boolean }) {
|
||||
</AlertRoot>
|
||||
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12">
|
||||
<Box class="relative overflow-hidden p-6 sm:p-8">
|
||||
<div class="pointer-events-none absolute -right-10 -top-10 size-40 rounded-full bg-primary/10 blur-2xl" />
|
||||
<div class="relative">
|
||||
<Badge variant="pending" class="mb-4">Beta</Badge>
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ greeting }}{{ authStore.userName ? `, ${authStore.userName}` : '' }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-2xl text-base leading-relaxed opacity-70">
|
||||
Selamat datang ke {{ appName }}. Papan pemuka utama sedang dibangunkan — buat masa ini,
|
||||
anda boleh mula dengan mengemas kini profil atau mengurus permohonan keahlian.
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-7">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Modul Tersedia</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Box v-for="module in availableModules" :key="module.route"
|
||||
class="flex h-full flex-col p-5 transition-colors hover:bg-foreground/2">
|
||||
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Lucide :icon="module.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="text-base font-medium">{{ module.title }}</div>
|
||||
<p class="mt-2 flex-1 text-sm leading-relaxed opacity-70">
|
||||
{{ module.description }}
|
||||
</p>
|
||||
<Button as-child variant="ghost" class="mt-5 w-full border border-foreground/15">
|
||||
<RouterLink :to="{ name: module.route }">
|
||||
Pergi ke {{ module.title }}
|
||||
<Lucide icon="ArrowRight" class="size-4" />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-5">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Akan Datang</h3>
|
||||
</div>
|
||||
|
||||
<Box class="p-5">
|
||||
<p class="text-sm leading-relaxed opacity-70">
|
||||
Ciri-ciri berikut sedang dirancang untuk papan pemuka ini:
|
||||
</p>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li v-for="feature in upcomingFeatures" :key="feature" class="flex items-start gap-3 text-sm">
|
||||
<Lucide icon="CircleDashed" class="mt-0.5 size-4 shrink-0 text-primary/70" />
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
|
||||
<Box class="mt-4 p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<Lucide icon="Lightbulb" class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">Petua</div>
|
||||
<p class="mt-1 text-sm leading-relaxed opacity-70">
|
||||
Pastikan profil anda lengkap dan terkini. Maklumat yang
|
||||
tepat membantu proses kelulusan berjalan lebih lancar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<DashboardGreeting />
|
||||
<MemberInvestmentSummary />
|
||||
<AvailableModules />
|
||||
</div>
|
||||
|
||||
<!-- BEGIN: Onboarding Dialog -->
|
||||
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogCloseTrigger />
|
||||
<div class="overflow-hidden">
|
||||
<div class="-my-5 -mx-10">
|
||||
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="phoneIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Sistem MyKOPKB adalah sistem integrasi bagi ahli Koperasi Permodalan Kelantan Berhad (KOPKB).
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex justify-end px-5">
|
||||
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
|
||||
Seterusnya
|
||||
<Lucide icon="MoveRight" />
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="womanIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Mulakan dengan Profil Anda</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Lengkapkan maklumat peribadi, pekerjaan dan butiran bank.
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
|
||||
<Lucide icon="MoveLeft" /> Kembali
|
||||
</CarouselPrevTrigger>
|
||||
<a class="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 font-semibold text-white shadow-md transition-all duration-200 hover:bg-primary/90 hover:shadow-lg active:scale-95 cursor-pointer"
|
||||
@click.prevent="finishOnboarding">
|
||||
<span>Selesai</span>
|
||||
<Lucide icon="Check" class="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</CarouselRoot>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
<!-- END: Onboarding Dialog -->
|
||||
<OnboardingDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { Trash } from '@lucide/vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { input as inputStyles } from '@/components/ui/styles/input.styles'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
@@ -16,21 +18,33 @@ import {
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@mykopkb/core/utils/cn'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUsers } from '@/modules/user/services/user.service'
|
||||
import type { UserListItem } from '@/modules/user/types/user.types'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import type { ManualInvestmentPayload } from '../types/investment.types'
|
||||
import type {
|
||||
ManualInvestmentFiles,
|
||||
ManualInvestmentPayload,
|
||||
} from '../types/investment.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
type StagedDocumentFile = {
|
||||
id: string
|
||||
file: File
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE_MB = 10
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
const props = defineProps<{
|
||||
saving?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: ManualInvestmentPayload]
|
||||
submit: [payload: ManualInvestmentPayload, files: ManualInvestmentFiles]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
@@ -41,6 +55,9 @@ const form = reactive({
|
||||
remarks: '',
|
||||
})
|
||||
|
||||
const documentFiles = ref<StagedDocumentFile[]>([])
|
||||
const documentError = ref<string | null>(null)
|
||||
|
||||
const memberSearch = ref('')
|
||||
const memberResults = ref<UserListItem[]>([])
|
||||
const selectedMember = ref<UserListItem | null>(null)
|
||||
@@ -149,15 +166,47 @@ function clearMember() {
|
||||
memberResults.value = []
|
||||
}
|
||||
|
||||
function onDocumentFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
const oversized = files.find((file) => file.size > MAX_FILE_SIZE_BYTES)
|
||||
if (oversized) {
|
||||
documentError.value = `Saiz fail melebihi had maksimum ${MAX_FILE_SIZE_MB}MB.`
|
||||
return
|
||||
}
|
||||
|
||||
documentError.value = null
|
||||
documentFiles.value.push(
|
||||
...files.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function removeDocumentFile(id: string) {
|
||||
documentFiles.value = documentFiles.value.filter((file) => file.id !== id)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const amount = Number(form.amount)
|
||||
|
||||
emit('submit', {
|
||||
user_id: form.user_id,
|
||||
investment_type_id: form.investment_type_id,
|
||||
amount: Number.isNaN(amount) ? 0 : amount,
|
||||
remarks: form.remarks.trim() || null,
|
||||
})
|
||||
emit(
|
||||
'submit',
|
||||
{
|
||||
user_id: form.user_id,
|
||||
investment_type_id: form.investment_type_id,
|
||||
amount: Number.isNaN(amount) ? 0 : amount,
|
||||
remarks: form.remarks.trim() || null,
|
||||
},
|
||||
{
|
||||
documents: documentFiles.value.map((item) => item.file),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -268,6 +317,54 @@ onMounted(() => {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="space-y-3 rounded-lg border border-foreground/10 p-4">
|
||||
<div>
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Pilihan. Tambah bukti atau dokumen sokongan (PDF, JPG, PNG, DOC, DOCX).
|
||||
Saiz maksimum setiap fail: {{ MAX_FILE_SIZE_MB }}MB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="documentFiles.length" class="space-y-2">
|
||||
<div
|
||||
v-for="document in documentFiles"
|
||||
:key="document.id"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-foreground/10 p-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ document.file.name }}</div>
|
||||
<div class="text-xs opacity-70">{{ document.file.type || 'Dokumen' }}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="props.saving"
|
||||
@click="removeDocumentFile(document.id)"
|
||||
>
|
||||
<Trash class="mr-2 size-4" />
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="manual-investment-documents">Tambah Dokumen</FieldLabel>
|
||||
<input
|
||||
id="manual-investment-documents"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,application/pdf,image/*"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="props.saving"
|
||||
@change="onDocumentFilesChange"
|
||||
/>
|
||||
<FieldError v-if="documentError">{{ documentError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
look="outline"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Icon } from '@/components/ui/lucide'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getMemberInvestmentSummary } from '../services/investment.service'
|
||||
import type { MemberInvestmentSummary } from '../types/investment.types'
|
||||
import { formatInvestmentAmount } from '../utils/investment.utils'
|
||||
|
||||
const CHART_COLOR_TOKENS = [
|
||||
'--color-primary',
|
||||
'--color-pending',
|
||||
'--color-warning',
|
||||
'--color-success',
|
||||
'--color-danger',
|
||||
] as const
|
||||
|
||||
export function useMemberInvestmentSummary(options?: { immediate?: boolean }) {
|
||||
const immediate = options?.immediate ?? true
|
||||
|
||||
const summary = ref<MemberInvestmentSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const summaryCards = computed(() => {
|
||||
const data = summary.value
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'current',
|
||||
label: 'Baki Semasa',
|
||||
value: formatInvestmentAmount(data?.total_current_balance),
|
||||
hint: `${data?.account_count ?? 0} akaun pelaburan`,
|
||||
icon: 'Wallet' as Icon,
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: 'Dalam Proses',
|
||||
value: formatInvestmentAmount(data?.total_pending_balance),
|
||||
hint: `${data?.pending_count ?? 0} transaksi menunggu`,
|
||||
icon: 'Hourglass' as Icon,
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
label: 'Transaksi Selesai',
|
||||
value: String(data?.completed_count ?? 0),
|
||||
hint: `${data?.transaction_count ?? 0} jumlah transaksi`,
|
||||
icon: 'CircleCheck' as Icon,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const balanceByTypeChart = computed(() => {
|
||||
const accounts = summary.value?.accounts ?? []
|
||||
const total = accounts.reduce(
|
||||
(sum, account) => sum + (Number(account.current_balance) || 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const segments = accounts.map((account, index) => {
|
||||
const value = Number(account.current_balance) || 0
|
||||
const color = CHART_COLOR_TOKENS[index % CHART_COLOR_TOKENS.length]
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
label: account.investment_type?.name ?? 'Jenis tidak diketahui',
|
||||
code: account.investment_type?.code ?? '-',
|
||||
value,
|
||||
color,
|
||||
percent: total > 0 ? Math.round((value / total) * 100) : 0,
|
||||
formatted: formatInvestmentAmount(account.current_balance),
|
||||
pendingFormatted: formatInvestmentAmount(account.pending_balance),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels: segments.map((segment) => segment.label),
|
||||
values: segments.map((segment) => segment.value),
|
||||
colors: segments.map((segment) => segment.color),
|
||||
segments,
|
||||
total,
|
||||
hasData: segments.some((segment) => segment.value > 0),
|
||||
chartKey: segments.map((segment) => `${segment.id}:${segment.value}`).join('|'),
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchSummary() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
summary.value = await getMemberInvestmentSummary()
|
||||
} catch (err) {
|
||||
summary.value = null
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan ringkasan pelaburan.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
onMounted(() => {
|
||||
void fetchSummary()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
loading,
|
||||
error,
|
||||
summaryCards,
|
||||
balanceByTypeChart,
|
||||
fetchSummary,
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Download, Eye } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getInvestment } from '../services/investment.service'
|
||||
import type { Investment } from '../types/investment.types'
|
||||
import {
|
||||
downloadInvestmentDocument,
|
||||
fetchInvestmentDocument,
|
||||
getInvestment,
|
||||
} from '../services/investment.service'
|
||||
import type { Investment, InvestmentDocument } from '../types/investment.types'
|
||||
import {
|
||||
formatInvestmentAmount,
|
||||
investmentSourceLabel,
|
||||
@@ -23,11 +28,43 @@ const investment = ref<Investment | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const documentPreviewOpen = ref(false)
|
||||
const previewDocument = ref<InvestmentDocument | null>(null)
|
||||
const documentPreviewUrl = ref<string | null>(null)
|
||||
const documentPreviewLoading = ref(false)
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
|
||||
const documents = computed(() => investment.value?.documents ?? [])
|
||||
|
||||
const isDocumentPreviewImage = computed(
|
||||
() => previewDocument.value?.mime_type?.startsWith('image/') ?? false,
|
||||
)
|
||||
|
||||
const isDocumentPreviewPdf = computed(
|
||||
() =>
|
||||
previewDocument.value?.mime_type === 'application/pdf' ||
|
||||
(previewDocument.value?.name.toLowerCase().endsWith('.pdf') ?? false),
|
||||
)
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function revokeDocumentPreviewUrl() {
|
||||
if (documentPreviewUrl.value) {
|
||||
window.URL.revokeObjectURL(documentPreviewUrl.value)
|
||||
documentPreviewUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInvestment() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -42,6 +79,54 @@ async function loadInvestment() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: InvestmentDocument) {
|
||||
if (!investment.value || downloadingDocumentId.value) return
|
||||
|
||||
downloadingDocumentId.value = document.id
|
||||
|
||||
try {
|
||||
await downloadInvestmentDocument(
|
||||
investment.value.id,
|
||||
document.id,
|
||||
document.name,
|
||||
document.mime_type,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat turun dokumen.')
|
||||
} finally {
|
||||
downloadingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDocument(document: InvestmentDocument) {
|
||||
if (!investment.value) return
|
||||
|
||||
previewDocument.value = document
|
||||
documentPreviewOpen.value = true
|
||||
documentPreviewLoading.value = true
|
||||
revokeDocumentPreviewUrl()
|
||||
|
||||
try {
|
||||
const blob = await fetchInvestmentDocument(
|
||||
investment.value.id,
|
||||
document.id,
|
||||
document.mime_type,
|
||||
)
|
||||
documentPreviewUrl.value = window.URL.createObjectURL(blob)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan pratonton dokumen.')
|
||||
documentPreviewOpen.value = false
|
||||
} finally {
|
||||
documentPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocumentPreview() {
|
||||
documentPreviewOpen.value = false
|
||||
previewDocument.value = null
|
||||
revokeDocumentPreviewUrl()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-investments' })
|
||||
}
|
||||
@@ -49,6 +134,10 @@ function goBack() {
|
||||
onMounted(() => {
|
||||
loadInvestment()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeDocumentPreviewUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -137,6 +226,60 @@ onMounted(() => {
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="p-6">
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
{{ documents.length }} dokumen · muat turun atau lihat
|
||||
</p>
|
||||
|
||||
<div v-if="!documents.length" class="mt-5 opacity-70">
|
||||
Tiada dokumen lampiran.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-3">
|
||||
<div
|
||||
v-for="document in documents"
|
||||
:key="document.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">{{ document.name }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">
|
||||
{{ formatFileSize(document.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="documentPreviewLoading && previewDocument?.id === document.id"
|
||||
@click="handleViewDocument(document)"
|
||||
>
|
||||
<Eye class="mr-2 size-4" />
|
||||
{{
|
||||
documentPreviewLoading && previewDocument?.id === document.id
|
||||
? 'Memuatkan...'
|
||||
: 'Lihat'
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="downloadingDocumentId === document.id"
|
||||
@click="handleDownloadDocument(document)"
|
||||
>
|
||||
<Download class="mr-2 size-4" />
|
||||
{{
|
||||
downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="investment.ledger_entries?.length" class="p-6">
|
||||
<h3 class="text-base font-medium">Entri Ledger</h3>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
@@ -189,5 +332,81 @@ onMounted(() => {
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="documentPreviewOpen"
|
||||
class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/80"
|
||||
aria-label="Tutup pratonton"
|
||||
@click="closeDocumentPreview"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-10 flex h-[min(90vh,920px)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-2xl"
|
||||
>
|
||||
<div class="shrink-0 border-b border-foreground/10 px-5 py-4">
|
||||
<div class="text-lg font-medium">
|
||||
{{ previewDocument?.name ?? 'Pratonton Dokumen' }}
|
||||
</div>
|
||||
<div v-if="previewDocument" class="mt-1 text-sm opacity-70">
|
||||
{{ formatFileSize(previewDocument.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto p-5">
|
||||
<div v-if="documentPreviewLoading" class="flex h-full items-center justify-center opacity-70">
|
||||
Memuatkan dokumen...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="documentPreviewUrl && isDocumentPreviewImage"
|
||||
class="flex h-full items-center justify-center"
|
||||
>
|
||||
<img
|
||||
:src="documentPreviewUrl"
|
||||
:alt="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
class="block h-auto max-h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
v-else-if="documentPreviewUrl && isDocumentPreviewPdf"
|
||||
:src="documentPreviewUrl"
|
||||
class="block h-full w-full rounded-lg border border-foreground/10"
|
||||
:title="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
/>
|
||||
|
||||
<div v-else-if="documentPreviewUrl" class="flex h-full items-center justify-center opacity-70">
|
||||
Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-foreground/10 px-5 py-4">
|
||||
<Button type="button" look="outline" @click="closeDocumentPreview">
|
||||
Tutup
|
||||
</Button>
|
||||
<Button
|
||||
v-if="previewDocument"
|
||||
type="button"
|
||||
look="outline"
|
||||
:disabled="downloadingDocumentId === previewDocument.id"
|
||||
@click="handleDownloadDocument(previewDocument)"
|
||||
>
|
||||
<Download class="mr-2 size-4" />
|
||||
{{
|
||||
downloadingDocumentId === previewDocument.id ? 'Memuat turun...' : 'Muat Turun'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import ManualInvestmentForm from '../components/ManualInvestmentForm.vue'
|
||||
import { createManualInvestment } from '../services/investment.service'
|
||||
import type { ManualInvestmentPayload } from '../types/investment.types'
|
||||
import type {
|
||||
ManualInvestmentFiles,
|
||||
ManualInvestmentPayload,
|
||||
} from '../types/investment.types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -15,13 +18,13 @@ const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit(payload: ManualInvestmentPayload) {
|
||||
async function handleSubmit(payload: ManualInvestmentPayload, files: ManualInvestmentFiles = {}) {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const investment = await createManualInvestment(payload)
|
||||
const investment = await createManualInvestment(payload, files)
|
||||
successMessage.value = 'Pelaburan manual berjaya direkodkan.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'view-investment', params: { id: investment.id } })
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
Investment,
|
||||
ListInvestmentsParams,
|
||||
ManualInvestmentFiles,
|
||||
ManualInvestmentPayload,
|
||||
MemberInvestmentSummary,
|
||||
} from '../types/investment.types'
|
||||
|
||||
type InvestmentApiResponse = {
|
||||
@@ -12,6 +14,36 @@ type InvestmentApiResponse = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
type MemberInvestmentSummaryApiResponse = {
|
||||
success: boolean
|
||||
data: MemberInvestmentSummary
|
||||
message?: string
|
||||
}
|
||||
|
||||
function appendIfPresent(formData: FormData, key: string, value: string | null | undefined) {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildManualInvestmentFormData(
|
||||
payload: ManualInvestmentPayload,
|
||||
files: ManualInvestmentFiles = {},
|
||||
): FormData {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('user_id', payload.user_id)
|
||||
formData.append('investment_type_id', payload.investment_type_id)
|
||||
formData.append('amount', String(payload.amount))
|
||||
appendIfPresent(formData, 'remarks', payload.remarks ?? undefined)
|
||||
|
||||
files.documents?.forEach((file, index) => {
|
||||
formData.append(`documents[${index}]`, file)
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
export async function listInvestments(
|
||||
params?: ListInvestmentsParams,
|
||||
): Promise<PaginatedApiResponse<Investment>> {
|
||||
@@ -36,10 +68,27 @@ export async function getInvestment(id: string): Promise<Investment> {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function getMemberInvestmentSummary(): Promise<MemberInvestmentSummary> {
|
||||
const { data } = await api.get<MemberInvestmentSummaryApiResponse>('/v1/investments/summary')
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan ringkasan pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createManualInvestment(
|
||||
payload: ManualInvestmentPayload,
|
||||
files: ManualInvestmentFiles = {},
|
||||
): Promise<Investment> {
|
||||
const { data } = await api.post<InvestmentApiResponse>('/v1/investments/manual', payload)
|
||||
const formData = buildManualInvestmentFormData(payload, files)
|
||||
|
||||
const { data } = await api.post<InvestmentApiResponse>('/v1/investments/manual', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal merekod pelaburan manual.')
|
||||
@@ -47,3 +96,36 @@ export async function createManualInvestment(
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchInvestmentDocument(
|
||||
investmentId: string,
|
||||
documentId: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<Blob> {
|
||||
const response = await api.get(
|
||||
`/v1/investments/${investmentId}/documents/${documentId}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
|
||||
const contentType =
|
||||
mimeType ||
|
||||
(typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
|
||||
'application/octet-stream'
|
||||
|
||||
return new Blob([response.data], { type: contentType })
|
||||
}
|
||||
|
||||
export async function downloadInvestmentDocument(
|
||||
investmentId: string,
|
||||
documentId: string,
|
||||
fileName: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<void> {
|
||||
const blob = await fetchInvestmentDocument(investmentId, documentId, mimeType)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,17 @@ export type InvestmentPaymentSummary = {
|
||||
} | null
|
||||
}
|
||||
|
||||
export type InvestmentDocument = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
description: string | null
|
||||
mime_type: string | null
|
||||
file_size: number | null
|
||||
uploaded_by: string | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export type Investment = {
|
||||
id: string
|
||||
reference_no: string
|
||||
@@ -84,6 +95,7 @@ export type Investment = {
|
||||
approved_by_user?: { id: string; name: string; email: string } | null
|
||||
ledger_entries?: InvestmentLedgerEntry[]
|
||||
payment?: InvestmentPaymentSummary | null
|
||||
documents?: InvestmentDocument[]
|
||||
}
|
||||
|
||||
export type ManualInvestmentPayload = {
|
||||
@@ -93,6 +105,10 @@ export type ManualInvestmentPayload = {
|
||||
remarks?: string | null
|
||||
}
|
||||
|
||||
export type ManualInvestmentFiles = {
|
||||
documents?: File[]
|
||||
}
|
||||
|
||||
export type ListInvestmentsParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
@@ -104,3 +120,35 @@ export type ListInvestmentsParams = {
|
||||
user_id?: string
|
||||
investment_type_id?: string
|
||||
}
|
||||
|
||||
export type MemberInvestmentAccountSummary = {
|
||||
id: string
|
||||
investment_type_id: string
|
||||
current_balance: string | number
|
||||
pending_balance: string | number
|
||||
status: string
|
||||
investment_type?: InvestmentTypeSummary | null
|
||||
}
|
||||
|
||||
export type MemberRecentInvestment = {
|
||||
id: string
|
||||
reference_no: string
|
||||
amount: string | number
|
||||
source: InvestmentSource | string
|
||||
status: InvestmentStatus | string
|
||||
created_at: string | null
|
||||
completed_at: string | null
|
||||
investment_type?: InvestmentTypeSummary | null
|
||||
}
|
||||
|
||||
export type MemberInvestmentSummary = {
|
||||
total_current_balance: string | number
|
||||
total_pending_balance: string | number
|
||||
account_count: number
|
||||
transaction_count: number
|
||||
completed_count: number
|
||||
pending_count: number
|
||||
status_counts: Record<InvestmentStatus, number>
|
||||
accounts: MemberInvestmentAccountSummary[]
|
||||
recent_investments: MemberRecentInvestment[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user