Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82665e576a | |||
| 1b7df24ee9 | |||
| 8f1dddb80a | |||
| 007cf1f804 |
@@ -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 DonutChart } from './DonutChart.vue'
|
||||||
export { default as Line2 } from './Line2.vue' //simple-line-chart-1
|
export { default as PieChart } from './PieChart.vue'
|
||||||
export { default as Line3 } from './Line3.vue' //simple-line-chart-3, simple-line-chart-4
|
export { default as LineChart } from './LineChart.vue'
|
||||||
export { default as Pie1 } from './Pie1.vue' //report-pie-chart
|
export { default as BarChart } from './BarChart.vue'
|
||||||
export { default as Donut1 } from './Donut1.vue' //report-donut-chart
|
|
||||||
export { default as Donut2 } from './Donut2.vue' //report-donut-chart-1
|
export type { LineChartDataset, BarChartDataset } from './types'
|
||||||
export { default as Bar1 } from './Bar1.vue' //report-bar-chart
|
|
||||||
export { default as Bar2 } from './Bar2.vue' //report-bar-chart-1
|
|
||||||
|
|||||||
@@ -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'
|
} from '@/components/ui/pagination'
|
||||||
import { Map } from '@/components/ui/map'
|
import { Map } from '@/components/ui/map'
|
||||||
import { Lucide } from '@/components/ui/lucide'
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
|
||||||
|
|
||||||
const pc = ref(false)
|
const pc = ref(false)
|
||||||
const electronic = ref(false)
|
const electronic = ref(false)
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import {
|
|||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
import { Map } from '@/components/ui/map'
|
import { Map } from '@/components/ui/map'
|
||||||
import { Lucide } from '@/components/ui/lucide'
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
|
||||||
|
|
||||||
const pc = ref(false)
|
const pc = ref(false)
|
||||||
const electronic = ref(false)
|
const electronic = ref(false)
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import {
|
|||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
import { Map } from '@/components/ui/map'
|
import { Map } from '@/components/ui/map'
|
||||||
import { Lucide } from '@/components/ui/lucide'
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
|
||||||
|
|
||||||
const pc = ref(false)
|
const pc = ref(false)
|
||||||
const electronic = ref(false)
|
const electronic = ref(false)
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import {
|
|||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
import { Map } from '@/components/ui/map'
|
import { Map } from '@/components/ui/map'
|
||||||
import { Lucide } from '@/components/ui/lucide'
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
|
||||||
|
|
||||||
const pc = ref(false)
|
const pc = ref(false)
|
||||||
const electronic = ref(false)
|
const electronic = ref(false)
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import {
|
|||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
import { Map } from '@/components/ui/map'
|
import { Map } from '@/components/ui/map'
|
||||||
import { Lucide } from '@/components/ui/lucide'
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
|
||||||
|
|
||||||
const pc = ref(false)
|
const pc = ref(false)
|
||||||
const electronic = ref(false)
|
const electronic = ref(false)
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ import { cn } from "@mykopkb/core/utils/cn";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { fieldLabel } from "@mykopkb/core/styles/field.styles";
|
import { fieldLabel } from "@mykopkb/core/styles/field.styles";
|
||||||
|
|
||||||
const { class: className, ...props } = defineProps<{
|
const {
|
||||||
|
class: className,
|
||||||
|
required = false,
|
||||||
|
...props
|
||||||
|
} = defineProps<{
|
||||||
class?: string;
|
class?: string;
|
||||||
|
required?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Label data-part="field-label" :class="cn(fieldLabel, className)" v-bind="{ ...props }">
|
<Label data-part="field-label" :class="cn(fieldLabel, className)" v-bind="{ ...props }">
|
||||||
<slot />
|
<slot />
|
||||||
|
<span v-if="required" class="text-danger" aria-hidden="true">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export const selectIndicator =
|
|||||||
"ms-auto transition data-[state=open]:rotate-180";
|
"ms-auto transition data-[state=open]:rotate-180";
|
||||||
export const selectClearTrigger =
|
export const selectClearTrigger =
|
||||||
"text-danger/90 cursor-pointer text-xs hidden ms-1";
|
"text-danger/90 cursor-pointer text-xs hidden ms-1";
|
||||||
export const selectPositioner = "!z-50 !w-auto";
|
/* Above dialog/sheet (z-70) so portaled menus remain visible inside modals */
|
||||||
|
export const selectPositioner = "!z-80 !w-auto";
|
||||||
export const selectContent =
|
export const selectContent =
|
||||||
"min-w-(--reference-width) p-0 z-(--layer-index) [&>div]:p-4 [&>div]:max-h-78 [&>div]:overflow-y-auto [&>div]:flex [&>div]:flex-col [&>div]:gap-5";
|
"min-w-(--reference-width) p-0 z-(--layer-index) [&>div]:p-4 [&>div]:max-h-78 [&>div]:overflow-y-auto [&>div]:flex [&>div]:flex-col [&>div]:gap-5";
|
||||||
export const selectItemGroup = "flex flex-col gap-3";
|
export const selectItemGroup = "flex flex-col gap-3";
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import {
|
|||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
import { Map } from '@/components/ui/map'
|
import { Map } from '@/components/ui/map'
|
||||||
import { Lucide } from '@/components/ui/lucide'
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
|
||||||
|
|
||||||
const pc = ref(false)
|
const pc = ref(false)
|
||||||
const electronic = ref(false)
|
const electronic = ref(false)
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import {
|
|||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
import { Map } from '@/components/ui/map'
|
import { Map } from '@/components/ui/map'
|
||||||
import { Lucide } from '@/components/ui/lucide'
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
|
||||||
|
|
||||||
const pc = ref(false)
|
const pc = ref(false)
|
||||||
const electronic = ref(false)
|
const electronic = ref(false)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { dashboardMenu } from '@/modules/dashboard/menu'
|
|||||||
import { externalSystemMenu } from '@/modules/external-system/menu'
|
import { externalSystemMenu } from '@/modules/external-system/menu'
|
||||||
import { activityLogMenu } from '@/modules/activity-log/menu'
|
import { activityLogMenu } from '@/modules/activity-log/menu'
|
||||||
import { feedbackMenu } from '@/modules/feedback'
|
import { feedbackMenu } from '@/modules/feedback'
|
||||||
|
import { investmentMenu } from '@/modules/investment'
|
||||||
|
|
||||||
export type { Menu }
|
export type { Menu }
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ const mainMenu: (string | Menu)[] = [
|
|||||||
'Pentadbiran',
|
'Pentadbiran',
|
||||||
...membershipApplicationMenu,
|
...membershipApplicationMenu,
|
||||||
...userMenu,
|
...userMenu,
|
||||||
|
'Akaun',
|
||||||
|
...investmentMenu,
|
||||||
]
|
]
|
||||||
|
|
||||||
export default mainMenu
|
export default mainMenu
|
||||||
|
|||||||
@@ -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>
|
<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 { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
import { Box } from '@/components/ui/box'
|
import AvailableModules from '../components/AvailableModules.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import DashboardGreeting from '../components/DashboardGreeting.vue'
|
||||||
import {
|
import MemberInvestmentSummary from '../components/MemberInvestmentSummary.vue'
|
||||||
CarouselRoot,
|
import OnboardingDialog from '../components/OnboardingDialog.vue'
|
||||||
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)
|
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME
|
const appName = import.meta.env.VITE_APP_NAME
|
||||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -120,139 +22,11 @@ function handleOnboardingOpenChange(details: { open: boolean }) {
|
|||||||
</AlertRoot>
|
</AlertRoot>
|
||||||
|
|
||||||
<div class="grid grid-cols-12 gap-6">
|
<div class="grid grid-cols-12 gap-6">
|
||||||
<div class="col-span-12">
|
<DashboardGreeting />
|
||||||
<Box class="relative overflow-hidden p-6 sm:p-8">
|
<MemberInvestmentSummary />
|
||||||
<div class="pointer-events-none absolute -right-10 -top-10 size-40 rounded-full bg-primary/10 blur-2xl" />
|
<AvailableModules />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- BEGIN: Onboarding Dialog -->
|
<OnboardingDialog />
|
||||||
<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 -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
SelectControl,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemText,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
INVESTMENT_TYPE_STATUS_OPTIONS,
|
||||||
|
toOptionalNumber,
|
||||||
|
} from '../utils/investment-type.utils'
|
||||||
|
import type {
|
||||||
|
InvestmentType,
|
||||||
|
InvestmentTypePayload,
|
||||||
|
InvestmentTypeStatus,
|
||||||
|
} from '../types/investment-type.types'
|
||||||
|
|
||||||
|
type SelectOption = { label: string; value: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initial?: InvestmentType | null
|
||||||
|
saving?: boolean
|
||||||
|
submitLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [payload: InvestmentTypePayload]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
status: 'ACTIVE' as InvestmentTypeStatus,
|
||||||
|
minimum_amount: '0',
|
||||||
|
maximum_amount: '',
|
||||||
|
total_fund_limit: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
allow_billplz: true,
|
||||||
|
allow_salary_deduction: false,
|
||||||
|
billplz_collection_id: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusCollection = select.collection({
|
||||||
|
items: INVESTMENT_TYPE_STATUS_OPTIONS as SelectOption[],
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusInitial = computed(() => {
|
||||||
|
const option = INVESTMENT_TYPE_STATUS_OPTIONS.find((item) => item.value === form.status)
|
||||||
|
return option ? [option.label] : ['Aktif']
|
||||||
|
})
|
||||||
|
|
||||||
|
function labelToStatus(label: string | undefined): InvestmentTypeStatus {
|
||||||
|
return (
|
||||||
|
INVESTMENT_TYPE_STATUS_OPTIONS.find((item) => item.label === label)?.value ?? 'ACTIVE'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatusValue(details: { value: string[] }) {
|
||||||
|
form.status = labelToStatus(details.value[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFromInitial(item: InvestmentType | null | undefined) {
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
form.code = item.code ?? ''
|
||||||
|
form.name = item.name ?? ''
|
||||||
|
form.description = item.description ?? ''
|
||||||
|
form.status = item.status ?? 'ACTIVE'
|
||||||
|
form.minimum_amount = item.minimum_amount == null ? '0' : String(item.minimum_amount)
|
||||||
|
form.maximum_amount = item.maximum_amount == null ? '' : String(item.maximum_amount)
|
||||||
|
form.total_fund_limit = item.total_fund_limit == null ? '' : String(item.total_fund_limit)
|
||||||
|
form.start_date = item.start_date ?? ''
|
||||||
|
form.end_date = item.end_date ?? ''
|
||||||
|
form.allow_billplz = Boolean(item.allow_billplz)
|
||||||
|
form.allow_salary_deduction = Boolean(item.allow_salary_deduction)
|
||||||
|
form.billplz_collection_id = item.billplz_collection_id ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initial,
|
||||||
|
(value) => {
|
||||||
|
syncFromInitial(value)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const minimumAmount = Number(form.minimum_amount)
|
||||||
|
const payload: InvestmentTypePayload = {
|
||||||
|
code: form.code.trim(),
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
status: form.status,
|
||||||
|
minimum_amount: Number.isNaN(minimumAmount) ? 0 : minimumAmount,
|
||||||
|
maximum_amount: toOptionalNumber(form.maximum_amount),
|
||||||
|
total_fund_limit: toOptionalNumber(form.total_fund_limit),
|
||||||
|
start_date: form.start_date || null,
|
||||||
|
end_date: form.end_date || null,
|
||||||
|
allow_billplz: form.allow_billplz,
|
||||||
|
allow_salary_deduction: form.allow_salary_deduction,
|
||||||
|
billplz_collection_id: form.billplz_collection_id.trim() || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', payload)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="investment-type-code" required>Kod</FieldLabel>
|
||||||
|
<Input id="investment-type-code" v-model="form.code" class="w-full" type="text" placeholder="Contoh: GOLD_FUND"
|
||||||
|
:disabled="saving" required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="investment-type-name" required>Nama</FieldLabel>
|
||||||
|
<Input id="investment-type-name" v-model="form.name" class="w-full" type="text" placeholder="Contoh: Dana Emas"
|
||||||
|
:disabled="saving" required />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="investment-type-description">Penerangan</FieldLabel>
|
||||||
|
<Textarea id="investment-type-description" v-model="form.description" class="w-full" rows="3"
|
||||||
|
placeholder="Penerangan produk pelaburan" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Status</FieldLabel>
|
||||||
|
<SelectRoot :key="statusInitial[0]" class="w-full" :collection="statusCollection" :default-value="statusInitial"
|
||||||
|
:disabled="saving" @value-change="setStatusValue">
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValueText placeholder="Pilih status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="investment-type-billplz-collection">Billplz Collection ID</FieldLabel>
|
||||||
|
<Input id="investment-type-billplz-collection" v-model="form.billplz_collection_id" class="w-full" type="text"
|
||||||
|
placeholder="Pilihan (kosong = koleksi global)" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="investment-type-min" required>Jumlah Minimum (MYR)</FieldLabel>
|
||||||
|
<Input id="investment-type-min" v-model="form.minimum_amount" class="w-full" type="number" min="0" step="0.01"
|
||||||
|
:disabled="saving" required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="investment-type-max">Jumlah Maksimum (MYR)</FieldLabel>
|
||||||
|
<Input id="investment-type-max" v-model="form.maximum_amount" class="w-full" type="number" min="0" step="0.01"
|
||||||
|
placeholder="Pilihan" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="investment-type-fund-limit">Had Dana (MYR)</FieldLabel>
|
||||||
|
<Input id="investment-type-fund-limit" v-model="form.total_fund_limit" class="w-full" type="number" min="0"
|
||||||
|
step="0.01" placeholder="Pilihan" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="investment-type-start">Tarikh Mula</FieldLabel>
|
||||||
|
<Input id="investment-type-start" v-model="form.start_date" class="w-full" type="date" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="investment-type-end">Tarikh Tamat</FieldLabel>
|
||||||
|
<Input id="investment-type-end" v-model="form.end_date" class="w-full" type="date" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<CheckboxRoot :checked="form.allow_billplz" :disabled="saving"
|
||||||
|
@checked-change="({ checked }) => (form.allow_billplz = checked === true)">
|
||||||
|
<CheckboxControl />
|
||||||
|
<CheckboxLabel>Benarkan pembayaran Billplz</CheckboxLabel>
|
||||||
|
</CheckboxRoot>
|
||||||
|
|
||||||
|
<CheckboxRoot :checked="form.allow_salary_deduction" :disabled="saving"
|
||||||
|
@checked-change="({ checked }) => (form.allow_salary_deduction = checked === true)">
|
||||||
|
<CheckboxControl />
|
||||||
|
<CheckboxLabel>Benarkan potongan gaji</CheckboxLabel>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pt-2">
|
||||||
|
<Button type="button" look="outline" variant="secondary" :disabled="saving" @click="emit('cancel')">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" look="outline" variant="primary" :disabled="saving">
|
||||||
|
{{ saving ? 'Menyimpan...' : submitLabel || 'Simpan' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<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, 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,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItem,
|
||||||
|
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 {
|
||||||
|
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, files: ManualInvestmentFiles]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
user_id: '',
|
||||||
|
investment_type_id: '',
|
||||||
|
amount: '',
|
||||||
|
remarks: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const documentFiles = ref<StagedDocumentFile[]>([])
|
||||||
|
const documentError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const memberSearch = ref('')
|
||||||
|
const memberResults = ref<UserListItem[]>([])
|
||||||
|
const selectedMember = ref<UserListItem | null>(null)
|
||||||
|
const searchingMembers = ref(false)
|
||||||
|
const memberSearchError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const investmentTypes = ref<InvestmentType[]>([])
|
||||||
|
const loadingTypes = ref(false)
|
||||||
|
const typesError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const typeOptions = computed<SelectOption[]>(() =>
|
||||||
|
investmentTypes.value.map((type) => ({
|
||||||
|
label: `${type.code} — ${type.name}`,
|
||||||
|
value: type.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeCollection = computed(() =>
|
||||||
|
select.collection({
|
||||||
|
items: typeOptions.value,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeInitial = computed(() => {
|
||||||
|
const option = typeOptions.value.find((item) => item.value === form.investment_type_id)
|
||||||
|
return option ? [option.label] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
function setTypeValue(details: { value: string[] }) {
|
||||||
|
const label = details.value[0]
|
||||||
|
form.investment_type_id =
|
||||||
|
typeOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvestmentTypes() {
|
||||||
|
loadingTypes.value = true
|
||||||
|
typesError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listInvestmentTypes({
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
})
|
||||||
|
investmentTypes.value = data.data.filter((type) => type.status === 'ACTIVE')
|
||||||
|
} catch (err) {
|
||||||
|
typesError.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||||
|
investmentTypes.value = []
|
||||||
|
} finally {
|
||||||
|
loadingTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMembers(page = 1) {
|
||||||
|
const query = memberSearch.value.trim()
|
||||||
|
if (query.length < 2) {
|
||||||
|
memberResults.value = []
|
||||||
|
memberSearchError.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchingMembers.value = true
|
||||||
|
memberSearchError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listUsers({
|
||||||
|
page,
|
||||||
|
per_page: 8,
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
search: query,
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
memberResults.value = data.data
|
||||||
|
} catch (err) {
|
||||||
|
memberSearchError.value = getApiErrorMessage(err, 'Gagal mencari ahli.')
|
||||||
|
memberResults.value = []
|
||||||
|
} finally {
|
||||||
|
searchingMembers.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedMemberSearch = debounce(() => {
|
||||||
|
searchMembers(1)
|
||||||
|
}, 350)
|
||||||
|
|
||||||
|
watch(memberSearch, () => {
|
||||||
|
if (selectedMember.value) return
|
||||||
|
debouncedMemberSearch()
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectMember(member: UserListItem) {
|
||||||
|
selectedMember.value = member
|
||||||
|
form.user_id = member.id
|
||||||
|
memberSearch.value = `${member.name} (${member.ic_number})`
|
||||||
|
memberResults.value = []
|
||||||
|
memberSearchError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMember() {
|
||||||
|
selectedMember.value = null
|
||||||
|
form.user_id = ''
|
||||||
|
memberSearch.value = ''
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
documents: documentFiles.value.map((item) => item.file),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadInvestmentTypes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Ahli</FieldLabel>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
v-model="memberSearch"
|
||||||
|
type="search"
|
||||||
|
placeholder="Cari nama, e-mel atau no. KP..."
|
||||||
|
:disabled="props.saving || Boolean(selectedMember)"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="selectedMember"
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 text-sm opacity-70 hover:opacity-100"
|
||||||
|
:disabled="props.saving"
|
||||||
|
@click="clearMember"
|
||||||
|
>
|
||||||
|
Tukar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="searchingMembers" class="mt-1 text-xs opacity-60">Mencari ahli...</p>
|
||||||
|
<p v-if="memberSearchError" class="mt-1 text-xs text-danger">{{ memberSearchError }}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="memberResults.length && !selectedMember"
|
||||||
|
class="mt-2 max-h-56 overflow-y-auto rounded-md border border-foreground/10"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="member in memberResults"
|
||||||
|
:key="member.id"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full flex-col items-start px-3 py-2 text-left text-sm hover:bg-foreground/5"
|
||||||
|
@click="selectMember(member)"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ member.name }}</span>
|
||||||
|
<span class="text-xs opacity-60">
|
||||||
|
{{ member.ic_number }}
|
||||||
|
<span v-if="member.member_number"> · No. {{ member.member_number }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||||
|
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan jenis pelaburan...</p>
|
||||||
|
<!-- Mount after load: SelectRoot/zag machine keeps initial disabled/collection -->
|
||||||
|
<SelectRoot
|
||||||
|
v-else-if="typeOptions.length"
|
||||||
|
:key="typeOptions.map((item) => item.value).join(',')"
|
||||||
|
class="w-full"
|
||||||
|
:collection="typeCollection"
|
||||||
|
:default-value="typeInitial"
|
||||||
|
:disabled="props.saving"
|
||||||
|
@value-change="setTypeValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||||
|
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in typeCollection.items"
|
||||||
|
:key="item.value"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
<p v-if="typesError" class="mt-1 text-xs text-danger">{{ typesError }}</p>
|
||||||
|
<p v-else-if="!loadingTypes && !typeOptions.length" class="mt-1 text-xs opacity-60">
|
||||||
|
Tiada jenis pelaburan aktif.
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Jumlah (MYR)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
v-model="form.amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
:disabled="props.saving"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Catatan</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
v-model="form.remarks"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Catatan pilihan..."
|
||||||
|
:disabled="props.saving"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="props.saving"
|
||||||
|
@click="emit('cancel')"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="
|
||||||
|
props.saving ||
|
||||||
|
!form.user_id ||
|
||||||
|
!form.investment_type_id ||
|
||||||
|
!form.amount ||
|
||||||
|
Number(form.amount) <= 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ props.saving ? 'Menyimpan...' : 'Rekod Pelaburan' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import debounce from 'lodash/debounce'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { listUsers } from '@/modules/user/services/user.service'
|
||||||
|
import type { UserListItem } from '@/modules/user/types/user.types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
selectedLabel?: string
|
||||||
|
disabled?: boolean
|
||||||
|
required?: boolean
|
||||||
|
label?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
select: [member: UserListItem]
|
||||||
|
clear: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const memberSearch = ref(props.selectedLabel ?? '')
|
||||||
|
const memberResults = ref<UserListItem[]>([])
|
||||||
|
const searchingMembers = ref(false)
|
||||||
|
const memberSearchError = ref<string | null>(null)
|
||||||
|
const hasSelection = ref(Boolean(props.modelValue))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selectedLabel,
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
memberSearch.value = value
|
||||||
|
hasSelection.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function searchMembers() {
|
||||||
|
const query = memberSearch.value.trim()
|
||||||
|
if (query.length < 2 || hasSelection.value) {
|
||||||
|
memberResults.value = []
|
||||||
|
memberSearchError.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchingMembers.value = true
|
||||||
|
memberSearchError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listUsers({
|
||||||
|
page: 1,
|
||||||
|
per_page: 8,
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
search: query,
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
memberResults.value = data.data
|
||||||
|
} catch (err) {
|
||||||
|
memberSearchError.value = getApiErrorMessage(err, 'Gagal mencari ahli.')
|
||||||
|
memberResults.value = []
|
||||||
|
} finally {
|
||||||
|
searchingMembers.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedMemberSearch = debounce(() => {
|
||||||
|
searchMembers()
|
||||||
|
}, 350)
|
||||||
|
|
||||||
|
watch(memberSearch, () => {
|
||||||
|
if (hasSelection.value) return
|
||||||
|
debouncedMemberSearch()
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectMember(member: UserListItem) {
|
||||||
|
hasSelection.value = true
|
||||||
|
memberSearch.value = `${member.name} (${member.ic_number})`
|
||||||
|
memberResults.value = []
|
||||||
|
memberSearchError.value = null
|
||||||
|
emit('update:modelValue', member.id)
|
||||||
|
emit('select', member)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMember() {
|
||||||
|
hasSelection.value = false
|
||||||
|
memberSearch.value = ''
|
||||||
|
memberResults.value = []
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
emit('clear')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel :required="required">{{ label ?? 'Ahli' }}</FieldLabel>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
v-model="memberSearch"
|
||||||
|
type="search"
|
||||||
|
placeholder="Cari nama, e-mel atau no. KP..."
|
||||||
|
:disabled="disabled || hasSelection"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="hasSelection"
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 text-sm opacity-70 hover:opacity-100"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="clearMember"
|
||||||
|
>
|
||||||
|
Tukar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="searchingMembers" class="mt-1 text-xs opacity-60">Mencari ahli...</p>
|
||||||
|
<p v-if="memberSearchError" class="mt-1 text-xs text-danger">{{ memberSearchError }}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="memberResults.length && !hasSelection"
|
||||||
|
class="mt-2 max-h-56 overflow-y-auto rounded-md border border-foreground/10"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="member in memberResults"
|
||||||
|
:key="member.id"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full flex-col items-start px-3 py-2 text-left text-sm hover:bg-foreground/5"
|
||||||
|
@click="selectMember(member)"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ member.name }}</span>
|
||||||
|
<span class="text-xs opacity-60">
|
||||||
|
{{ member.ic_number }}
|
||||||
|
<span v-if="member.member_number"> · No. {{ member.member_number }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
SelectControl,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemText,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import MemberSearchField from './MemberSearchField.vue'
|
||||||
|
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||||
|
import type { InvestmentType } from '../types/investment-type.types'
|
||||||
|
import type {
|
||||||
|
SalaryContribution,
|
||||||
|
SalaryContributionPayload,
|
||||||
|
SalaryContributionStatus,
|
||||||
|
} from '../types/salary.types'
|
||||||
|
import { SALARY_CONTRIBUTION_STATUS_OPTIONS } from '../utils/salary.utils'
|
||||||
|
|
||||||
|
type SelectOption = { label: string; value: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initial?: SalaryContribution | null
|
||||||
|
saving?: boolean
|
||||||
|
submitLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [payload: SalaryContributionPayload]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
user_id: '',
|
||||||
|
investment_type_id: '',
|
||||||
|
monthly_amount: '',
|
||||||
|
start_date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
end_date: '',
|
||||||
|
status: 'ACTIVE' as SalaryContributionStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
const investmentTypes = ref<InvestmentType[]>([])
|
||||||
|
const loadingTypes = ref(false)
|
||||||
|
const typesError = ref<string | null>(null)
|
||||||
|
const selectedMemberLabel = ref('')
|
||||||
|
|
||||||
|
const isEdit = computed(() => Boolean(props.initial))
|
||||||
|
|
||||||
|
const typeOptions = computed<SelectOption[]>(() =>
|
||||||
|
investmentTypes.value
|
||||||
|
.filter((type) => type.status === 'ACTIVE' && type.allow_salary_deduction)
|
||||||
|
.map((type) => ({
|
||||||
|
label: `${type.code} — ${type.name}`,
|
||||||
|
value: type.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeCollection = computed(() =>
|
||||||
|
select.collection({
|
||||||
|
items: typeOptions.value,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeInitial = computed(() => {
|
||||||
|
const option = typeOptions.value.find((item) => item.value === form.investment_type_id)
|
||||||
|
return option ? [option.label] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusCollection = select.collection({
|
||||||
|
items: SALARY_CONTRIBUTION_STATUS_OPTIONS as SelectOption[],
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusInitial = computed(() => {
|
||||||
|
const option = SALARY_CONTRIBUTION_STATUS_OPTIONS.find((item) => item.value === form.status)
|
||||||
|
return option ? [option.label] : ['Aktif']
|
||||||
|
})
|
||||||
|
|
||||||
|
function setTypeValue(details: { value: string[] }) {
|
||||||
|
form.investment_type_id =
|
||||||
|
typeOptions.value.find((item) => item.label === details.value[0])?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatusValue(details: { value: string[] }) {
|
||||||
|
form.status =
|
||||||
|
SALARY_CONTRIBUTION_STATUS_OPTIONS.find((item) => item.label === details.value[0])?.value
|
||||||
|
?? 'ACTIVE'
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFromInitial(item: SalaryContribution | null | undefined) {
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
form.user_id = item.user_id
|
||||||
|
form.investment_type_id = item.investment_type_id
|
||||||
|
form.monthly_amount = String(item.monthly_amount)
|
||||||
|
form.start_date = item.start_date
|
||||||
|
form.end_date = item.end_date ?? ''
|
||||||
|
form.status = item.status
|
||||||
|
selectedMemberLabel.value = item.user
|
||||||
|
? `${item.user.name}${item.user.ic_number ? ` (${item.user.ic_number})` : ''}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initial,
|
||||||
|
(value) => {
|
||||||
|
syncFromInitial(value)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadInvestmentTypes() {
|
||||||
|
loadingTypes.value = true
|
||||||
|
typesError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listInvestmentTypes({
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
})
|
||||||
|
investmentTypes.value = data.data
|
||||||
|
} catch (err) {
|
||||||
|
typesError.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||||
|
investmentTypes.value = []
|
||||||
|
} finally {
|
||||||
|
loadingTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const amount = Number(form.monthly_amount)
|
||||||
|
emit('submit', {
|
||||||
|
user_id: form.user_id,
|
||||||
|
investment_type_id: form.investment_type_id,
|
||||||
|
monthly_amount: Number.isNaN(amount) ? 0 : amount,
|
||||||
|
start_date: form.start_date,
|
||||||
|
end_date: form.end_date || null,
|
||||||
|
status: form.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadInvestmentTypes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||||
|
<MemberSearchField
|
||||||
|
v-model="form.user_id"
|
||||||
|
:selected-label="selectedMemberLabel"
|
||||||
|
:disabled="saving || isEdit"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||||
|
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan jenis pelaburan...</p>
|
||||||
|
<SelectRoot
|
||||||
|
v-else-if="typeOptions.length"
|
||||||
|
:key="`${form.investment_type_id}-${typeOptions.map((item) => item.value).join(',')}`"
|
||||||
|
class="w-full"
|
||||||
|
:collection="typeCollection"
|
||||||
|
:default-value="typeInitial"
|
||||||
|
:disabled="saving"
|
||||||
|
@value-change="setTypeValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||||
|
<SelectValueText placeholder="Pilih jenis (benarkan potongan gaji)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in typeCollection.items"
|
||||||
|
:key="item.value"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
<p v-if="typesError" class="mt-1 text-xs text-danger">{{ typesError }}</p>
|
||||||
|
<p v-else-if="!loadingTypes && !typeOptions.length" class="mt-1 text-xs opacity-60">
|
||||||
|
Tiada jenis pelaburan aktif yang membenarkan potongan gaji.
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Jumlah Bulanan (MYR)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
v-model="form.monthly_amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Tarikh Mula</FieldLabel>
|
||||||
|
<Input v-model="form.start_date" type="date" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Tarikh Tamat</FieldLabel>
|
||||||
|
<Input v-model="form.end_date" type="date" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Status</FieldLabel>
|
||||||
|
<SelectRoot
|
||||||
|
class="w-full"
|
||||||
|
:collection="statusCollection"
|
||||||
|
:default-value="statusInitial"
|
||||||
|
:disabled="saving"
|
||||||
|
@value-change="setStatusValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Pilih status">
|
||||||
|
<SelectValueText placeholder="Pilih status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in statusCollection.items"
|
||||||
|
:key="item.value"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="emit('cancel')">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="
|
||||||
|
saving ||
|
||||||
|
!form.user_id ||
|
||||||
|
!form.investment_type_id ||
|
||||||
|
!form.monthly_amount ||
|
||||||
|
Number(form.monthly_amount) <= 0 ||
|
||||||
|
!form.start_date
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Menyimpan...' : (submitLabel ?? 'Simpan') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
SelectControl,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemText,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import MemberSearchField from './MemberSearchField.vue'
|
||||||
|
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||||
|
import type { UserInvestmentLimit, UserInvestmentLimitPayload } from '../types/investment-limit.types'
|
||||||
|
import type { InvestmentType } from '../types/investment-type.types'
|
||||||
|
import { toOptionalNumber } from '../utils/investment-type.utils'
|
||||||
|
|
||||||
|
type SelectOption = { label: string; value: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initial?: UserInvestmentLimit | null
|
||||||
|
saving?: boolean
|
||||||
|
submitLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [payload: UserInvestmentLimitPayload]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
user_id: '',
|
||||||
|
investment_type_id: '',
|
||||||
|
max_investment_amount: '',
|
||||||
|
max_transaction_amount: '',
|
||||||
|
effective_date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
expiry_date: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const investmentTypes = ref<InvestmentType[]>([])
|
||||||
|
const loadingTypes = ref(false)
|
||||||
|
const typesError = ref<string | null>(null)
|
||||||
|
const selectedMemberLabel = ref('')
|
||||||
|
|
||||||
|
const isEdit = computed(() => Boolean(props.initial))
|
||||||
|
|
||||||
|
const typeOptions = computed<SelectOption[]>(() =>
|
||||||
|
investmentTypes.value
|
||||||
|
.filter((type) => type.status === 'ACTIVE')
|
||||||
|
.map((type) => ({
|
||||||
|
label: `${type.code} — ${type.name}`,
|
||||||
|
value: type.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeCollection = computed(() =>
|
||||||
|
select.collection({
|
||||||
|
items: typeOptions.value,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeInitial = computed(() => {
|
||||||
|
const option = typeOptions.value.find((item) => item.value === form.investment_type_id)
|
||||||
|
return option ? [option.label] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
function setTypeValue(details: { value: string[] }) {
|
||||||
|
const label = details.value[0]
|
||||||
|
form.investment_type_id =
|
||||||
|
typeOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFromInitial(item: UserInvestmentLimit | null | undefined) {
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
form.user_id = item.user_id
|
||||||
|
form.investment_type_id = item.investment_type_id
|
||||||
|
form.max_investment_amount =
|
||||||
|
item.max_investment_amount == null ? '' : String(item.max_investment_amount)
|
||||||
|
form.max_transaction_amount =
|
||||||
|
item.max_transaction_amount == null ? '' : String(item.max_transaction_amount)
|
||||||
|
form.effective_date = item.effective_date
|
||||||
|
form.expiry_date = item.expiry_date ?? ''
|
||||||
|
selectedMemberLabel.value = item.user
|
||||||
|
? `${item.user.name}${item.user.ic_number ? ` (${item.user.ic_number})` : ''}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initial,
|
||||||
|
(value) => {
|
||||||
|
syncFromInitial(value)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadInvestmentTypes() {
|
||||||
|
loadingTypes.value = true
|
||||||
|
typesError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listInvestmentTypes({
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
})
|
||||||
|
investmentTypes.value = data.data
|
||||||
|
} catch (err) {
|
||||||
|
typesError.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||||
|
investmentTypes.value = []
|
||||||
|
} finally {
|
||||||
|
loadingTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
emit('submit', {
|
||||||
|
user_id: form.user_id,
|
||||||
|
investment_type_id: form.investment_type_id,
|
||||||
|
max_investment_amount: toOptionalNumber(form.max_investment_amount),
|
||||||
|
max_transaction_amount: toOptionalNumber(form.max_transaction_amount),
|
||||||
|
effective_date: form.effective_date,
|
||||||
|
expiry_date: form.expiry_date || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadInvestmentTypes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||||
|
<MemberSearchField
|
||||||
|
v-model="form.user_id"
|
||||||
|
:selected-label="selectedMemberLabel"
|
||||||
|
:disabled="saving || isEdit"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||||
|
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan jenis pelaburan...</p>
|
||||||
|
<SelectRoot
|
||||||
|
v-else-if="typeOptions.length"
|
||||||
|
:key="`${form.investment_type_id}-${typeOptions.map((item) => item.value).join(',')}`"
|
||||||
|
class="w-full"
|
||||||
|
:collection="typeCollection"
|
||||||
|
:default-value="typeInitial"
|
||||||
|
:disabled="saving"
|
||||||
|
@value-change="setTypeValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||||
|
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in typeCollection.items"
|
||||||
|
:key="item.value"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
<p v-if="typesError" class="mt-1 text-xs text-danger">{{ typesError }}</p>
|
||||||
|
<p v-else-if="!loadingTypes && !typeOptions.length" class="mt-1 text-xs opacity-60">
|
||||||
|
Tiada jenis pelaburan aktif.
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Had Kumulatif (MYR)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
v-model="form.max_investment_amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
:disabled="saving"
|
||||||
|
placeholder="Kosong = tiada had"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Had Transaksi (MYR)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
v-model="form.max_transaction_amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
:disabled="saving"
|
||||||
|
placeholder="Kosong = tiada had"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Tarikh Berkuat Kuasa</FieldLabel>
|
||||||
|
<Input v-model="form.effective_date" type="date" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Tarikh Tamat</FieldLabel>
|
||||||
|
<Input v-model="form.expiry_date" type="date" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="emit('cancel')">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="
|
||||||
|
saving ||
|
||||||
|
!form.user_id ||
|
||||||
|
!form.investment_type_id ||
|
||||||
|
!form.effective_date
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Menyimpan...' : (submitLabel ?? 'Simpan') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import debounce from 'lodash/debounce'
|
||||||
|
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { useApiPagination } from '@/composables/useApiPagination'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { listInvestmentLimitProfiles } from '../services/investment-limit.service'
|
||||||
|
import type { InvestmentLimitProfile } from '../types/investment-limit.types'
|
||||||
|
|
||||||
|
export function useInvestmentLimitProfileList() {
|
||||||
|
const items = ref<InvestmentLimitProfile[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(10)
|
||||||
|
|
||||||
|
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||||
|
|
||||||
|
async function fetchItems(requestPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeSort = sortBy.value[0]
|
||||||
|
const data = await listInvestmentLimitProfiles({
|
||||||
|
page: requestPage,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
|
sort_by: activeSort?.key ?? 'name',
|
||||||
|
sort_order: activeSort?.order ?? 'asc',
|
||||||
|
search: search.value.trim() || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = data.data
|
||||||
|
applyPagination(data.pagination)
|
||||||
|
page.value = data.pagination.current_page
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil had pelaburan.')
|
||||||
|
items.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortUpdate(value: SortConfig[]) {
|
||||||
|
sortBy.value = value
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
debouncedSearch()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(page, (nextPage, previousPage) => {
|
||||||
|
if (nextPage !== previousPage) {
|
||||||
|
fetchItems(nextPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||||
|
if (nextValue !== previousValue) {
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
sortBy,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
handleSortUpdate,
|
||||||
|
fetchItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import debounce from 'lodash/debounce'
|
||||||
|
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { useApiPagination } from '@/composables/useApiPagination'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { listInvestments } from '../services/investment.service'
|
||||||
|
import type {
|
||||||
|
Investment,
|
||||||
|
InvestmentSource,
|
||||||
|
InvestmentStatus,
|
||||||
|
} from '../types/investment.types'
|
||||||
|
|
||||||
|
export function useInvestmentList() {
|
||||||
|
const items = ref<Investment[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const statusFilter = ref<InvestmentStatus | ''>('')
|
||||||
|
const sourceFilter = ref<InvestmentSource | ''>('')
|
||||||
|
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(10)
|
||||||
|
|
||||||
|
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||||
|
|
||||||
|
async function fetchItems(requestPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeSort = sortBy.value[0]
|
||||||
|
const data = await listInvestments({
|
||||||
|
page: requestPage,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
|
sort_by: activeSort?.key ?? 'created_at',
|
||||||
|
sort_order: activeSort?.order ?? 'desc',
|
||||||
|
search: search.value.trim() || undefined,
|
||||||
|
status: statusFilter.value || undefined,
|
||||||
|
source: sourceFilter.value || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = data.data
|
||||||
|
applyPagination(data.pagination)
|
||||||
|
page.value = data.pagination.current_page
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pelaburan.')
|
||||||
|
items.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortUpdate(value: SortConfig[]) {
|
||||||
|
sortBy.value = value
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
debouncedSearch()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([statusFilter, sourceFilter], () => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(page, (nextPage, previousPage) => {
|
||||||
|
if (nextPage !== previousPage) {
|
||||||
|
fetchItems(nextPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||||
|
if (nextValue !== previousValue) {
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
statusFilter,
|
||||||
|
sourceFilter,
|
||||||
|
sortBy,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
handleSortUpdate,
|
||||||
|
fetchItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import debounce from 'lodash/debounce'
|
||||||
|
import { useApiPagination } from '@/composables/useApiPagination'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||||
|
import type { InvestmentType } from '../types/investment-type.types'
|
||||||
|
|
||||||
|
export function useInvestmentTypeList() {
|
||||||
|
const items = ref<InvestmentType[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(12)
|
||||||
|
const sortBy = ref('name')
|
||||||
|
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const { pagination, applyPagination } = useApiPagination({ per_page: 12 })
|
||||||
|
|
||||||
|
async function fetchItems(requestPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listInvestmentTypes({
|
||||||
|
page: requestPage,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
|
sort_by: sortBy.value,
|
||||||
|
sort_order: sortOrder.value,
|
||||||
|
search: search.value.trim() || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = data.data
|
||||||
|
applyPagination(data.pagination)
|
||||||
|
page.value = data.pagination.current_page
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai jenis pelaburan.')
|
||||||
|
items.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
debouncedSearch()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(page, (nextPage, previousPage) => {
|
||||||
|
if (nextPage !== previousPage) {
|
||||||
|
fetchItems(nextPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
pagination,
|
||||||
|
fetchItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import debounce from 'lodash/debounce'
|
||||||
|
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { useApiPagination } from '@/composables/useApiPagination'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { listSalaryContributions } from '../services/salary.service'
|
||||||
|
import type {
|
||||||
|
SalaryContribution,
|
||||||
|
SalaryContributionStatus,
|
||||||
|
} from '../types/salary.types'
|
||||||
|
|
||||||
|
export function useSalaryContributionList() {
|
||||||
|
const items = ref<SalaryContribution[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const statusFilter = ref<SalaryContributionStatus | ''>('')
|
||||||
|
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(10)
|
||||||
|
|
||||||
|
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||||
|
|
||||||
|
async function fetchItems(requestPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeSort = sortBy.value[0]
|
||||||
|
const data = await listSalaryContributions({
|
||||||
|
page: requestPage,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
|
sort_by: activeSort?.key ?? 'created_at',
|
||||||
|
sort_order: activeSort?.order ?? 'desc',
|
||||||
|
search: search.value.trim() || undefined,
|
||||||
|
status: statusFilter.value || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = data.data
|
||||||
|
applyPagination(data.pagination)
|
||||||
|
page.value = data.pagination.current_page
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan caruman potongan gaji.')
|
||||||
|
items.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortUpdate(value: SortConfig[]) {
|
||||||
|
sortBy.value = value
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
debouncedSearch()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(statusFilter, () => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(page, (nextPage, previousPage) => {
|
||||||
|
if (nextPage !== previousPage) {
|
||||||
|
fetchItems(nextPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||||
|
if (nextValue !== previousValue) {
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
statusFilter,
|
||||||
|
sortBy,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
handleSortUpdate,
|
||||||
|
fetchItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { useApiPagination } from '@/composables/useApiPagination'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { listSalaryDeductionBatches } from '../services/salary.service'
|
||||||
|
import type {
|
||||||
|
SalaryDeductionBatch,
|
||||||
|
SalaryDeductionBatchStatus,
|
||||||
|
} from '../types/salary.types'
|
||||||
|
|
||||||
|
export function useSalaryDeductionBatchList() {
|
||||||
|
const items = ref<SalaryDeductionBatch[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const statusFilter = ref<SalaryDeductionBatchStatus | ''>('')
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(10)
|
||||||
|
|
||||||
|
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||||
|
|
||||||
|
async function fetchItems(requestPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listSalaryDeductionBatches({
|
||||||
|
page: requestPage,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
|
status: statusFilter.value || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = data.data
|
||||||
|
applyPagination(data.pagination)
|
||||||
|
page.value = data.pagination.current_page
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan batch potongan gaji.')
|
||||||
|
items.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(statusFilter, () => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(page, (nextPage, previousPage) => {
|
||||||
|
if (nextPage !== previousPage) {
|
||||||
|
fetchItems(nextPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||||
|
if (nextValue !== previousValue) {
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
statusFilter,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
fetchItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import debounce from 'lodash/debounce'
|
||||||
|
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { useApiPagination } from '@/composables/useApiPagination'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { listUserInvestmentLimits } from '../services/investment-limit.service'
|
||||||
|
import type { UserInvestmentLimit } from '../types/investment-limit.types'
|
||||||
|
|
||||||
|
export function useUserInvestmentLimitList() {
|
||||||
|
const items = ref<UserInvestmentLimit[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(10)
|
||||||
|
|
||||||
|
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||||
|
|
||||||
|
async function fetchItems(requestPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeSort = sortBy.value[0]
|
||||||
|
const data = await listUserInvestmentLimits({
|
||||||
|
page: requestPage,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
|
sort_by: activeSort?.key ?? 'created_at',
|
||||||
|
sort_order: activeSort?.order ?? 'desc',
|
||||||
|
search: search.value.trim() || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = data.data
|
||||||
|
applyPagination(data.pagination)
|
||||||
|
page.value = data.pagination.current_page
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan had pelaburan ahli.')
|
||||||
|
items.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortUpdate(value: SortConfig[]) {
|
||||||
|
sortBy.value = value
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
debouncedSearch()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(page, (nextPage, previousPage) => {
|
||||||
|
if (nextPage !== previousPage) {
|
||||||
|
fetchItems(nextPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||||
|
if (nextValue !== previousValue) {
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
sortBy,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
handleSortUpdate,
|
||||||
|
fetchItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { useApiPagination } from '@/composables/useApiPagination'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { listUserLimitProfiles } from '../services/investment-limit.service'
|
||||||
|
import type { UserLimitProfile } from '../types/investment-limit.types'
|
||||||
|
|
||||||
|
export function useUserLimitProfileList() {
|
||||||
|
const items = ref<UserLimitProfile[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const activeOnly = ref(true)
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(10)
|
||||||
|
|
||||||
|
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||||
|
|
||||||
|
async function fetchItems(requestPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listUserLimitProfiles({
|
||||||
|
page: requestPage,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
|
active_only: activeOnly.value ? 1 : 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = data.data
|
||||||
|
applyPagination(data.pagination)
|
||||||
|
page.value = data.pagination.current_page
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan tugasan profil had.')
|
||||||
|
items.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeOnly, () => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(page, (nextPage, previousPage) => {
|
||||||
|
if (nextPage !== previousPage) {
|
||||||
|
fetchItems(nextPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||||
|
if (nextValue !== previousValue) {
|
||||||
|
fetchItems(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchItems(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
activeOnly,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
fetchItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { investmentLayoutRoutes } from './routes'
|
||||||
|
export { investmentMenu } from './menu'
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Menu } from '@/core/types/menu'
|
||||||
|
|
||||||
|
export const investmentMenu: Menu[] = [
|
||||||
|
{
|
||||||
|
icon: 'Wallet',
|
||||||
|
route_name: 'list-investments',
|
||||||
|
title: 'Pelaburan',
|
||||||
|
permission: 'lihat pelaburan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'PiggyBank',
|
||||||
|
route_name: 'list-investment-types',
|
||||||
|
title: 'Jenis Pelaburan',
|
||||||
|
permission: 'lihat jenis pelaburan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'Shield',
|
||||||
|
route_name: 'list-investment-limit-profiles',
|
||||||
|
title: 'Profil Had Pelaburan',
|
||||||
|
permission: 'lihat had pelaburan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'UserCog',
|
||||||
|
route_name: 'list-user-limit-profiles',
|
||||||
|
title: 'Tugasan Profil Had',
|
||||||
|
permission: 'lihat had pelaburan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'SlidersHorizontal',
|
||||||
|
route_name: 'list-user-investment-limits',
|
||||||
|
title: 'Had Pelaburan Ahli',
|
||||||
|
permission: 'lihat had pelaburan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'Banknote',
|
||||||
|
route_name: 'list-salary-contributions',
|
||||||
|
title: 'Caruman Potongan Gaji',
|
||||||
|
permission: 'lihat potongan gaji',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'ClipboardList',
|
||||||
|
route_name: 'list-salary-deduction-batches',
|
||||||
|
title: 'Batch Potongan Gaji',
|
||||||
|
permission: 'lihat potongan gaji',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
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 {
|
||||||
|
downloadInvestmentDocument,
|
||||||
|
fetchInvestmentDocument,
|
||||||
|
getInvestment,
|
||||||
|
} from '../services/investment.service'
|
||||||
|
import type { Investment, InvestmentDocument } from '../types/investment.types'
|
||||||
|
import {
|
||||||
|
formatInvestmentAmount,
|
||||||
|
investmentSourceLabel,
|
||||||
|
investmentStatusLabel,
|
||||||
|
investmentStatusVariant,
|
||||||
|
} from '../utils/investment.utils'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
try {
|
||||||
|
investment.value = await getInvestment(String(route.params.id))
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan pelaburan.')
|
||||||
|
investment.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadInvestment()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
revokeDocumentPreviewUrl()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Butiran Pelaburan</h2>
|
||||||
|
<p v-if="investment" class="mt-1 text-sm opacity-70">
|
||||||
|
{{ investment.reference_no }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="loading" @click="goBack">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||||
|
|
||||||
|
<template v-else-if="investment">
|
||||||
|
<Box class="space-y-6 p-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm opacity-70">Jumlah</div>
|
||||||
|
<div class="mt-1 text-2xl font-medium">
|
||||||
|
{{ formatInvestmentAmount(investment.amount) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge look="outline" :variant="investmentStatusVariant(investment.status)">
|
||||||
|
{{ investmentStatusLabel(investment.status) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Ahli</div>
|
||||||
|
<div class="mt-1 font-medium">{{ investment.user?.name ?? '-' }}</div>
|
||||||
|
<div class="text-sm opacity-70">{{ investment.user?.ic_number ?? '' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Jenis Pelaburan</div>
|
||||||
|
<div class="mt-1 font-medium">{{ investment.investment_type?.name ?? '-' }}</div>
|
||||||
|
<div class="text-sm uppercase opacity-70">
|
||||||
|
{{ investment.investment_type?.code ?? '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Sumber</div>
|
||||||
|
<div class="mt-1 font-medium">{{ investmentSourceLabel(investment.source) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Dicipta</div>
|
||||||
|
<div class="mt-1">{{ formatDate(investment.created_at) }}</div>
|
||||||
|
<div v-if="investment.created_by_user" class="text-sm opacity-70">
|
||||||
|
oleh {{ investment.created_by_user.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Diluluskan</div>
|
||||||
|
<div class="mt-1">{{ formatDate(investment.approved_at) }}</div>
|
||||||
|
<div v-if="investment.approved_by_user" class="text-sm opacity-70">
|
||||||
|
oleh {{ investment.approved_by_user.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Selesai</div>
|
||||||
|
<div class="mt-1">{{ formatDate(investment.completed_at) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="investment.remarks">
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Catatan</div>
|
||||||
|
<div class="mt-1 whitespace-pre-wrap">{{ investment.remarks }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="investment.account">
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Baki Akaun</div>
|
||||||
|
<div class="mt-1 text-sm">
|
||||||
|
Semasa: {{ formatInvestmentAmount(investment.account.current_balance) }}
|
||||||
|
· Pending: {{ formatInvestmentAmount(investment.account.pending_balance) }}
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Kredit</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Debit</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Baki</th>
|
||||||
|
<th class="py-2 font-medium">Tarikh</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="entry in investment.ledger_entries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="border-b border-foreground/5"
|
||||||
|
>
|
||||||
|
<td class="py-2 pr-4">{{ entry.transaction_type }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ formatInvestmentAmount(entry.credit) }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ formatInvestmentAmount(entry.debit) }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ formatInvestmentAmount(entry.balance_after) }}</td>
|
||||||
|
<td class="py-2">{{ formatDate(entry.created_at) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box v-if="investment.payment" class="p-6">
|
||||||
|
<h3 class="text-base font-medium">Pembayaran</h3>
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Gateway</div>
|
||||||
|
<div class="mt-1">{{ investment.payment.gateway }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Status</div>
|
||||||
|
<div class="mt-1">{{ investment.payment.status }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Dibayar</div>
|
||||||
|
<div class="mt-1">{{ formatDate(investment.payment.paid_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="investment.payment.billplz?.bill_id">
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Billplz Bill ID</div>
|
||||||
|
<div class="mt-1">{{ investment.payment.billplz.bill_id }}</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { createInvestmentLimitProfile } from '../services/investment-limit.service'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await createInvestmentLimitProfile({
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
})
|
||||||
|
router.push({ name: 'view-investment-limit-profile', params: { id: profile.id } })
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal menambah profil had.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-investment-limit-profiles' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 class="mr-auto text-lg font-medium">Tambah Profil Had</h2>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box class="p-6">
|
||||||
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Nama</FieldLabel>
|
||||||
|
<Input v-model="form.name" :disabled="saving" placeholder="cth. Ahli Biasa" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Keterangan</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
v-model="form.description"
|
||||||
|
rows="3"
|
||||||
|
:disabled="saving"
|
||||||
|
placeholder="Keterangan pilihan..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" :disabled="saving || !form.name.trim()">
|
||||||
|
{{ saving ? 'Menyimpan...' : 'Tambah & Urus Had' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { Pencil, Trash2 } from '@lucide/vue'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
SelectControl,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemText,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||||
|
import {
|
||||||
|
createProfileInvestmentLimit,
|
||||||
|
deleteProfileInvestmentLimit,
|
||||||
|
getInvestmentLimitProfile,
|
||||||
|
updateInvestmentLimitProfile,
|
||||||
|
updateProfileInvestmentLimit,
|
||||||
|
} from '../services/investment-limit.service'
|
||||||
|
import type {
|
||||||
|
InvestmentLimitProfile,
|
||||||
|
ProfileInvestmentLimit,
|
||||||
|
} from '../types/investment-limit.types'
|
||||||
|
import type { InvestmentType } from '../types/investment-type.types'
|
||||||
|
import { formatInvestmentAmount, toOptionalNumber } from '../utils/investment-type.utils'
|
||||||
|
|
||||||
|
type SelectOption = { label: string; value: string }
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const canUpdate = computed(() => hasPermission('kemaskini had pelaburan'))
|
||||||
|
const canCreate = computed(() => hasPermission('tambah had pelaburan'))
|
||||||
|
const canDelete = computed(() => hasPermission('hapus had pelaburan'))
|
||||||
|
|
||||||
|
const profile = ref<InvestmentLimitProfile | null>(null)
|
||||||
|
const limits = ref<ProfileInvestmentLimit[]>([])
|
||||||
|
const investmentTypes = ref<InvestmentType[]>([])
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const savingProfile = ref(false)
|
||||||
|
const savingLimit = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const successMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
const profileForm = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const limitDialogOpen = ref(false)
|
||||||
|
const editingLimit = ref<ProfileInvestmentLimit | null>(null)
|
||||||
|
const limitForm = reactive({
|
||||||
|
investment_type_id: '',
|
||||||
|
max_investment_amount: '',
|
||||||
|
max_transaction_amount: '',
|
||||||
|
})
|
||||||
|
const limitError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
const limitToDelete = ref<ProfileInvestmentLimit | null>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const deleteError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const typeOptions = computed<SelectOption[]>(() => {
|
||||||
|
const usedIds = new Set(
|
||||||
|
limits.value
|
||||||
|
.filter((limit) => limit.id !== editingLimit.value?.id)
|
||||||
|
.map((limit) => limit.investment_type_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return investmentTypes.value
|
||||||
|
.filter((type) => type.status === 'ACTIVE' && !usedIds.has(type.id))
|
||||||
|
.map((type) => ({
|
||||||
|
label: `${type.code} — ${type.name}`,
|
||||||
|
value: type.id,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeCollection = computed(() =>
|
||||||
|
select.collection({
|
||||||
|
items: typeOptions.value,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeInitial = computed(() => {
|
||||||
|
const option = typeOptions.value.find((item) => item.value === limitForm.investment_type_id)
|
||||||
|
if (option) return [option.label]
|
||||||
|
|
||||||
|
// Keep current type visible when editing even if already "used"
|
||||||
|
if (editingLimit.value?.investment_type) {
|
||||||
|
return [
|
||||||
|
`${editingLimit.value.investment_type.code} — ${editingLimit.value.investment_type.name}`,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const editTypeOptions = computed<SelectOption[]>(() => {
|
||||||
|
if (!editingLimit.value?.investment_type) return typeOptions.value
|
||||||
|
|
||||||
|
const current = {
|
||||||
|
label: `${editingLimit.value.investment_type.code} — ${editingLimit.value.investment_type.name}`,
|
||||||
|
value: editingLimit.value.investment_type_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeOptions.value.some((item) => item.value === current.value)) {
|
||||||
|
return typeOptions.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return [current, ...typeOptions.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const editTypeCollection = computed(() =>
|
||||||
|
select.collection({
|
||||||
|
items: editTypeOptions.value,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function setTypeValue(details: { value: string[] }) {
|
||||||
|
const label = details.value[0]
|
||||||
|
limitForm.investment_type_id =
|
||||||
|
editTypeOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [profileData, typesData] = await Promise.all([
|
||||||
|
getInvestmentLimitProfile(String(route.params.id)),
|
||||||
|
listInvestmentTypes({ page: 1, per_page: 100, sort_by: 'name', sort_order: 'asc' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
profile.value = profileData
|
||||||
|
profileForm.name = profileData.name
|
||||||
|
profileForm.description = profileData.description ?? ''
|
||||||
|
limits.value = profileData.profile_limits ?? []
|
||||||
|
investmentTypes.value = typesData.data
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil had.')
|
||||||
|
profile.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
if (!profile.value || !canUpdate.value) return
|
||||||
|
|
||||||
|
savingProfile.value = true
|
||||||
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateInvestmentLimitProfile(profile.value.id, {
|
||||||
|
name: profileForm.name.trim(),
|
||||||
|
description: profileForm.description.trim() || null,
|
||||||
|
})
|
||||||
|
profile.value = { ...profile.value, ...updated }
|
||||||
|
successMessage.value = 'Profil had berjaya dikemaskini.'
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal mengemaskini profil had.')
|
||||||
|
} finally {
|
||||||
|
savingProfile.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateLimit() {
|
||||||
|
editingLimit.value = null
|
||||||
|
limitForm.investment_type_id = ''
|
||||||
|
limitForm.max_investment_amount = ''
|
||||||
|
limitForm.max_transaction_amount = ''
|
||||||
|
limitError.value = null
|
||||||
|
limitDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditLimit(limit: ProfileInvestmentLimit) {
|
||||||
|
editingLimit.value = limit
|
||||||
|
limitForm.investment_type_id = limit.investment_type_id
|
||||||
|
limitForm.max_investment_amount =
|
||||||
|
limit.max_investment_amount == null ? '' : String(limit.max_investment_amount)
|
||||||
|
limitForm.max_transaction_amount =
|
||||||
|
limit.max_transaction_amount == null ? '' : String(limit.max_transaction_amount)
|
||||||
|
limitError.value = null
|
||||||
|
limitDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLimit() {
|
||||||
|
if (!profile.value) return
|
||||||
|
|
||||||
|
savingLimit.value = true
|
||||||
|
limitError.value = null
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
investment_type_id: limitForm.investment_type_id,
|
||||||
|
max_investment_amount: toOptionalNumber(limitForm.max_investment_amount),
|
||||||
|
max_transaction_amount: toOptionalNumber(limitForm.max_transaction_amount),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingLimit.value) {
|
||||||
|
await updateProfileInvestmentLimit(profile.value.id, editingLimit.value.id, payload)
|
||||||
|
} else {
|
||||||
|
await createProfileInvestmentLimit(profile.value.id, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
limitDialogOpen.value = false
|
||||||
|
await loadAll()
|
||||||
|
successMessage.value = editingLimit.value
|
||||||
|
? 'Had profil berjaya dikemaskini.'
|
||||||
|
: 'Had profil berjaya ditambah.'
|
||||||
|
} catch (err) {
|
||||||
|
limitError.value = getApiErrorMessage(err, 'Gagal menyimpan had profil.')
|
||||||
|
} finally {
|
||||||
|
savingLimit.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteLimit(limit: ProfileInvestmentLimit) {
|
||||||
|
limitToDelete.value = limit
|
||||||
|
deleteError.value = null
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteLimit() {
|
||||||
|
if (!profile.value || !limitToDelete.value || deleting.value) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
deleteError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteProfileInvestmentLimit(profile.value.id, limitToDelete.value.id)
|
||||||
|
deleteOpen.value = false
|
||||||
|
limitToDelete.value = null
|
||||||
|
await loadAll()
|
||||||
|
} catch (err) {
|
||||||
|
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus had profil.')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push({ name: 'list-investment-limit-profiles' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAll()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Urus Profil Had</h2>
|
||||||
|
<p v-if="profile" class="mt-1 text-sm opacity-70">{{ profile.name }}</p>
|
||||||
|
</div>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="loading" @click="goBack">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<AlertRoot v-if="successMessage" variant="success">
|
||||||
|
<AlertTitle>Berjaya</AlertTitle>
|
||||||
|
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||||
|
|
||||||
|
<template v-else-if="profile">
|
||||||
|
<Box class="p-6">
|
||||||
|
<h3 class="text-base font-medium">Maklumat Profil</h3>
|
||||||
|
<form class="mt-4 space-y-4" @submit.prevent="saveProfile">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Nama</FieldLabel>
|
||||||
|
<Input v-model="profileForm.name" :disabled="savingProfile || !canUpdate" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Keterangan</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
v-model="profileForm.description"
|
||||||
|
rows="3"
|
||||||
|
:disabled="savingProfile || !canUpdate"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div v-if="canUpdate" class="flex justify-end">
|
||||||
|
<Button type="submit" variant="primary" :disabled="savingProfile || !profileForm.name.trim()">
|
||||||
|
{{ savingProfile ? 'Menyimpan...' : 'Simpan Profil' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box class="p-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h3 class="text-base font-medium">Had Mengikut Jenis Pelaburan</h3>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Had kumulatif dan had setiap transaksi untuk produk dalam profil ini.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="canCreate"
|
||||||
|
look="outline"
|
||||||
|
variant="primary"
|
||||||
|
type="button"
|
||||||
|
@click="openCreateLimit"
|
||||||
|
>
|
||||||
|
Tambah Had
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Had Kumulatif</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Had Transaksi</th>
|
||||||
|
<th class="py-2 font-medium">Tindakan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="limit in limits" :key="limit.id" class="border-b border-foreground/5">
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<div class="font-medium">{{ limit.investment_type?.name ?? '-' }}</div>
|
||||||
|
<div class="text-xs uppercase opacity-60">
|
||||||
|
{{ limit.investment_type?.code ?? '' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
{{ formatInvestmentAmount(limit.max_investment_amount) }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
{{ formatInvestmentAmount(limit.max_transaction_amount) }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="canUpdate"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="Edit"
|
||||||
|
@click="openEditLimit(limit)"
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canDelete"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-danger"
|
||||||
|
title="Hapus"
|
||||||
|
@click="openDeleteLimit(limit)"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!limits.length">
|
||||||
|
<td colspan="4" class="py-6 text-center opacity-60">
|
||||||
|
Tiada had ditetapkan untuk profil ini.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<DialogRoot
|
||||||
|
:open="limitDialogOpen"
|
||||||
|
:close-on-interact-outside="false"
|
||||||
|
@openChange="(details) => (limitDialogOpen = details.open)"
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<div class="space-y-4 p-5">
|
||||||
|
<div class="text-xl font-medium">
|
||||||
|
{{ editingLimit ? 'Kemaskini Had' : 'Tambah Had' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="limitError" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ limitError }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||||
|
<!-- Mount only while open so zag select gets the current collection -->
|
||||||
|
<SelectRoot
|
||||||
|
v-if="limitDialogOpen && editTypeOptions.length"
|
||||||
|
:key="`limit-type-${editingLimit?.id ?? 'new'}-${editTypeOptions.map((i) => i.value).join(',')}`"
|
||||||
|
class="w-full"
|
||||||
|
:collection="editTypeCollection"
|
||||||
|
:default-value="typeInitial"
|
||||||
|
:disabled="savingLimit"
|
||||||
|
@value-change="setTypeValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||||
|
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in editTypeCollection.items"
|
||||||
|
:key="item.value"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
<p v-else-if="limitDialogOpen" class="text-sm opacity-60">
|
||||||
|
Tiada jenis pelaburan aktif yang tersedia.
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Had Kumulatif (MYR)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
v-model="limitForm.max_investment_amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
:disabled="savingLimit"
|
||||||
|
placeholder="Kosong = tiada had"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Had Transaksi (MYR)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
v-model="limitForm.max_transaction_amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
:disabled="savingLimit"
|
||||||
|
placeholder="Kosong = tiada had"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 px-5 pb-5">
|
||||||
|
<DialogCloseTrigger :disabled="savingLimit">Batal</DialogCloseTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="savingLimit || !limitForm.investment_type_id"
|
||||||
|
@click="saveLimit"
|
||||||
|
>
|
||||||
|
{{ savingLimit ? 'Menyimpan...' : 'Simpan' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
|
||||||
|
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||||
|
<DialogContent>
|
||||||
|
<div class="p-5 text-center">
|
||||||
|
<div class="mt-5 text-2xl font-medium">Hapus had ini?</div>
|
||||||
|
<div class="mt-2 opacity-70">
|
||||||
|
{{ limitToDelete?.investment_type?.name ?? 'Had profil' }} akan dipadam.
|
||||||
|
</div>
|
||||||
|
<div v-if="deleteError" class="mt-4 text-sm text-danger">{{ deleteError }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-8 text-center">
|
||||||
|
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||||
|
<Button
|
||||||
|
class="w-24"
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
look="outline"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="confirmDeleteLimit"
|
||||||
|
>
|
||||||
|
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Eye, Trash2 } from '@lucide/vue'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { useInvestmentLimitProfileList } from '../composables/useInvestmentLimitProfileList'
|
||||||
|
import { deleteInvestmentLimitProfile } from '../services/investment-limit.service'
|
||||||
|
import type { InvestmentLimitProfile } from '../types/investment-limit.types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const canCreate = computed(() => hasPermission('tambah had pelaburan'))
|
||||||
|
const canView = computed(() => hasPermission('lihat had pelaburan'))
|
||||||
|
const canDelete = computed(() => hasPermission('hapus had pelaburan'))
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
const itemToDelete = ref<InvestmentLimitProfile | null>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const deleteError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
sortBy,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
handleSortUpdate,
|
||||||
|
fetchItems,
|
||||||
|
} = useInvestmentLimitProfileList()
|
||||||
|
|
||||||
|
const headers: TableHeader[] = [
|
||||||
|
{ title: 'Nama', key: 'name', sortable: true },
|
||||||
|
{ title: 'Keterangan', key: 'description', sortable: false },
|
||||||
|
{ title: 'Bil. Had', key: 'profile_limits_count', sortable: false },
|
||||||
|
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function goToCreate() {
|
||||||
|
router.push({ name: 'create-investment-limit-profile' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToDetail(id: string) {
|
||||||
|
router.push({ name: 'view-investment-limit-profile', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(item: InvestmentLimitProfile) {
|
||||||
|
itemToDelete.value = item
|
||||||
|
deleteError.value = null
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!itemToDelete.value || deleting.value) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
deleteError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteInvestmentLimitProfile(itemToDelete.value.id)
|
||||||
|
deleteOpen.value = false
|
||||||
|
itemToDelete.value = null
|
||||||
|
await fetchItems(page.value)
|
||||||
|
} catch (err) {
|
||||||
|
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus profil had.')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col items-center sm:flex-row">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Profil Had Pelaburan</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Tetapkan had kumulatif dan had transaksi mengikut kumpulan ahli.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||||
|
<Button
|
||||||
|
v-if="canCreate"
|
||||||
|
look="outline"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="goToCreate"
|
||||||
|
>
|
||||||
|
Tambah Profil
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<DataTable
|
||||||
|
:headers="headers"
|
||||||
|
:items="items"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
:current-sort="sortBy"
|
||||||
|
show-pagination
|
||||||
|
exportable
|
||||||
|
export-file-name="profil-had-pelaburan"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:items-per-page="itemsPerPage"
|
||||||
|
@update:sort-by="handleSortUpdate"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<Input
|
||||||
|
v-model="search"
|
||||||
|
class="w-full max-w-md"
|
||||||
|
type="search"
|
||||||
|
placeholder="Cari nama atau keterangan..."
|
||||||
|
aria-label="Cari profil had"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.description="{ item }">
|
||||||
|
<span class="line-clamp-2">
|
||||||
|
{{ (item as InvestmentLimitProfile).description || '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.profile_limits_count="{ item }">
|
||||||
|
{{ (item as InvestmentLimitProfile).profile_limits_count ?? 0 }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="canView"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="bg-green-600 text-white"
|
||||||
|
title="Lihat / urus"
|
||||||
|
@click="goToDetail((item as InvestmentLimitProfile).id)"
|
||||||
|
>
|
||||||
|
<Eye class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canDelete"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="bg-danger text-white"
|
||||||
|
title="Hapus"
|
||||||
|
@click="openDelete(item as InvestmentLimitProfile)"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||||
|
<DialogContent>
|
||||||
|
<div class="p-5 text-center">
|
||||||
|
<div class="mt-5 text-2xl font-medium">Hapus profil had?</div>
|
||||||
|
<div class="mt-2 opacity-70">
|
||||||
|
Hapus
|
||||||
|
<span v-if="itemToDelete" class="font-medium">{{ itemToDelete.name }}</span>?
|
||||||
|
Profil yang masih ditugaskan kepada ahli tidak boleh dipadam.
|
||||||
|
</div>
|
||||||
|
<div v-if="deleteError" class="mt-4 text-sm text-danger">{{ deleteError }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-8 text-center">
|
||||||
|
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||||
|
<Button
|
||||||
|
class="w-24"
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
look="outline"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { Eye } from '@lucide/vue'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
SelectControl,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItemGroupLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemText,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { useInvestmentList } from '../composables/useInvestmentList'
|
||||||
|
import type { Investment, InvestmentSource, InvestmentStatus } from '../types/investment.types'
|
||||||
|
import {
|
||||||
|
INVESTMENT_SOURCE_OPTIONS,
|
||||||
|
INVESTMENT_STATUS_OPTIONS,
|
||||||
|
formatInvestmentAmount,
|
||||||
|
investmentSourceLabel,
|
||||||
|
investmentStatusLabel,
|
||||||
|
investmentStatusVariant,
|
||||||
|
} from '../utils/investment.utils'
|
||||||
|
|
||||||
|
type SelectOption = { label: string; value: string }
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||||
|
{ label: 'Semua Status', value: '' },
|
||||||
|
...INVESTMENT_STATUS_OPTIONS,
|
||||||
|
]
|
||||||
|
|
||||||
|
const SOURCE_FILTER_OPTIONS: SelectOption[] = [
|
||||||
|
{ label: 'Semua Sumber', value: '' },
|
||||||
|
...INVESTMENT_SOURCE_OPTIONS,
|
||||||
|
]
|
||||||
|
|
||||||
|
function createSelectCollection(options: SelectOption[]) {
|
||||||
|
return select.collection({
|
||||||
|
items: options,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||||
|
if (!label) return ''
|
||||||
|
return options.find((option) => option.label === label)?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||||
|
const option = options.find((item) => item.value === value)
|
||||||
|
return option ? [option.label] : [options[0]?.label ?? '']
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: TableHeader[] = [
|
||||||
|
{ title: 'Rujukan', key: 'reference_no', sortable: true },
|
||||||
|
{ title: 'Ahli', key: 'user', sortable: false },
|
||||||
|
{ title: 'Jenis', key: 'investment_type', sortable: false },
|
||||||
|
{ title: 'Jumlah', key: 'amount', sortable: true },
|
||||||
|
{ title: 'Sumber', key: 'source', sortable: true },
|
||||||
|
{ title: 'Status', key: 'status', sortable: true },
|
||||||
|
{ title: 'Tarikh', key: 'created_at', sortable: true },
|
||||||
|
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||||
|
const sourceCollection = createSelectCollection(SOURCE_FILTER_OPTIONS)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
statusFilter,
|
||||||
|
sourceFilter,
|
||||||
|
sortBy,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
handleSortUpdate,
|
||||||
|
} = useInvestmentList()
|
||||||
|
|
||||||
|
const canCreateManual = computed(() => hasPermission('tambah pelaburan manual'))
|
||||||
|
const canView = computed(() => hasPermission('lihat pelaburan'))
|
||||||
|
|
||||||
|
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||||
|
const sourceInitial = computed(() => valueToLabel(SOURCE_FILTER_OPTIONS, sourceFilter.value))
|
||||||
|
|
||||||
|
function setStatusFilter(details: { value: string[] }) {
|
||||||
|
statusFilter.value = labelToValue(STATUS_FILTER_OPTIONS, details.value[0]) as InvestmentStatus | ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSourceFilter(details: { value: string[] }) {
|
||||||
|
sourceFilter.value = labelToValue(SOURCE_FILTER_OPTIONS, details.value[0]) as InvestmentSource | ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToDetail(id: string) {
|
||||||
|
router.push({ name: 'view-investment', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreateManual() {
|
||||||
|
router.push({ name: 'create-manual-investment' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col items-center sm:flex-row">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Pelaburan</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">Senarai transaksi pelaburan ahli.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||||
|
<Button
|
||||||
|
v-if="canCreateManual"
|
||||||
|
look="outline"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="goToCreateManual"
|
||||||
|
>
|
||||||
|
Rekod Pelaburan Manual
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<DataTable
|
||||||
|
:headers="headers"
|
||||||
|
:items="items"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
:current-sort="sortBy"
|
||||||
|
show-pagination
|
||||||
|
exportable
|
||||||
|
export-file-name="pelaburan"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:items-per-page="itemsPerPage"
|
||||||
|
@update:sort-by="handleSortUpdate"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="flex w-full flex-wrap items-center gap-3">
|
||||||
|
<Input
|
||||||
|
v-model="search"
|
||||||
|
class="w-full max-w-md"
|
||||||
|
type="search"
|
||||||
|
placeholder="Cari rujukan, ahli, jenis..."
|
||||||
|
aria-label="Cari pelaburan"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectRoot
|
||||||
|
class="w-full sm:w-52"
|
||||||
|
:collection="statusCollection"
|
||||||
|
:default-value="statusInitial"
|
||||||
|
@value-change="setStatusFilter"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Tapis status">
|
||||||
|
<SelectValueText placeholder="Semua Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in statusCollection.items"
|
||||||
|
:key="item.label"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
|
||||||
|
<SelectRoot
|
||||||
|
class="w-full sm:w-52"
|
||||||
|
:collection="sourceCollection"
|
||||||
|
:default-value="sourceInitial"
|
||||||
|
@value-change="setSourceFilter"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Tapis sumber">
|
||||||
|
<SelectValueText placeholder="Semua Sumber" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItemGroupLabel>Sumber</SelectItemGroupLabel>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in sourceCollection.items"
|
||||||
|
:key="item.label"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.reference_no="{ item }">
|
||||||
|
<div class="font-medium">{{ (item as Investment).reference_no }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.user="{ item }">
|
||||||
|
<div>{{ (item as Investment).user?.name ?? '-' }}</div>
|
||||||
|
<div class="mt-0.5 text-xs opacity-60">
|
||||||
|
{{ (item as Investment).user?.ic_number ?? '' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.investment_type="{ item }">
|
||||||
|
<div>{{ (item as Investment).investment_type?.name ?? '-' }}</div>
|
||||||
|
<div class="mt-0.5 text-xs uppercase opacity-60">
|
||||||
|
{{ (item as Investment).investment_type?.code ?? '' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.amount="{ item }">
|
||||||
|
{{ formatInvestmentAmount((item as Investment).amount) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.source="{ item }">
|
||||||
|
{{ investmentSourceLabel((item as Investment).source) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.status="{ item }">
|
||||||
|
<Badge look="outline" :variant="investmentStatusVariant((item as Investment).status)">
|
||||||
|
{{ investmentStatusLabel((item as Investment).status) }}
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.created_at="{ item }">
|
||||||
|
{{ formatDate((item as Investment).created_at) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<Button
|
||||||
|
v-if="canView"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="bg-green-600 text-white"
|
||||||
|
title="Lihat butiran"
|
||||||
|
@click="goToDetail((item as Investment).id)"
|
||||||
|
>
|
||||||
|
<Eye class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
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 {
|
||||||
|
ManualInvestmentFiles,
|
||||||
|
ManualInvestmentPayload,
|
||||||
|
} from '../types/investment.types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const successMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(payload: ManualInvestmentPayload, files: ManualInvestmentFiles = {}) {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const investment = await createManualInvestment(payload, files)
|
||||||
|
successMessage.value = 'Pelaburan manual berjaya direkodkan.'
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push({ name: 'view-investment', params: { id: investment.id } })
|
||||||
|
}, 300)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal merekod pelaburan manual.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-investments' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Rekod Pelaburan Manual</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Rekod pelaburan bagi pihak ahli. Transaksi akan terus dihantar ke ledger.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
look="outline"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<AlertRoot v-if="successMessage" variant="success">
|
||||||
|
<AlertTitle>Berjaya</AlertTitle>
|
||||||
|
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box class="p-6">
|
||||||
|
<ManualInvestmentForm
|
||||||
|
:saving="saving"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import InvestmentTypeForm from '../components/InvestmentTypeForm.vue'
|
||||||
|
import { createInvestmentType } from '../services/investment-type.service'
|
||||||
|
import type { InvestmentTypePayload } from '../types/investment-type.types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const successMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(payload: InvestmentTypePayload) {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createInvestmentType(payload)
|
||||||
|
successMessage.value = 'Jenis pelaburan berjaya ditambah.'
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push({ name: 'list-investment-types' })
|
||||||
|
}, 300)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal menambah jenis pelaburan.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-investment-types' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 class="mr-auto text-lg font-medium">Tambah Jenis Pelaburan</h2>
|
||||||
|
<Button
|
||||||
|
look="outline"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<AlertRoot v-if="successMessage" variant="success">
|
||||||
|
<AlertTitle>Berjaya</AlertTitle>
|
||||||
|
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<InvestmentTypeForm
|
||||||
|
:saving="saving"
|
||||||
|
submit-label="Tambah"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import InvestmentTypeForm from '../components/InvestmentTypeForm.vue'
|
||||||
|
import {
|
||||||
|
getInvestmentType,
|
||||||
|
updateInvestmentType,
|
||||||
|
} from '../services/investment-type.service'
|
||||||
|
import type { InvestmentType, InvestmentTypePayload } from '../types/investment-type.types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const investmentTypeId = computed(() => String(route.params.id ?? ''))
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const successMessage = ref<string | null>(null)
|
||||||
|
const item = ref<InvestmentType | null>(null)
|
||||||
|
|
||||||
|
async function fetchItem() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.value = await getInvestmentType(investmentTypeId.value)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||||
|
item.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(payload: InvestmentTypePayload) {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.value = await updateInvestmentType(investmentTypeId.value, payload)
|
||||||
|
successMessage.value = 'Jenis pelaburan berjaya dikemaskini.'
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push({ name: 'list-investment-types' })
|
||||||
|
}, 300)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal mengemaskini jenis pelaburan.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-investment-types' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchItem()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 class="mr-auto text-lg font-medium">Kemaskini Jenis Pelaburan</h2>
|
||||||
|
<Button
|
||||||
|
look="outline"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving || loading"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<AlertRoot v-if="successMessage" variant="success">
|
||||||
|
<AlertTitle>Berjaya</AlertTitle>
|
||||||
|
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||||
|
|
||||||
|
<Box v-else-if="item">
|
||||||
|
<InvestmentTypeForm
|
||||||
|
:initial="item"
|
||||||
|
:saving="saving"
|
||||||
|
submit-label="Kemaskini"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
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 { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
|
import {
|
||||||
|
PaginationContext,
|
||||||
|
PaginationRoot,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevTrigger,
|
||||||
|
PaginationNextTrigger,
|
||||||
|
PaginationEllipsis,
|
||||||
|
} from '@/components/ui/pagination'
|
||||||
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { useInvestmentTypeList } from '../composables/useInvestmentTypeList'
|
||||||
|
import { deleteInvestmentType } from '../services/investment-type.service'
|
||||||
|
import type { InvestmentType } from '../types/investment-type.types'
|
||||||
|
import {
|
||||||
|
formatInvestmentAmount,
|
||||||
|
investmentTypeStatusLabel,
|
||||||
|
investmentTypeStatusVariant,
|
||||||
|
} from '../utils/investment-type.utils'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const canCreate = computed(() => hasPermission('tambah jenis pelaburan'))
|
||||||
|
const canEdit = computed(() => hasPermission('kemaskini jenis pelaburan'))
|
||||||
|
const canDelete = computed(() => hasPermission('hapus jenis pelaburan'))
|
||||||
|
|
||||||
|
const deleteConfirmationOpen = ref(false)
|
||||||
|
const itemToDelete = ref<InvestmentType | null>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const deleteError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
fetchItems,
|
||||||
|
} = useInvestmentTypeList()
|
||||||
|
|
||||||
|
function handlePageChange(details: { page: number }) {
|
||||||
|
page.value = details.page
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate() {
|
||||||
|
router.push({ name: 'create-investment-type' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToEdit(id: string) {
|
||||||
|
router.push({ name: 'edit-investment-type', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteConfirmation(item: InvestmentType) {
|
||||||
|
itemToDelete.value = item
|
||||||
|
deleteError.value = null
|
||||||
|
deleteConfirmationOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!itemToDelete.value || deleting.value) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
deleteError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteInvestmentType(itemToDelete.value.id)
|
||||||
|
deleteConfirmationOpen.value = false
|
||||||
|
itemToDelete.value = null
|
||||||
|
await fetchItems(page.value)
|
||||||
|
} catch (err) {
|
||||||
|
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus jenis pelaburan.')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(item: InvestmentType): string {
|
||||||
|
const channels: string[] = []
|
||||||
|
if (item.allow_billplz) channels.push('Billplz')
|
||||||
|
if (item.allow_salary_deduction) channels.push('Potongan Gaji')
|
||||||
|
return channels.length ? channels.join(' · ') : 'Tiada saluran'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col items-center sm:flex-row">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Jenis Pelaburan</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">Urus produk pelaburan koperasi.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||||
|
<Button
|
||||||
|
v-if="canCreate"
|
||||||
|
look="outline"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="goToCreate"
|
||||||
|
>
|
||||||
|
Tambah Jenis Pelaburan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
|
||||||
|
<div class="col-span-12 mt-2 flex flex-wrap items-center sm:flex-nowrap">
|
||||||
|
<div class="w-full sm:w-auto">
|
||||||
|
<div class="relative w-56">
|
||||||
|
<Input
|
||||||
|
v-model="search"
|
||||||
|
class="w-56 pr-10"
|
||||||
|
type="search"
|
||||||
|
placeholder="Cari jenis pelaburan..."
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 h-4 w-4" icon="Search" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3"
|
||||||
|
>
|
||||||
|
<Box class="p-0">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="rounded-lg border border-foreground/10 bg-foreground/2 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-base font-medium">{{ item.name }}</div>
|
||||||
|
<div class="mt-1 truncate text-xs uppercase tracking-wide opacity-70">
|
||||||
|
{{ item.code }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
class="shrink-0"
|
||||||
|
look="outline"
|
||||||
|
:variant="investmentTypeStatusVariant(item.status)"
|
||||||
|
>
|
||||||
|
{{ investmentTypeStatusLabel(item.status) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-2 text-sm opacity-70">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Lucide class="mr-2 h-4 w-4" icon="Wallet" />
|
||||||
|
Min: {{ formatInvestmentAmount(item.minimum_amount) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Lucide class="mr-2 h-4 w-4" icon="Landmark" />
|
||||||
|
Had dana: {{ formatInvestmentAmount(item.total_fund_limit) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Lucide class="mr-2 h-4 w-4" icon="CreditCard" />
|
||||||
|
{{ channelLabel(item) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center border-t border-foreground/8 p-5 lg:justify-end">
|
||||||
|
<a
|
||||||
|
v-if="canEdit"
|
||||||
|
class="mr-3 flex items-center"
|
||||||
|
href="#"
|
||||||
|
@click.prevent="goToEdit(item.id)"
|
||||||
|
>
|
||||||
|
<Lucide class="mr-1 h-4 w-4" icon="CheckSquare" /> Edit
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-if="canDelete"
|
||||||
|
class="text-danger flex items-center"
|
||||||
|
href="#"
|
||||||
|
@click.prevent="openDeleteConfirmation(item)"
|
||||||
|
>
|
||||||
|
<Lucide class="mr-1 h-4 w-4" icon="Trash" /> Hapus
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && !items.length" class="col-span-12">
|
||||||
|
<Box class="p-6 text-center opacity-70">Tiada rekod ditemui</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 flex flex-wrap items-center sm:flex-row sm:flex-nowrap">
|
||||||
|
<PaginationRoot
|
||||||
|
:count="pagination.total"
|
||||||
|
:page="page"
|
||||||
|
:page-size="itemsPerPage"
|
||||||
|
:siblingCount="1"
|
||||||
|
:onPageChange="handlePageChange"
|
||||||
|
class="w-full sm:mr-auto sm:w-auto"
|
||||||
|
>
|
||||||
|
<PaginationPrevTrigger>Previous</PaginationPrevTrigger>
|
||||||
|
<PaginationContext v-slot="{ pagination: pages }">
|
||||||
|
<template v-for="(pageItem, index) in pages?.pages" :key="index">
|
||||||
|
<PaginationItem v-if="pageItem.type === 'page'" v-bind="{ ...pageItem }">
|
||||||
|
{{ pageItem.value }}
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationEllipsis v-else :index="index" />
|
||||||
|
</template>
|
||||||
|
</PaginationContext>
|
||||||
|
<PaginationNextTrigger>Next</PaginationNextTrigger>
|
||||||
|
</PaginationRoot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogRoot
|
||||||
|
:open="deleteConfirmationOpen"
|
||||||
|
@openChange="(details) => (deleteConfirmationOpen = details.open)"
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<div class="p-5 text-center">
|
||||||
|
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
|
||||||
|
<div class="mt-5 text-2xl font-medium">Adakah anda yakin?</div>
|
||||||
|
<div class="mt-2 opacity-70">
|
||||||
|
Hapus jenis pelaburan
|
||||||
|
<span v-if="itemToDelete" class="font-medium">{{ itemToDelete.name }}</span>?
|
||||||
|
<br />
|
||||||
|
Proses ini boleh dikembalikan melalui soft delete di pangkalan data.
|
||||||
|
</div>
|
||||||
|
<div v-if="deleteError" class="mt-4 text-sm text-danger">
|
||||||
|
{{ deleteError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-8 text-center">
|
||||||
|
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||||
|
<Button
|
||||||
|
class="w-24"
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
look="outline"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import SalaryContributionForm from '../components/SalaryContributionForm.vue'
|
||||||
|
import { createSalaryContribution } from '../services/salary.service'
|
||||||
|
import type { SalaryContributionPayload } from '../types/salary.types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(payload: SalaryContributionPayload) {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createSalaryContribution(payload)
|
||||||
|
router.push({ name: 'list-salary-contributions' })
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal menambah caruman potongan gaji.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-salary-contributions' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Tambah Caruman Potongan Gaji</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">Tetapkan arahan potongan bulanan untuk ahli.</p>
|
||||||
|
</div>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box class="p-6">
|
||||||
|
<SalaryContributionForm
|
||||||
|
:saving="saving"
|
||||||
|
submit-label="Tambah Caruman"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import SalaryContributionForm from '../components/SalaryContributionForm.vue'
|
||||||
|
import {
|
||||||
|
getSalaryContribution,
|
||||||
|
updateSalaryContribution,
|
||||||
|
} from '../services/salary.service'
|
||||||
|
import type {
|
||||||
|
SalaryContribution,
|
||||||
|
SalaryContributionPayload,
|
||||||
|
} from '../types/salary.types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const item = ref<SalaryContribution | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadItem() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.value = await getSalaryContribution(String(route.params.id))
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan caruman.')
|
||||||
|
item.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(payload: SalaryContributionPayload) {
|
||||||
|
if (!item.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSalaryContribution(item.value.id, payload)
|
||||||
|
router.push({ name: 'list-salary-contributions' })
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal mengemaskini caruman.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-salary-contributions' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItem()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 class="mr-auto text-lg font-medium">Kemaskini Caruman Potongan Gaji</h2>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||||
|
|
||||||
|
<Box v-else-if="item" class="p-6">
|
||||||
|
<SalaryContributionForm
|
||||||
|
:initial="item"
|
||||||
|
:saving="saving"
|
||||||
|
submit-label="Kemaskini"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { Pencil, Trash2 } from '@lucide/vue'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
SelectControl,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItemGroupLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemText,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { useSalaryContributionList } from '../composables/useSalaryContributionList'
|
||||||
|
import { deleteSalaryContribution } from '../services/salary.service'
|
||||||
|
import type { SalaryContribution, SalaryContributionStatus } from '../types/salary.types'
|
||||||
|
import {
|
||||||
|
SALARY_CONTRIBUTION_STATUS_OPTIONS,
|
||||||
|
formatInvestmentAmount,
|
||||||
|
salaryContributionStatusLabel,
|
||||||
|
salaryContributionStatusVariant,
|
||||||
|
} from '../utils/salary.utils'
|
||||||
|
|
||||||
|
type SelectOption = { label: string; value: string }
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||||
|
{ label: 'Semua Status', value: '' },
|
||||||
|
...SALARY_CONTRIBUTION_STATUS_OPTIONS,
|
||||||
|
]
|
||||||
|
|
||||||
|
function createSelectCollection(options: SelectOption[]) {
|
||||||
|
return select.collection({
|
||||||
|
items: options,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||||
|
if (!label) return ''
|
||||||
|
return options.find((option) => option.label === label)?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||||
|
const option = options.find((item) => item.value === value)
|
||||||
|
return option ? [option.label] : [options[0]?.label ?? '']
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const canCreate = computed(() => hasPermission('tambah potongan gaji'))
|
||||||
|
const canUpdate = computed(() => hasPermission('kemaskini potongan gaji'))
|
||||||
|
const canDelete = computed(() => hasPermission('hapus potongan gaji'))
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
const itemToDelete = ref<SalaryContribution | null>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const deleteError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
statusFilter,
|
||||||
|
sortBy,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
handleSortUpdate,
|
||||||
|
fetchItems,
|
||||||
|
} = useSalaryContributionList()
|
||||||
|
|
||||||
|
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||||
|
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||||
|
|
||||||
|
const headers: TableHeader[] = [
|
||||||
|
{ title: 'Ahli', key: 'user', sortable: false },
|
||||||
|
{ title: 'Jenis', key: 'investment_type', sortable: false },
|
||||||
|
{ title: 'Jumlah Bulanan', key: 'monthly_amount', sortable: true },
|
||||||
|
{ title: 'Mula', key: 'start_date', sortable: true },
|
||||||
|
{ title: 'Tamat', key: 'end_date', sortable: true },
|
||||||
|
{ title: 'Status', key: 'status', sortable: true },
|
||||||
|
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function setStatusFilter(details: { value: string[] }) {
|
||||||
|
statusFilter.value = labelToValue(
|
||||||
|
STATUS_FILTER_OPTIONS,
|
||||||
|
details.value[0],
|
||||||
|
) as SalaryContributionStatus | ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('DD MMM YYYY')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate() {
|
||||||
|
router.push({ name: 'create-salary-contribution' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToEdit(id: string) {
|
||||||
|
router.push({ name: 'edit-salary-contribution', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(item: SalaryContribution) {
|
||||||
|
itemToDelete.value = item
|
||||||
|
deleteError.value = null
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!itemToDelete.value || deleting.value) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
deleteError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteSalaryContribution(itemToDelete.value.id)
|
||||||
|
deleteOpen.value = false
|
||||||
|
itemToDelete.value = null
|
||||||
|
await fetchItems(page.value)
|
||||||
|
} catch (err) {
|
||||||
|
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus caruman.')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col items-center sm:flex-row">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Caruman Potongan Gaji</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Arahan potongan bulanan ahli. Digunakan untuk menjana batch bulanan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||||
|
<Button
|
||||||
|
v-if="canCreate"
|
||||||
|
look="outline"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="goToCreate"
|
||||||
|
>
|
||||||
|
Tambah Caruman
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<DataTable
|
||||||
|
:headers="headers"
|
||||||
|
:items="items"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
:current-sort="sortBy"
|
||||||
|
show-pagination
|
||||||
|
exportable
|
||||||
|
export-file-name="caruman-potongan-gaji"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:items-per-page="itemsPerPage"
|
||||||
|
@update:sort-by="handleSortUpdate"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="flex w-full flex-wrap items-center gap-3">
|
||||||
|
<Input
|
||||||
|
v-model="search"
|
||||||
|
class="w-full max-w-md"
|
||||||
|
type="search"
|
||||||
|
placeholder="Cari ahli atau jenis..."
|
||||||
|
/>
|
||||||
|
<SelectRoot
|
||||||
|
class="w-full sm:w-52"
|
||||||
|
:collection="statusCollection"
|
||||||
|
:default-value="statusInitial"
|
||||||
|
@value-change="setStatusFilter"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Tapis status">
|
||||||
|
<SelectValueText placeholder="Semua Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in statusCollection.items"
|
||||||
|
:key="item.label"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.user="{ item }">
|
||||||
|
<div>{{ (item as SalaryContribution).user?.name ?? '-' }}</div>
|
||||||
|
<div class="mt-0.5 text-xs opacity-60">
|
||||||
|
{{ (item as SalaryContribution).user?.ic_number
|
||||||
|
|| (item as SalaryContribution).user?.email
|
||||||
|
|| '' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.investment_type="{ item }">
|
||||||
|
<div>{{ (item as SalaryContribution).investment_type?.name ?? '-' }}</div>
|
||||||
|
<div class="mt-0.5 text-xs uppercase opacity-60">
|
||||||
|
{{ (item as SalaryContribution).investment_type?.code ?? '' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.monthly_amount="{ item }">
|
||||||
|
{{ formatInvestmentAmount((item as SalaryContribution).monthly_amount) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.start_date="{ item }">
|
||||||
|
{{ formatDate((item as SalaryContribution).start_date) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.end_date="{ item }">
|
||||||
|
{{ formatDate((item as SalaryContribution).end_date) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.status="{ item }">
|
||||||
|
<Badge
|
||||||
|
look="outline"
|
||||||
|
:variant="salaryContributionStatusVariant((item as SalaryContribution).status)"
|
||||||
|
>
|
||||||
|
{{ salaryContributionStatusLabel((item as SalaryContribution).status) }}
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="canUpdate"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="Edit"
|
||||||
|
@click="goToEdit((item as SalaryContribution).id)"
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canDelete"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="bg-danger text-white"
|
||||||
|
title="Hapus"
|
||||||
|
@click="openDelete(item as SalaryContribution)"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||||
|
<DialogContent>
|
||||||
|
<div class="p-5 text-center">
|
||||||
|
<div class="mt-5 text-2xl font-medium">Hapus caruman?</div>
|
||||||
|
<div class="mt-2 opacity-70">
|
||||||
|
Caruman
|
||||||
|
<span v-if="itemToDelete" class="font-medium">
|
||||||
|
{{ itemToDelete.user?.name }} / {{ itemToDelete.investment_type?.name }}
|
||||||
|
</span>
|
||||||
|
akan dipadam.
|
||||||
|
</div>
|
||||||
|
<div v-if="deleteError" class="mt-4 text-sm text-danger">{{ deleteError }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-8 text-center">
|
||||||
|
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||||
|
<Button
|
||||||
|
class="w-24"
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
look="outline"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { Trash2 } from '@lucide/vue'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
SelectControl,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemText,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import MemberSearchField from '../components/MemberSearchField.vue'
|
||||||
|
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||||
|
import {
|
||||||
|
generateSalaryDeductionBatch,
|
||||||
|
uploadSalaryDeductionBatch,
|
||||||
|
} from '../services/salary.service'
|
||||||
|
import type { InvestmentType } from '../types/investment-type.types'
|
||||||
|
import type { UserListItem } from '@/modules/user/types/user.types'
|
||||||
|
import { MONTH_OPTIONS, formatInvestmentAmount } from '../utils/salary.utils'
|
||||||
|
|
||||||
|
type SelectOption = { label: string; value: string }
|
||||||
|
type Mode = 'generate' | 'manual'
|
||||||
|
|
||||||
|
type ManualDetail = {
|
||||||
|
key: string
|
||||||
|
user_id: string
|
||||||
|
user_label: string
|
||||||
|
investment_type_id: string
|
||||||
|
amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const mode = ref<Mode>('generate')
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
month: dayjs().month() + 1,
|
||||||
|
year: dayjs().year(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const investmentTypes = ref<InvestmentType[]>([])
|
||||||
|
const loadingTypes = ref(false)
|
||||||
|
|
||||||
|
const draftDetail = reactive({
|
||||||
|
user_id: '',
|
||||||
|
investment_type_id: '',
|
||||||
|
amount: '',
|
||||||
|
})
|
||||||
|
const draftMemberLabel = ref('')
|
||||||
|
const draftMemberKey = ref(0)
|
||||||
|
const details = ref<ManualDetail[]>([])
|
||||||
|
|
||||||
|
const yearOptions = computed(() => {
|
||||||
|
const current = dayjs().year()
|
||||||
|
return Array.from({ length: 6 }, (_, index) => {
|
||||||
|
const year = current - 2 + index
|
||||||
|
return { label: String(year), value: String(year) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthOptions = computed<SelectOption[]>(() =>
|
||||||
|
MONTH_OPTIONS.map((item) => ({ label: item.label, value: String(item.value) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const monthCollection = computed(() =>
|
||||||
|
select.collection({
|
||||||
|
items: monthOptions.value,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const yearCollection = computed(() =>
|
||||||
|
select.collection({
|
||||||
|
items: yearOptions.value,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const monthInitial = computed(() => {
|
||||||
|
const option = monthOptions.value.find((item) => item.value === String(form.month))
|
||||||
|
return option ? [option.label] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const yearInitial = computed(() => {
|
||||||
|
const option = yearOptions.value.find((item) => item.value === String(form.year))
|
||||||
|
return option ? [option.label] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeOptions = computed<SelectOption[]>(() =>
|
||||||
|
investmentTypes.value
|
||||||
|
.filter((type) => type.status === 'ACTIVE' && type.allow_salary_deduction)
|
||||||
|
.map((type) => ({
|
||||||
|
label: `${type.code} — ${type.name}`,
|
||||||
|
value: type.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeCollection = computed(() =>
|
||||||
|
select.collection({
|
||||||
|
items: typeOptions.value,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalAmount = computed(() =>
|
||||||
|
details.value.reduce((sum, detail) => sum + (Number(detail.amount) || 0), 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
function setMonthValue(event: { value: string[] }) {
|
||||||
|
form.month = Number(
|
||||||
|
monthOptions.value.find((item) => item.label === event.value[0])?.value ?? form.month,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setYearValue(event: { value: string[] }) {
|
||||||
|
form.year = Number(
|
||||||
|
yearOptions.value.find((item) => item.label === event.value[0])?.value ?? form.year,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDraftTypeValue(event: { value: string[] }) {
|
||||||
|
draftDetail.investment_type_id =
|
||||||
|
typeOptions.value.find((item) => item.label === event.value[0])?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMemberSelect(member: UserListItem) {
|
||||||
|
draftDetail.user_id = member.id
|
||||||
|
draftMemberLabel.value = `${member.name} (${member.ic_number})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDraftMember() {
|
||||||
|
draftDetail.user_id = ''
|
||||||
|
draftMemberLabel.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDetail() {
|
||||||
|
if (!draftDetail.user_id || !draftDetail.investment_type_id || Number(draftDetail.amount) <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
details.value.push({
|
||||||
|
key: `${Date.now()}-${details.value.length}`,
|
||||||
|
user_id: draftDetail.user_id,
|
||||||
|
user_label: draftMemberLabel.value || draftDetail.user_id,
|
||||||
|
investment_type_id: draftDetail.investment_type_id,
|
||||||
|
amount: draftDetail.amount,
|
||||||
|
})
|
||||||
|
|
||||||
|
draftDetail.user_id = ''
|
||||||
|
draftDetail.investment_type_id = ''
|
||||||
|
draftDetail.amount = ''
|
||||||
|
draftMemberLabel.value = ''
|
||||||
|
draftMemberKey.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDetail(key: string) {
|
||||||
|
details.value = details.value.filter((detail) => detail.key !== key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(typeId: string): string {
|
||||||
|
return typeOptions.value.find((item) => item.value === typeId)?.label ?? typeId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTypes() {
|
||||||
|
loadingTypes.value = true
|
||||||
|
try {
|
||||||
|
const data = await listInvestmentTypes({
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
})
|
||||||
|
investmentTypes.value = data.data
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||||
|
} finally {
|
||||||
|
loadingTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const batch = await generateSalaryDeductionBatch({
|
||||||
|
month: form.month,
|
||||||
|
year: form.year,
|
||||||
|
})
|
||||||
|
router.push({ name: 'view-salary-deduction-batch', params: { id: batch.id } })
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal menjana batch.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!details.value.length) {
|
||||||
|
error.value = 'Tambah sekurang-kurangnya satu rekod potongan.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const batch = await uploadSalaryDeductionBatch({
|
||||||
|
month: form.month,
|
||||||
|
year: form.year,
|
||||||
|
details: details.value.map((detail) => ({
|
||||||
|
user_id: detail.user_id,
|
||||||
|
investment_type_id: detail.investment_type_id,
|
||||||
|
amount: Number(detail.amount),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
router.push({ name: 'view-salary-deduction-batch', params: { id: batch.id } })
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuat naik batch.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-salary-deduction-batches' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTypes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Jana / Muat Naik Batch</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Jana daripada caruman aktif, atau muat naik rekod secara manual.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
look="outline"
|
||||||
|
:variant="mode === 'generate' ? 'primary' : 'secondary'"
|
||||||
|
@click="mode = 'generate'"
|
||||||
|
>
|
||||||
|
Jana dari Caruman
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
look="outline"
|
||||||
|
:variant="mode === 'manual' ? 'primary' : 'secondary'"
|
||||||
|
@click="mode = 'manual'"
|
||||||
|
>
|
||||||
|
Muat Naik Manual
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box class="space-y-6 p-6">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Bulan</FieldLabel>
|
||||||
|
<SelectRoot
|
||||||
|
:key="`month-${form.month}`"
|
||||||
|
class="w-full"
|
||||||
|
:collection="monthCollection"
|
||||||
|
:default-value="monthInitial"
|
||||||
|
:disabled="saving"
|
||||||
|
@value-change="setMonthValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Pilih bulan">
|
||||||
|
<SelectValueText placeholder="Pilih bulan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in monthCollection.items"
|
||||||
|
:key="item.value"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Tahun</FieldLabel>
|
||||||
|
<SelectRoot
|
||||||
|
:key="`year-${form.year}`"
|
||||||
|
class="w-full"
|
||||||
|
:collection="yearCollection"
|
||||||
|
:default-value="yearInitial"
|
||||||
|
:disabled="saving"
|
||||||
|
@value-change="setYearValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Pilih tahun">
|
||||||
|
<SelectValueText placeholder="Pilih tahun" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in yearCollection.items"
|
||||||
|
:key="item.value"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="mode === 'generate'">
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
Sistem akan menjana satu rekod untuk setiap caruman aktif yang meliputi bulan/tahun ini.
|
||||||
|
Satu batch sahaja dibenarkan setiap tempoh.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="primary" :disabled="saving" @click="handleGenerate">
|
||||||
|
{{ saving ? 'Menjana...' : 'Jana Batch' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="space-y-4 rounded-md border border-foreground/10 p-4">
|
||||||
|
<h3 class="text-sm font-medium">Tambah Rekod</h3>
|
||||||
|
<MemberSearchField
|
||||||
|
:key="draftMemberKey"
|
||||||
|
v-model="draftDetail.user_id"
|
||||||
|
required
|
||||||
|
:disabled="saving"
|
||||||
|
@select="onMemberSelect"
|
||||||
|
@clear="clearDraftMember"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||||
|
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan...</p>
|
||||||
|
<SelectRoot
|
||||||
|
v-else-if="typeOptions.length"
|
||||||
|
:key="`${draftMemberKey}-${typeOptions.map((item) => item.value).join(',')}`"
|
||||||
|
class="w-full"
|
||||||
|
:collection="typeCollection"
|
||||||
|
:disabled="saving"
|
||||||
|
@value-change="setDraftTypeValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Pilih jenis">
|
||||||
|
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in typeCollection.items"
|
||||||
|
:key="item.value"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Jumlah (MYR)</FieldLabel>
|
||||||
|
<Input v-model="draftDetail.amount" type="number" min="0" step="0.01" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
look="outline"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="
|
||||||
|
saving ||
|
||||||
|
!draftDetail.user_id ||
|
||||||
|
!draftDetail.investment_type_id ||
|
||||||
|
Number(draftDetail.amount) <= 0
|
||||||
|
"
|
||||||
|
@click="addDetail"
|
||||||
|
>
|
||||||
|
Tambah ke Senarai
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 pr-4 font-medium">Ahli</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Jumlah</th>
|
||||||
|
<th class="py-2 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="detail in details" :key="detail.key" class="border-b border-foreground/5">
|
||||||
|
<td class="py-2 pr-4">{{ detail.user_label }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ typeLabel(detail.investment_type_id) }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ formatInvestmentAmount(detail.amount) }}</td>
|
||||||
|
<td class="py-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-danger"
|
||||||
|
@click="removeDetail(detail.key)"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!details.length">
|
||||||
|
<td colspan="4" class="py-6 text-center opacity-60">Belum ada rekod ditambah.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="text-sm opacity-70">
|
||||||
|
{{ details.length }} rekod · Jumlah {{ formatInvestmentAmount(totalAmount) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="saving || !details.length"
|
||||||
|
@click="handleUpload"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Memuat naik...' : 'Muat Naik Batch' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { Check, X } 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 { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import {
|
||||||
|
getSalaryDeductionBatch,
|
||||||
|
markSalaryDeductionBatchUnderReview,
|
||||||
|
rejectSalaryDeductionBatch,
|
||||||
|
rejectSalaryDeductionDetail,
|
||||||
|
verifyAllSalaryDeductionBatch,
|
||||||
|
verifySalaryDeductionDetail,
|
||||||
|
} from '../services/salary.service'
|
||||||
|
import type {
|
||||||
|
SalaryDeductionBatch,
|
||||||
|
SalaryDeductionBatchDetail,
|
||||||
|
} from '../types/salary.types'
|
||||||
|
import {
|
||||||
|
formatInvestmentAmount,
|
||||||
|
monthLabel,
|
||||||
|
salaryBatchStatusLabel,
|
||||||
|
salaryBatchStatusVariant,
|
||||||
|
salaryDetailStatusLabel,
|
||||||
|
salaryDetailStatusVariant,
|
||||||
|
} from '../utils/salary.utils'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const canVerify = computed(() => hasPermission('sahkan potongan gaji'))
|
||||||
|
|
||||||
|
const batch = ref<SalaryDeductionBatch | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const acting = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const successMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
const actionDialogOpen = ref(false)
|
||||||
|
const actionType = ref<'verify-detail' | 'reject-detail' | 'reject-batch' | null>(null)
|
||||||
|
const actionDetail = ref<SalaryDeductionBatchDetail | null>(null)
|
||||||
|
const actionRemarks = ref('')
|
||||||
|
const actionError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const pendingCount = computed(
|
||||||
|
() => batch.value?.details?.filter((detail) => detail.status === 'PENDING').length ?? 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const canProcessBatch = computed(
|
||||||
|
() =>
|
||||||
|
batch.value?.status === 'UPLOADED' || batch.value?.status === 'UNDER_REVIEW',
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBatch() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
batch.value = await getSalaryDeductionBatch(String(route.params.id))
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan batch.')
|
||||||
|
batch.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markUnderReview() {
|
||||||
|
if (!batch.value || acting.value) return
|
||||||
|
|
||||||
|
acting.value = true
|
||||||
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
batch.value = await markSalaryDeductionBatchUnderReview(batch.value.id)
|
||||||
|
successMessage.value = 'Batch dihantar untuk semakan.'
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal menghantar untuk semakan.')
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAll() {
|
||||||
|
if (!batch.value || acting.value) return
|
||||||
|
|
||||||
|
acting.value = true
|
||||||
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
batch.value = await verifyAllSalaryDeductionBatch(batch.value.id)
|
||||||
|
successMessage.value = 'Semua rekod pending berjaya disahkan.'
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal mengesahkan semua rekod.')
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVerifyDetail(detail: SalaryDeductionBatchDetail) {
|
||||||
|
actionType.value = 'verify-detail'
|
||||||
|
actionDetail.value = detail
|
||||||
|
actionRemarks.value = ''
|
||||||
|
actionError.value = null
|
||||||
|
actionDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRejectDetail(detail: SalaryDeductionBatchDetail) {
|
||||||
|
actionType.value = 'reject-detail'
|
||||||
|
actionDetail.value = detail
|
||||||
|
actionRemarks.value = ''
|
||||||
|
actionError.value = null
|
||||||
|
actionDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRejectBatch() {
|
||||||
|
actionType.value = 'reject-batch'
|
||||||
|
actionDetail.value = null
|
||||||
|
actionRemarks.value = ''
|
||||||
|
actionError.value = null
|
||||||
|
actionDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTitle = computed(() => {
|
||||||
|
if (actionType.value === 'verify-detail') return 'Sahkan rekod ini?'
|
||||||
|
if (actionType.value === 'reject-detail') return 'Tolak rekod ini?'
|
||||||
|
if (actionType.value === 'reject-batch') return 'Tolak keseluruhan batch?'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function confirmAction() {
|
||||||
|
if (!batch.value || !actionType.value || acting.value) return
|
||||||
|
|
||||||
|
acting.value = true
|
||||||
|
actionError.value = null
|
||||||
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (actionType.value === 'verify-detail' && actionDetail.value) {
|
||||||
|
await verifySalaryDeductionDetail(
|
||||||
|
batch.value.id,
|
||||||
|
actionDetail.value.id,
|
||||||
|
actionRemarks.value.trim() || null,
|
||||||
|
)
|
||||||
|
successMessage.value = 'Rekod berjaya disahkan dan dihantar ke ledger.'
|
||||||
|
} else if (actionType.value === 'reject-detail' && actionDetail.value) {
|
||||||
|
await rejectSalaryDeductionDetail(
|
||||||
|
batch.value.id,
|
||||||
|
actionDetail.value.id,
|
||||||
|
actionRemarks.value.trim() || null,
|
||||||
|
)
|
||||||
|
successMessage.value = 'Rekod berjaya ditolak.'
|
||||||
|
} else if (actionType.value === 'reject-batch') {
|
||||||
|
await rejectSalaryDeductionBatch(
|
||||||
|
batch.value.id,
|
||||||
|
actionRemarks.value.trim() || null,
|
||||||
|
)
|
||||||
|
successMessage.value = 'Batch berjaya ditolak.'
|
||||||
|
}
|
||||||
|
|
||||||
|
actionDialogOpen.value = false
|
||||||
|
await loadBatch()
|
||||||
|
} catch (err) {
|
||||||
|
actionError.value = getApiErrorMessage(err, 'Tindakan gagal.')
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push({ name: 'list-salary-deduction-batches' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToInvestment(id: string) {
|
||||||
|
router.push({ name: 'view-investment', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadBatch()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Butiran Batch Potongan Gaji</h2>
|
||||||
|
<p v-if="batch" class="mt-1 text-sm opacity-70">{{ batch.batch_no }}</p>
|
||||||
|
</div>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="loading || acting" @click="goBack">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<AlertRoot v-if="successMessage" variant="success">
|
||||||
|
<AlertTitle>Berjaya</AlertTitle>
|
||||||
|
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||||
|
|
||||||
|
<template v-else-if="batch">
|
||||||
|
<Box class="space-y-4 p-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm opacity-70">Tempoh</div>
|
||||||
|
<div class="mt-1 text-xl font-medium">
|
||||||
|
{{ monthLabel(batch.month) }} {{ batch.year }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge look="outline" :variant="salaryBatchStatusVariant(batch.status)">
|
||||||
|
{{ salaryBatchStatusLabel(batch.status) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Jumlah Rekod</div>
|
||||||
|
<div class="mt-1 font-medium">{{ batch.total_records }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Jumlah Amaun</div>
|
||||||
|
<div class="mt-1 font-medium">{{ formatInvestmentAmount(batch.total_amount) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Dimuat Naik</div>
|
||||||
|
<div class="mt-1">{{ formatDate(batch.uploaded_at) }}</div>
|
||||||
|
<div v-if="batch.uploaded_by_user" class="opacity-70">
|
||||||
|
oleh {{ batch.uploaded_by_user.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-60">Disahkan</div>
|
||||||
|
<div class="mt-1">{{ formatDate(batch.verified_at) }}</div>
|
||||||
|
<div v-if="batch.verified_by_user" class="opacity-70">
|
||||||
|
oleh {{ batch.verified_by_user.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canVerify && canProcessBatch" class="flex flex-wrap gap-2 border-t border-foreground/10 pt-4">
|
||||||
|
<Button v-if="batch.status === 'UPLOADED'" type="button" look="outline" variant="secondary" :disabled="acting"
|
||||||
|
@click="markUnderReview">
|
||||||
|
Sahkan Semakan
|
||||||
|
</Button>
|
||||||
|
<Button v-if="pendingCount > 0" type="button" variant="primary" :disabled="acting" @click="verifyAll">
|
||||||
|
Sahkan Semua ({{ pendingCount }})
|
||||||
|
</Button>
|
||||||
|
<Button type="button" look="outline" variant="danger" :disabled="acting" @click="openRejectBatch">
|
||||||
|
Tolak Semua
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box class="p-6">
|
||||||
|
<h3 class="text-base font-medium">Rekod Potongan</h3>
|
||||||
|
<div class="mt-4 overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 pr-4 font-medium">Ahli</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Jumlah</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Status</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Pelaburan</th>
|
||||||
|
<th class="py-2 font-medium">Tindakan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="detail in batch.details" :key="detail.id" class="border-b border-foreground/5">
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<div class="font-medium">{{ detail.user?.name ?? '-' }}</div>
|
||||||
|
<div class="text-xs opacity-60">
|
||||||
|
{{ detail.user?.ic_number || detail.user?.email || '' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<div>{{ detail.investment_type?.name ?? '-' }}</div>
|
||||||
|
<div class="text-xs uppercase opacity-60">
|
||||||
|
{{ detail.investment_type?.code ?? '' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4">{{ formatInvestmentAmount(detail.amount) }}</td>
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<Badge look="outline" :variant="salaryDetailStatusVariant(detail.status)">
|
||||||
|
{{ salaryDetailStatusLabel(detail.status) }}
|
||||||
|
</Badge>
|
||||||
|
<div v-if="detail.remarks" class="mt-1 text-xs opacity-60">
|
||||||
|
{{ detail.remarks }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<button v-if="detail.investment_id" type="button"
|
||||||
|
class="text-left text-sm underline opacity-80 hover:opacity-100"
|
||||||
|
@click="goToInvestment(detail.investment_id)">
|
||||||
|
{{ detail.investment?.reference_no ?? 'Lihat' }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="opacity-50">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3">
|
||||||
|
<div v-if="canVerify && canProcessBatch && detail.status === 'PENDING'"
|
||||||
|
class="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" class="bg-green-600 text-white" title="Sahkan"
|
||||||
|
:disabled="acting" @click="openVerifyDetail(detail)">
|
||||||
|
<Check class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" class="bg-danger text-white" title="Tolak"
|
||||||
|
:disabled="acting" @click="openRejectDetail(detail)">
|
||||||
|
<X class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<span v-else class="opacity-40">-</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!batch.details?.length">
|
||||||
|
<td colspan="6" class="py-6 text-center opacity-60">Tiada rekod dalam batch.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<DialogRoot :open="actionDialogOpen" @openChange="(details) => (actionDialogOpen = details.open)">
|
||||||
|
<DialogContent>
|
||||||
|
<div class="space-y-4 p-5">
|
||||||
|
<div class="text-xl font-medium">{{ actionTitle }}</div>
|
||||||
|
<p v-if="actionDetail" class="text-sm opacity-70">
|
||||||
|
{{ actionDetail.user?.name }} ·
|
||||||
|
{{ formatInvestmentAmount(actionDetail.amount) }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="actionType === 'reject-batch'" class="text-sm opacity-70">
|
||||||
|
Batch yang sudah ada rekod disahkan tidak boleh ditolak sepenuhnya.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AlertRoot v-if="actionError" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ actionError }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Catatan</FieldLabel>
|
||||||
|
<Textarea v-model="actionRemarks" rows="3" :disabled="acting" placeholder="Catatan pilihan..." />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 px-5 pb-5">
|
||||||
|
<DialogCloseTrigger :disabled="acting">Batal</DialogCloseTrigger>
|
||||||
|
<Button type="button" :variant="actionType === 'verify-detail' ? 'primary' : 'danger'" :disabled="acting"
|
||||||
|
@click="confirmAction">
|
||||||
|
{{ acting ? 'Memproses...' : 'Sahkan' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { Eye } from '@lucide/vue'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
SelectControl,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItemGroupLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemText,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { useSalaryDeductionBatchList } from '../composables/useSalaryDeductionBatchList'
|
||||||
|
import type {
|
||||||
|
SalaryDeductionBatch,
|
||||||
|
SalaryDeductionBatchStatus,
|
||||||
|
} from '../types/salary.types'
|
||||||
|
import {
|
||||||
|
SALARY_BATCH_STATUS_OPTIONS,
|
||||||
|
formatInvestmentAmount,
|
||||||
|
monthLabel,
|
||||||
|
salaryBatchStatusLabel,
|
||||||
|
salaryBatchStatusVariant,
|
||||||
|
} from '../utils/salary.utils'
|
||||||
|
|
||||||
|
type SelectOption = { label: string; value: string }
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||||
|
{ label: 'Semua Status', value: '' },
|
||||||
|
...SALARY_BATCH_STATUS_OPTIONS,
|
||||||
|
]
|
||||||
|
|
||||||
|
function createSelectCollection(options: SelectOption[]) {
|
||||||
|
return select.collection({
|
||||||
|
items: options,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||||
|
if (!label) return ''
|
||||||
|
return options.find((option) => option.label === label)?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||||
|
const option = options.find((item) => item.value === value)
|
||||||
|
return option ? [option.label] : [options[0]?.label ?? '']
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const canCreate = computed(() => hasPermission('tambah potongan gaji'))
|
||||||
|
const canView = computed(() => hasPermission('lihat potongan gaji'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
statusFilter,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
} = useSalaryDeductionBatchList()
|
||||||
|
|
||||||
|
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||||
|
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||||
|
|
||||||
|
const headers: TableHeader[] = [
|
||||||
|
{ title: 'No. Batch', key: 'batch_no', sortable: false },
|
||||||
|
{ title: 'Tempoh', key: 'period', sortable: false },
|
||||||
|
{ title: 'Rekod', key: 'total_records', sortable: false },
|
||||||
|
{ title: 'Jumlah', key: 'total_amount', sortable: false },
|
||||||
|
{ title: 'Status', key: 'status', sortable: false },
|
||||||
|
{ title: 'Dimuat Naik', key: 'uploaded_at', sortable: false },
|
||||||
|
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function setStatusFilter(details: { value: string[] }) {
|
||||||
|
statusFilter.value = labelToValue(
|
||||||
|
STATUS_FILTER_OPTIONS,
|
||||||
|
details.value[0],
|
||||||
|
) as SalaryDeductionBatchStatus | ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate() {
|
||||||
|
router.push({ name: 'create-salary-deduction-batch' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToDetail(id: string) {
|
||||||
|
router.push({ name: 'view-salary-deduction-batch', params: { id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col items-center sm:flex-row">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Batch Potongan Gaji</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Muat naik / jana batch bulanan, kemudian sahkan setiap rekod ke ledger.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||||
|
<Button
|
||||||
|
v-if="canCreate"
|
||||||
|
look="outline"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="goToCreate"
|
||||||
|
>
|
||||||
|
Jana / Muat Naik Batch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<DataTable
|
||||||
|
:headers="headers"
|
||||||
|
:items="items"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
show-pagination
|
||||||
|
exportable
|
||||||
|
export-file-name="batch-potongan-gaji"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:items-per-page="itemsPerPage"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<SelectRoot
|
||||||
|
class="w-full sm:w-56"
|
||||||
|
:collection="statusCollection"
|
||||||
|
:default-value="statusInitial"
|
||||||
|
@value-change="setStatusFilter"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Tapis status">
|
||||||
|
<SelectValueText placeholder="Semua Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in statusCollection.items"
|
||||||
|
:key="item.label"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.batch_no="{ item }">
|
||||||
|
<div class="font-medium">{{ (item as SalaryDeductionBatch).batch_no }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.period="{ item }">
|
||||||
|
{{ monthLabel((item as SalaryDeductionBatch).month) }}
|
||||||
|
{{ (item as SalaryDeductionBatch).year }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.total_amount="{ item }">
|
||||||
|
{{ formatInvestmentAmount((item as SalaryDeductionBatch).total_amount) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.status="{ item }">
|
||||||
|
<Badge
|
||||||
|
look="outline"
|
||||||
|
:variant="salaryBatchStatusVariant((item as SalaryDeductionBatch).status)"
|
||||||
|
>
|
||||||
|
{{ salaryBatchStatusLabel((item as SalaryDeductionBatch).status) }}
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.uploaded_at="{ item }">
|
||||||
|
{{ formatDate((item as SalaryDeductionBatch).uploaded_at) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<Button
|
||||||
|
v-if="canView"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="bg-green-600 text-white"
|
||||||
|
title="Lihat & sahkan"
|
||||||
|
@click="goToDetail((item as SalaryDeductionBatch).id)"
|
||||||
|
>
|
||||||
|
<Eye class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import UserInvestmentLimitForm from '../components/UserInvestmentLimitForm.vue'
|
||||||
|
import { createUserInvestmentLimit } from '../services/investment-limit.service'
|
||||||
|
import type { UserInvestmentLimitPayload } from '../types/investment-limit.types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(payload: UserInvestmentLimitPayload) {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createUserInvestmentLimit(payload)
|
||||||
|
router.push({ name: 'list-user-investment-limits' })
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal menambah had pelaburan ahli.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-user-investment-limits' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Tambah Had Pelaburan Ahli</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">Override had profil untuk ahli dan jenis pelaburan ini.</p>
|
||||||
|
</div>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box class="p-6">
|
||||||
|
<UserInvestmentLimitForm
|
||||||
|
:saving="saving"
|
||||||
|
submit-label="Tambah Override"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import UserInvestmentLimitForm from '../components/UserInvestmentLimitForm.vue'
|
||||||
|
import {
|
||||||
|
getUserInvestmentLimit,
|
||||||
|
updateUserInvestmentLimit,
|
||||||
|
} from '../services/investment-limit.service'
|
||||||
|
import type {
|
||||||
|
UserInvestmentLimit,
|
||||||
|
UserInvestmentLimitPayload,
|
||||||
|
} from '../types/investment-limit.types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const item = ref<UserInvestmentLimit | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadItem() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.value = await getUserInvestmentLimit(String(route.params.id))
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan had pelaburan ahli.')
|
||||||
|
item.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(payload: UserInvestmentLimitPayload) {
|
||||||
|
if (!item.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateUserInvestmentLimit(item.value.id, payload)
|
||||||
|
router.push({ name: 'list-user-investment-limits' })
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal mengemaskini had pelaburan ahli.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-user-investment-limits' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItem()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 class="mr-auto text-lg font-medium">Kemaskini Had Pelaburan Ahli</h2>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||||
|
|
||||||
|
<Box v-else-if="item" class="p-6">
|
||||||
|
<UserInvestmentLimitForm
|
||||||
|
:initial="item"
|
||||||
|
:saving="saving"
|
||||||
|
submit-label="Kemaskini"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { Pencil, Trash2 } from '@lucide/vue'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import { useUserInvestmentLimitList } from '../composables/useUserInvestmentLimitList'
|
||||||
|
import { deleteUserInvestmentLimit } from '../services/investment-limit.service'
|
||||||
|
import type { UserInvestmentLimit } from '../types/investment-limit.types'
|
||||||
|
import { formatInvestmentAmount } from '../utils/investment-type.utils'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const canCreate = computed(() => hasPermission('tambah had pelaburan'))
|
||||||
|
const canUpdate = computed(() => hasPermission('kemaskini had pelaburan'))
|
||||||
|
const canDelete = computed(() => hasPermission('hapus had pelaburan'))
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
const itemToDelete = ref<UserInvestmentLimit | null>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const deleteError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
sortBy,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
handleSortUpdate,
|
||||||
|
fetchItems,
|
||||||
|
} = useUserInvestmentLimitList()
|
||||||
|
|
||||||
|
const headers: TableHeader[] = [
|
||||||
|
{ title: 'Ahli', key: 'user', sortable: false },
|
||||||
|
{ title: 'Jenis', key: 'investment_type', sortable: false },
|
||||||
|
{ title: 'Had Kumulatif', key: 'max_investment_amount', sortable: true },
|
||||||
|
{ title: 'Had Transaksi', key: 'max_transaction_amount', sortable: true },
|
||||||
|
{ title: 'Mula', key: 'effective_date', sortable: true },
|
||||||
|
{ title: 'Tamat', key: 'expiry_date', sortable: true },
|
||||||
|
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('DD MMM YYYY')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate() {
|
||||||
|
router.push({ name: 'create-user-investment-limit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToEdit(id: string) {
|
||||||
|
router.push({ name: 'edit-user-investment-limit', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(item: UserInvestmentLimit) {
|
||||||
|
itemToDelete.value = item
|
||||||
|
deleteError.value = null
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!itemToDelete.value || deleting.value) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
deleteError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteUserInvestmentLimit(itemToDelete.value.id)
|
||||||
|
deleteOpen.value = false
|
||||||
|
itemToDelete.value = null
|
||||||
|
await fetchItems(page.value)
|
||||||
|
} catch (err) {
|
||||||
|
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus had ahli.')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col items-center sm:flex-row">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Had Pelaburan Ahli</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Override had profil untuk ahli tertentu. Override aktif mengatasi had profil.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||||
|
<Button
|
||||||
|
v-if="canCreate"
|
||||||
|
look="outline"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="goToCreate"
|
||||||
|
>
|
||||||
|
Tambah Override
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<DataTable
|
||||||
|
:headers="headers"
|
||||||
|
:items="items"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
:current-sort="sortBy"
|
||||||
|
show-pagination
|
||||||
|
exportable
|
||||||
|
export-file-name="had-pelaburan-ahli"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:items-per-page="itemsPerPage"
|
||||||
|
@update:sort-by="handleSortUpdate"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<Input
|
||||||
|
v-model="search"
|
||||||
|
class="w-full max-w-md"
|
||||||
|
type="search"
|
||||||
|
placeholder="Cari ahli atau jenis pelaburan..."
|
||||||
|
aria-label="Cari had ahli"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.user="{ item }">
|
||||||
|
<div>{{ (item as UserInvestmentLimit).user?.name ?? '-' }}</div>
|
||||||
|
<div class="mt-0.5 text-xs opacity-60">
|
||||||
|
{{ (item as UserInvestmentLimit).user?.ic_number
|
||||||
|
|| (item as UserInvestmentLimit).user?.email
|
||||||
|
|| '' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.investment_type="{ item }">
|
||||||
|
<div>{{ (item as UserInvestmentLimit).investment_type?.name ?? '-' }}</div>
|
||||||
|
<div class="mt-0.5 text-xs uppercase opacity-60">
|
||||||
|
{{ (item as UserInvestmentLimit).investment_type?.code ?? '' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.max_investment_amount="{ item }">
|
||||||
|
{{ formatInvestmentAmount((item as UserInvestmentLimit).max_investment_amount) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.max_transaction_amount="{ item }">
|
||||||
|
{{ formatInvestmentAmount((item as UserInvestmentLimit).max_transaction_amount) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.effective_date="{ item }">
|
||||||
|
{{ formatDate((item as UserInvestmentLimit).effective_date) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.expiry_date="{ item }">
|
||||||
|
{{ formatDate((item as UserInvestmentLimit).expiry_date) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="canUpdate"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="Edit"
|
||||||
|
@click="goToEdit((item as UserInvestmentLimit).id)"
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canDelete"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="bg-danger text-white"
|
||||||
|
title="Hapus"
|
||||||
|
@click="openDelete(item as UserInvestmentLimit)"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||||
|
<DialogContent>
|
||||||
|
<div class="p-5 text-center">
|
||||||
|
<div class="mt-5 text-2xl font-medium">Hapus override had?</div>
|
||||||
|
<div class="mt-2 opacity-70">
|
||||||
|
Override untuk
|
||||||
|
<span v-if="itemToDelete" class="font-medium">
|
||||||
|
{{ itemToDelete.user?.name }} / {{ itemToDelete.investment_type?.name }}
|
||||||
|
</span>
|
||||||
|
akan dipadam.
|
||||||
|
</div>
|
||||||
|
<div v-if="deleteError" class="mt-4 text-sm text-danger">{{ deleteError }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-8 text-center">
|
||||||
|
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||||
|
<Button
|
||||||
|
class="w-24"
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
look="outline"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
SelectControl,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValueText,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemText,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import MemberSearchField from '../components/MemberSearchField.vue'
|
||||||
|
import {
|
||||||
|
assignUserLimitProfile,
|
||||||
|
listInvestmentLimitProfiles,
|
||||||
|
} from '../services/investment-limit.service'
|
||||||
|
import type { InvestmentLimitProfile } from '../types/investment-limit.types'
|
||||||
|
|
||||||
|
type SelectOption = { label: string; value: string }
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const loadingProfiles = ref(false)
|
||||||
|
const profiles = ref<InvestmentLimitProfile[]>([])
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
user_id: '',
|
||||||
|
profile_id: '',
|
||||||
|
effective_date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const profileOptions = computed<SelectOption[]>(() =>
|
||||||
|
profiles.value.map((profile) => ({
|
||||||
|
label: profile.name,
|
||||||
|
value: profile.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const profileCollection = computed(() =>
|
||||||
|
select.collection({
|
||||||
|
items: profileOptions.value,
|
||||||
|
itemToValue: (item) => item.label,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function setProfileValue(details: { value: string[] }) {
|
||||||
|
const label = details.value[0]
|
||||||
|
form.profile_id = profileOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfiles() {
|
||||||
|
loadingProfiles.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listInvestmentLimitProfiles({
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
})
|
||||||
|
profiles.value = data.data
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai profil had.')
|
||||||
|
profiles.value = []
|
||||||
|
} finally {
|
||||||
|
loadingProfiles.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assignUserLimitProfile({
|
||||||
|
user_id: form.user_id,
|
||||||
|
profile_id: form.profile_id,
|
||||||
|
effective_date: form.effective_date,
|
||||||
|
})
|
||||||
|
router.push({ name: 'list-user-limit-profiles' })
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err, 'Gagal menugaskan profil had.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push({ name: 'list-user-limit-profiles' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProfiles()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Tugaskan Profil Had</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Ahli akan menggunakan had daripada profil yang dipilih.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<Box class="p-6">
|
||||||
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||||
|
<MemberSearchField v-model="form.user_id" required :disabled="saving" />
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Profil Had</FieldLabel>
|
||||||
|
<p v-if="loadingProfiles" class="text-sm opacity-60">Memuatkan profil...</p>
|
||||||
|
<SelectRoot
|
||||||
|
v-else-if="profileOptions.length"
|
||||||
|
:key="profileOptions.map((item) => item.value).join(',')"
|
||||||
|
class="w-full"
|
||||||
|
:collection="profileCollection"
|
||||||
|
:disabled="saving"
|
||||||
|
@value-change="setProfileValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger aria-label="Pilih profil had">
|
||||||
|
<SelectValueText placeholder="Pilih profil had" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in profileCollection.items"
|
||||||
|
:key="item.value"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
<p v-else class="text-sm opacity-60">Tiada profil had. Cipta profil dahulu.</p>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Tarikh Berkuat Kuasa</FieldLabel>
|
||||||
|
<Input v-model="form.effective_date" type="date" :disabled="saving" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="saving || !form.user_id || !form.profile_id || !form.effective_date"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Menyimpan...' : 'Tugaskan' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<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 { Button } from '@/components/ui/button'
|
||||||
|
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||||
|
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||||
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { useUserLimitProfileList } from '../composables/useUserLimitProfileList'
|
||||||
|
import type { UserLimitProfile } from '../types/investment-limit.types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const canAssign = computed(() => hasPermission('tambah had pelaburan'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
activeOnly,
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
pagination,
|
||||||
|
} = useUserLimitProfileList()
|
||||||
|
|
||||||
|
const headers: TableHeader[] = [
|
||||||
|
{ title: 'Ahli', key: 'user', sortable: false },
|
||||||
|
{ title: 'Profil', key: 'profile', sortable: false },
|
||||||
|
{ title: 'Mula', key: 'effective_date', sortable: false },
|
||||||
|
{ title: 'Tamat', key: 'expiry_date', sortable: false },
|
||||||
|
{ title: 'Status', key: 'status', sortable: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('DD MMM YYYY')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(item: UserLimitProfile): boolean {
|
||||||
|
const today = dayjs().startOf('day')
|
||||||
|
if (dayjs(item.effective_date).startOf('day').isAfter(today)) return false
|
||||||
|
if (!item.expiry_date) return true
|
||||||
|
return !dayjs(item.expiry_date).startOf('day').isBefore(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToAssign() {
|
||||||
|
router.push({ name: 'assign-user-limit-profile' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col items-center sm:flex-row">
|
||||||
|
<div class="mr-auto">
|
||||||
|
<h2 class="text-lg font-medium">Tugasan Profil Had</h2>
|
||||||
|
<p class="mt-1 text-sm opacity-70">
|
||||||
|
Tugaskan profil had kepada ahli. Tugasan baharu akan tamatkan tugasan aktif sedia ada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||||
|
<Button
|
||||||
|
v-if="canAssign"
|
||||||
|
look="outline"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="goToAssign"
|
||||||
|
>
|
||||||
|
Tugaskan Profil
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||||
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</AlertRoot>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<DataTable
|
||||||
|
:headers="headers"
|
||||||
|
:items="items"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
show-pagination
|
||||||
|
exportable
|
||||||
|
export-file-name="tugasan-profil-had"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:items-per-page="itemsPerPage"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<CheckboxRoot
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
:checked="activeOnly"
|
||||||
|
@checked-change="({ checked }) => (activeOnly = checked === true)"
|
||||||
|
>
|
||||||
|
<CheckboxControl />
|
||||||
|
<CheckboxLabel>Aktif sahaja</CheckboxLabel>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.user="{ item }">
|
||||||
|
<div>{{ (item as UserLimitProfile).user?.name ?? '-' }}</div>
|
||||||
|
<div class="mt-0.5 text-xs opacity-60">
|
||||||
|
{{ (item as UserLimitProfile).user?.ic_number
|
||||||
|
|| (item as UserLimitProfile).user?.email
|
||||||
|
|| '' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.profile="{ item }">
|
||||||
|
{{ (item as UserLimitProfile).profile?.name ?? '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.effective_date="{ item }">
|
||||||
|
{{ formatDate((item as UserLimitProfile).effective_date) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.expiry_date="{ item }">
|
||||||
|
{{ formatDate((item as UserLimitProfile).expiry_date) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.status="{ item }">
|
||||||
|
<Badge
|
||||||
|
look="outline"
|
||||||
|
:variant="isActive(item as UserLimitProfile) ? 'success' : 'secondary'"
|
||||||
|
>
|
||||||
|
{{ isActive(item as UserLimitProfile) ? 'Aktif' : 'Tamat' }}
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
export const investmentLayoutRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: 'list-investments',
|
||||||
|
name: 'list-investments',
|
||||||
|
component: () => import('./pages/InvestmentList.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Pelaburan',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'investments/manual/create',
|
||||||
|
name: 'create-manual-investment',
|
||||||
|
component: () => import('./pages/InvestmentManualCreate.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Rekod Pelaburan Manual',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'tambah pelaburan manual',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'investments/:id',
|
||||||
|
name: 'view-investment',
|
||||||
|
component: () => import('./pages/InvestmentDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Butiran Pelaburan',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'list-investment-types',
|
||||||
|
name: 'list-investment-types',
|
||||||
|
component: () => import('./pages/InvestmentTypeList.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Jenis Pelaburan',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat jenis pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'investment-types/create',
|
||||||
|
name: 'create-investment-type',
|
||||||
|
component: () => import('./pages/InvestmentTypeCreate.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Tambah Jenis Pelaburan',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'tambah jenis pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'investment-types/:id/edit',
|
||||||
|
name: 'edit-investment-type',
|
||||||
|
component: () => import('./pages/InvestmentTypeEdit.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Kemaskini Jenis Pelaburan',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'kemaskini jenis pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'list-investment-limit-profiles',
|
||||||
|
name: 'list-investment-limit-profiles',
|
||||||
|
component: () => import('./pages/InvestmentLimitProfileList.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Profil Had Pelaburan',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat had pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'investment-limit-profiles/create',
|
||||||
|
name: 'create-investment-limit-profile',
|
||||||
|
component: () => import('./pages/InvestmentLimitProfileCreate.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Tambah Profil Had',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'tambah had pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'investment-limit-profiles/:id',
|
||||||
|
name: 'view-investment-limit-profile',
|
||||||
|
component: () => import('./pages/InvestmentLimitProfileDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Urus Profil Had',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat had pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'list-user-limit-profiles',
|
||||||
|
name: 'list-user-limit-profiles',
|
||||||
|
component: () => import('./pages/UserLimitProfileList.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Tugasan Profil Had',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat had pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-limit-profiles/assign',
|
||||||
|
name: 'assign-user-limit-profile',
|
||||||
|
component: () => import('./pages/UserLimitProfileAssign.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Tugaskan Profil Had',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'tambah had pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'list-user-investment-limits',
|
||||||
|
name: 'list-user-investment-limits',
|
||||||
|
component: () => import('./pages/UserInvestmentLimitList.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Had Pelaburan Ahli',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat had pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-investment-limits/create',
|
||||||
|
name: 'create-user-investment-limit',
|
||||||
|
component: () => import('./pages/UserInvestmentLimitCreate.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Tambah Had Pelaburan Ahli',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'tambah had pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-investment-limits/:id/edit',
|
||||||
|
name: 'edit-user-investment-limit',
|
||||||
|
component: () => import('./pages/UserInvestmentLimitEdit.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Kemaskini Had Pelaburan Ahli',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'kemaskini had pelaburan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'list-salary-contributions',
|
||||||
|
name: 'list-salary-contributions',
|
||||||
|
component: () => import('./pages/SalaryContributionList.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Caruman Potongan Gaji',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat potongan gaji',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'salary-contributions/create',
|
||||||
|
name: 'create-salary-contribution',
|
||||||
|
component: () => import('./pages/SalaryContributionCreate.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Tambah Caruman Potongan Gaji',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'tambah potongan gaji',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'salary-contributions/:id/edit',
|
||||||
|
name: 'edit-salary-contribution',
|
||||||
|
component: () => import('./pages/SalaryContributionEdit.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Kemaskini Caruman Potongan Gaji',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'kemaskini potongan gaji',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'list-salary-deduction-batches',
|
||||||
|
name: 'list-salary-deduction-batches',
|
||||||
|
component: () => import('./pages/SalaryDeductionBatchList.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Batch Potongan Gaji',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat potongan gaji',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'salary-deduction-batches/create',
|
||||||
|
name: 'create-salary-deduction-batch',
|
||||||
|
component: () => import('./pages/SalaryDeductionBatchCreate.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Jana / Muat Naik Batch',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'tambah potongan gaji',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'salary-deduction-batches/:id',
|
||||||
|
name: 'view-salary-deduction-batch',
|
||||||
|
component: () => import('./pages/SalaryDeductionBatchDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Butiran Batch Potongan Gaji',
|
||||||
|
module: 'investment',
|
||||||
|
permission: 'lihat potongan gaji',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { api } from '@/core/services/api'
|
||||||
|
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||||
|
import type {
|
||||||
|
AssignUserLimitProfilePayload,
|
||||||
|
InvestmentLimitProfile,
|
||||||
|
InvestmentLimitProfilePayload,
|
||||||
|
ListInvestmentLimitProfilesParams,
|
||||||
|
ListUserInvestmentLimitsParams,
|
||||||
|
ListUserLimitProfilesParams,
|
||||||
|
ProfileInvestmentLimit,
|
||||||
|
ProfileInvestmentLimitPayload,
|
||||||
|
UserInvestmentLimit,
|
||||||
|
UserInvestmentLimitPayload,
|
||||||
|
UserLimitProfile,
|
||||||
|
} from '../types/investment-limit.types'
|
||||||
|
|
||||||
|
type ProfileApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: InvestmentLimitProfile
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileLimitApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: ProfileInvestmentLimit
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileLimitsApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: ProfileInvestmentLimit[]
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserLimitProfileApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: UserLimitProfile
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserInvestmentLimitApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: UserInvestmentLimit
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInvestmentLimitProfiles(
|
||||||
|
params?: ListInvestmentLimitProfilesParams,
|
||||||
|
): Promise<PaginatedApiResponse<InvestmentLimitProfile>> {
|
||||||
|
const { data } = await api.get<PaginatedApiResponse<InvestmentLimitProfile>>(
|
||||||
|
'/v1/investment-limit-profiles',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan profil had pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvestmentLimitProfile(id: string): Promise<InvestmentLimitProfile> {
|
||||||
|
const { data } = await api.get<ProfileApiResponse>(`/v1/investment-limit-profiles/${id}`)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan profil had pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvestmentLimitProfile(
|
||||||
|
payload: InvestmentLimitProfilePayload,
|
||||||
|
): Promise<InvestmentLimitProfile> {
|
||||||
|
const { data } = await api.post<ProfileApiResponse>('/v1/investment-limit-profiles', payload)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menambah profil had pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateInvestmentLimitProfile(
|
||||||
|
id: string,
|
||||||
|
payload: InvestmentLimitProfilePayload,
|
||||||
|
): Promise<InvestmentLimitProfile> {
|
||||||
|
const { data } = await api.put<ProfileApiResponse>(
|
||||||
|
`/v1/investment-limit-profiles/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal mengemaskini profil had pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInvestmentLimitProfile(id: string): Promise<void> {
|
||||||
|
const { data } = await api.delete<MessageApiResponse>(`/v1/investment-limit-profiles/${id}`)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menghapus profil had pelaburan.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listProfileInvestmentLimits(
|
||||||
|
profileId: string,
|
||||||
|
): Promise<ProfileInvestmentLimit[]> {
|
||||||
|
const { data } = await api.get<ProfileLimitsApiResponse>(
|
||||||
|
`/v1/investment-limit-profiles/${profileId}/limits`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan had profil.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProfileInvestmentLimit(
|
||||||
|
profileId: string,
|
||||||
|
payload: ProfileInvestmentLimitPayload,
|
||||||
|
): Promise<ProfileInvestmentLimit> {
|
||||||
|
const { data } = await api.post<ProfileLimitApiResponse>(
|
||||||
|
`/v1/investment-limit-profiles/${profileId}/limits`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menambah had profil.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfileInvestmentLimit(
|
||||||
|
profileId: string,
|
||||||
|
limitId: string,
|
||||||
|
payload: ProfileInvestmentLimitPayload,
|
||||||
|
): Promise<ProfileInvestmentLimit> {
|
||||||
|
const { data } = await api.put<ProfileLimitApiResponse>(
|
||||||
|
`/v1/investment-limit-profiles/${profileId}/limits/${limitId}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal mengemaskini had profil.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProfileInvestmentLimit(
|
||||||
|
profileId: string,
|
||||||
|
limitId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { data } = await api.delete<MessageApiResponse>(
|
||||||
|
`/v1/investment-limit-profiles/${profileId}/limits/${limitId}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menghapus had profil.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserLimitProfiles(
|
||||||
|
params?: ListUserLimitProfilesParams,
|
||||||
|
): Promise<PaginatedApiResponse<UserLimitProfile>> {
|
||||||
|
const { data } = await api.get<PaginatedApiResponse<UserLimitProfile>>(
|
||||||
|
'/v1/user-limit-profiles',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan tugasan profil had.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignUserLimitProfile(
|
||||||
|
payload: AssignUserLimitProfilePayload,
|
||||||
|
): Promise<UserLimitProfile> {
|
||||||
|
const { data } = await api.post<UserLimitProfileApiResponse>(
|
||||||
|
'/v1/user-limit-profiles',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menugaskan profil had.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserInvestmentLimits(
|
||||||
|
params?: ListUserInvestmentLimitsParams,
|
||||||
|
): Promise<PaginatedApiResponse<UserInvestmentLimit>> {
|
||||||
|
const { data } = await api.get<PaginatedApiResponse<UserInvestmentLimit>>(
|
||||||
|
'/v1/user-investment-limits',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan had pelaburan ahli.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserInvestmentLimit(id: string): Promise<UserInvestmentLimit> {
|
||||||
|
const { data } = await api.get<UserInvestmentLimitApiResponse>(
|
||||||
|
`/v1/user-investment-limits/${id}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan had pelaburan ahli.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserInvestmentLimit(
|
||||||
|
payload: UserInvestmentLimitPayload,
|
||||||
|
): Promise<UserInvestmentLimit> {
|
||||||
|
const { data } = await api.post<UserInvestmentLimitApiResponse>(
|
||||||
|
'/v1/user-investment-limits',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menambah had pelaburan ahli.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserInvestmentLimit(
|
||||||
|
id: string,
|
||||||
|
payload: UserInvestmentLimitPayload,
|
||||||
|
): Promise<UserInvestmentLimit> {
|
||||||
|
const { data } = await api.put<UserInvestmentLimitApiResponse>(
|
||||||
|
`/v1/user-investment-limits/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal mengemaskini had pelaburan ahli.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserInvestmentLimit(id: string): Promise<void> {
|
||||||
|
const { data } = await api.delete<MessageApiResponse>(`/v1/user-investment-limits/${id}`)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menghapus had pelaburan ahli.')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { api } from '@/core/services/api'
|
||||||
|
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||||
|
import type {
|
||||||
|
InvestmentType,
|
||||||
|
InvestmentTypePayload,
|
||||||
|
ListInvestmentTypesParams,
|
||||||
|
} from '../types/investment-type.types'
|
||||||
|
|
||||||
|
type InvestmentTypeApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: InvestmentType
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteInvestmentTypeApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInvestmentTypes(
|
||||||
|
params?: ListInvestmentTypesParams,
|
||||||
|
): Promise<PaginatedApiResponse<InvestmentType>> {
|
||||||
|
const { data } = await api.get<PaginatedApiResponse<InvestmentType>>('/v1/investment-types', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan senarai jenis pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvestmentType(id: string): Promise<InvestmentType> {
|
||||||
|
const { data } = await api.get<InvestmentTypeApiResponse>(`/v1/investment-types/${id}`)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan jenis pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvestmentType(
|
||||||
|
payload: InvestmentTypePayload,
|
||||||
|
): Promise<InvestmentType> {
|
||||||
|
const { data } = await api.post<InvestmentTypeApiResponse>('/v1/investment-types', payload)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menambah jenis pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateInvestmentType(
|
||||||
|
id: string,
|
||||||
|
payload: InvestmentTypePayload,
|
||||||
|
): Promise<InvestmentType> {
|
||||||
|
const { data } = await api.put<InvestmentTypeApiResponse>(`/v1/investment-types/${id}`, payload)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal mengemaskini jenis pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInvestmentType(id: string): Promise<void> {
|
||||||
|
const { data } = await api.delete<DeleteInvestmentTypeApiResponse>(`/v1/investment-types/${id}`)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menghapus jenis pelaburan.')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { api } from '@/core/services/api'
|
||||||
|
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||||
|
import type {
|
||||||
|
Investment,
|
||||||
|
ListInvestmentsParams,
|
||||||
|
ManualInvestmentFiles,
|
||||||
|
ManualInvestmentPayload,
|
||||||
|
MemberInvestmentSummary,
|
||||||
|
} from '../types/investment.types'
|
||||||
|
|
||||||
|
type InvestmentApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: Investment
|
||||||
|
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>> {
|
||||||
|
const { data } = await api.get<PaginatedApiResponse<Investment>>('/v1/investments', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan senarai pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvestment(id: string): Promise<Investment> {
|
||||||
|
const { data } = await api.get<InvestmentApiResponse>(`/v1/investments/${id}`)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan pelaburan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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.')
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { api } from '@/core/services/api'
|
||||||
|
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||||
|
import type {
|
||||||
|
GenerateSalaryDeductionBatchPayload,
|
||||||
|
ListSalaryContributionsParams,
|
||||||
|
ListSalaryDeductionBatchesParams,
|
||||||
|
SalaryContribution,
|
||||||
|
SalaryContributionPayload,
|
||||||
|
SalaryDeductionBatch,
|
||||||
|
SalaryDeductionBatchDetail,
|
||||||
|
UploadSalaryDeductionBatchPayload,
|
||||||
|
} from '../types/salary.types'
|
||||||
|
|
||||||
|
type ContributionApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: SalaryContribution
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: SalaryDeductionBatch
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetailApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
data: SalaryDeductionBatchDetail
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageApiResponse = {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSalaryContributions(
|
||||||
|
params?: ListSalaryContributionsParams,
|
||||||
|
): Promise<PaginatedApiResponse<SalaryContribution>> {
|
||||||
|
const { data } = await api.get<PaginatedApiResponse<SalaryContribution>>(
|
||||||
|
'/v1/salary-contributions',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan caruman potongan gaji.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSalaryContribution(id: string): Promise<SalaryContribution> {
|
||||||
|
const { data } = await api.get<ContributionApiResponse>(`/v1/salary-contributions/${id}`)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan caruman potongan gaji.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSalaryContribution(
|
||||||
|
payload: SalaryContributionPayload,
|
||||||
|
): Promise<SalaryContribution> {
|
||||||
|
const { data } = await api.post<ContributionApiResponse>('/v1/salary-contributions', payload)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menambah caruman potongan gaji.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSalaryContribution(
|
||||||
|
id: string,
|
||||||
|
payload: SalaryContributionPayload,
|
||||||
|
): Promise<SalaryContribution> {
|
||||||
|
const { data } = await api.put<ContributionApiResponse>(
|
||||||
|
`/v1/salary-contributions/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal mengemaskini caruman potongan gaji.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSalaryContribution(id: string): Promise<void> {
|
||||||
|
const { data } = await api.delete<MessageApiResponse>(`/v1/salary-contributions/${id}`)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menghapus caruman potongan gaji.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSalaryDeductionBatches(
|
||||||
|
params?: ListSalaryDeductionBatchesParams,
|
||||||
|
): Promise<PaginatedApiResponse<SalaryDeductionBatch>> {
|
||||||
|
const { data } = await api.get<PaginatedApiResponse<SalaryDeductionBatch>>(
|
||||||
|
'/v1/salary-deduction-batches',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan batch potongan gaji.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSalaryDeductionBatch(id: string): Promise<SalaryDeductionBatch> {
|
||||||
|
const { data } = await api.get<BatchApiResponse>(`/v1/salary-deduction-batches/${id}`)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuatkan batch potongan gaji.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadSalaryDeductionBatch(
|
||||||
|
payload: UploadSalaryDeductionBatchPayload,
|
||||||
|
): Promise<SalaryDeductionBatch> {
|
||||||
|
const { data } = await api.post<BatchApiResponse>('/v1/salary-deduction-batches', payload)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memuat naik batch potongan gaji.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSalaryDeductionBatch(
|
||||||
|
payload: GenerateSalaryDeductionBatchPayload,
|
||||||
|
): Promise<SalaryDeductionBatch> {
|
||||||
|
const { data } = await api.post<BatchApiResponse>(
|
||||||
|
'/v1/salary-deduction-batches/generate',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menjana batch potongan gaji.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markSalaryDeductionBatchUnderReview(
|
||||||
|
id: string,
|
||||||
|
): Promise<SalaryDeductionBatch> {
|
||||||
|
const { data } = await api.post<BatchApiResponse>(
|
||||||
|
`/v1/salary-deduction-batches/${id}/under-review`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menghantar batch untuk semakan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAllSalaryDeductionBatch(
|
||||||
|
id: string,
|
||||||
|
): Promise<SalaryDeductionBatch> {
|
||||||
|
const { data } = await api.post<BatchApiResponse>(
|
||||||
|
`/v1/salary-deduction-batches/${id}/verify-all`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal mengesahkan semua rekod.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectSalaryDeductionBatch(
|
||||||
|
id: string,
|
||||||
|
remarks?: string | null,
|
||||||
|
): Promise<SalaryDeductionBatch> {
|
||||||
|
const { data } = await api.post<BatchApiResponse>(
|
||||||
|
`/v1/salary-deduction-batches/${id}/reject`,
|
||||||
|
{ remarks: remarks ?? null },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menolak batch.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySalaryDeductionDetail(
|
||||||
|
batchId: string,
|
||||||
|
detailId: string,
|
||||||
|
remarks?: string | null,
|
||||||
|
): Promise<SalaryDeductionBatchDetail> {
|
||||||
|
const { data } = await api.post<DetailApiResponse>(
|
||||||
|
`/v1/salary-deduction-batches/${batchId}/details/${detailId}/verify`,
|
||||||
|
{ remarks: remarks ?? null },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal mengesahkan rekod.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectSalaryDeductionDetail(
|
||||||
|
batchId: string,
|
||||||
|
detailId: string,
|
||||||
|
remarks?: string | null,
|
||||||
|
): Promise<SalaryDeductionBatchDetail> {
|
||||||
|
const { data } = await api.post<DetailApiResponse>(
|
||||||
|
`/v1/salary-deduction-batches/${batchId}/details/${detailId}/reject`,
|
||||||
|
{ remarks: remarks ?? null },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menolak rekod.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
export type InvestmentLimitProfile = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
profile_limits_count?: number
|
||||||
|
profile_limits?: ProfileInvestmentLimit[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvestmentLimitProfilePayload = {
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfileInvestmentLimit = {
|
||||||
|
id: string
|
||||||
|
profile_id: string
|
||||||
|
investment_type_id: string
|
||||||
|
max_investment_amount: string | number | null
|
||||||
|
max_transaction_amount: string | number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
investment_type?: {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfileInvestmentLimitPayload = {
|
||||||
|
investment_type_id: string
|
||||||
|
max_investment_amount?: number | null
|
||||||
|
max_transaction_amount?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserLimitProfile = {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
profile_id: string
|
||||||
|
effective_date: string
|
||||||
|
expiry_date: string | null
|
||||||
|
created_at: string
|
||||||
|
user?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
ic_number?: string | null
|
||||||
|
} | null
|
||||||
|
profile?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssignUserLimitProfilePayload = {
|
||||||
|
user_id: string
|
||||||
|
profile_id: string
|
||||||
|
effective_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserInvestmentLimit = {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
investment_type_id: string
|
||||||
|
max_investment_amount: string | number | null
|
||||||
|
max_transaction_amount: string | number | null
|
||||||
|
effective_date: string
|
||||||
|
expiry_date: string | null
|
||||||
|
created_by: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
user?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
ic_number?: string | null
|
||||||
|
} | null
|
||||||
|
investment_type?: {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserInvestmentLimitPayload = {
|
||||||
|
user_id: string
|
||||||
|
investment_type_id: string
|
||||||
|
max_investment_amount?: number | null
|
||||||
|
max_transaction_amount?: number | null
|
||||||
|
effective_date: string
|
||||||
|
expiry_date?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListInvestmentLimitProfilesParams = {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
search?: string
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListUserLimitProfilesParams = {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
user_id?: string
|
||||||
|
active_only?: boolean | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListUserInvestmentLimitsParams = {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
search?: string
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
user_id?: string
|
||||||
|
investment_type_id?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type InvestmentTypeStatus = 'ACTIVE' | 'INACTIVE' | 'CLOSED'
|
||||||
|
|
||||||
|
export type InvestmentType = {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
status: InvestmentTypeStatus
|
||||||
|
minimum_amount: string | number
|
||||||
|
maximum_amount: string | number | null
|
||||||
|
total_fund_limit: string | number | null
|
||||||
|
reserved_amount: string | number
|
||||||
|
start_date: string | null
|
||||||
|
end_date: string | null
|
||||||
|
allow_billplz: boolean
|
||||||
|
allow_salary_deduction: boolean
|
||||||
|
billplz_collection_id: string | null
|
||||||
|
created_by: string | null
|
||||||
|
updated_by: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvestmentTypePayload = {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
status: InvestmentTypeStatus
|
||||||
|
minimum_amount: number
|
||||||
|
maximum_amount?: number | null
|
||||||
|
total_fund_limit?: number | null
|
||||||
|
start_date?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
allow_billplz: boolean
|
||||||
|
allow_salary_deduction: boolean
|
||||||
|
billplz_collection_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListInvestmentTypesParams = {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
search?: string
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
export type InvestmentSource =
|
||||||
|
| 'BILLPLZ'
|
||||||
|
| 'SALARY_DEDUCTION'
|
||||||
|
| 'MANUAL'
|
||||||
|
| 'ADJUSTMENT'
|
||||||
|
| 'REFUND'
|
||||||
|
|
||||||
|
export type InvestmentStatus =
|
||||||
|
| 'DRAFT'
|
||||||
|
| 'PENDING_PAYMENT'
|
||||||
|
| 'PENDING_VERIFICATION'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'REJECTED'
|
||||||
|
| 'CANCELLED'
|
||||||
|
|
||||||
|
export type InvestmentUserSummary = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
ic_number?: string | null
|
||||||
|
member_number?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvestmentTypeSummary = {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvestmentAccountSummary = {
|
||||||
|
id: string
|
||||||
|
current_balance: string | number
|
||||||
|
pending_balance: string | number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvestmentLedgerEntry = {
|
||||||
|
id: string
|
||||||
|
transaction_type: string
|
||||||
|
credit: string | number
|
||||||
|
debit: string | number
|
||||||
|
balance_after: string | number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvestmentPaymentSummary = {
|
||||||
|
id: string
|
||||||
|
gateway: string
|
||||||
|
amount: string | number
|
||||||
|
currency: string
|
||||||
|
status: string
|
||||||
|
external_reference: string | null
|
||||||
|
paid_at: string | null
|
||||||
|
billplz?: {
|
||||||
|
bill_id: string | null
|
||||||
|
bill_url: string | null
|
||||||
|
state: string | null
|
||||||
|
transaction_status: string | null
|
||||||
|
signature_verified: boolean
|
||||||
|
} | 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
|
||||||
|
user_id: string
|
||||||
|
investment_account_id: string
|
||||||
|
investment_type_id: string
|
||||||
|
amount: string | number
|
||||||
|
source: InvestmentSource
|
||||||
|
status: InvestmentStatus
|
||||||
|
remarks: string | null
|
||||||
|
created_by: string | null
|
||||||
|
approved_by: string | null
|
||||||
|
approved_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
cancelled_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
user?: InvestmentUserSummary | null
|
||||||
|
investment_type?: InvestmentTypeSummary | null
|
||||||
|
account?: InvestmentAccountSummary | null
|
||||||
|
created_by_user?: { id: string; name: string; email: string } | null
|
||||||
|
approved_by_user?: { id: string; name: string; email: string } | null
|
||||||
|
ledger_entries?: InvestmentLedgerEntry[]
|
||||||
|
payment?: InvestmentPaymentSummary | null
|
||||||
|
documents?: InvestmentDocument[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManualInvestmentPayload = {
|
||||||
|
user_id: string
|
||||||
|
investment_type_id: string
|
||||||
|
amount: number
|
||||||
|
remarks?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManualInvestmentFiles = {
|
||||||
|
documents?: File[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListInvestmentsParams = {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
search?: string
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
status?: InvestmentStatus | ''
|
||||||
|
source?: InvestmentSource | ''
|
||||||
|
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[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
export type SalaryContributionStatus = 'ACTIVE' | 'PAUSED' | 'ENDED'
|
||||||
|
|
||||||
|
export type SalaryDeductionBatchStatus =
|
||||||
|
| 'UPLOADED'
|
||||||
|
| 'UNDER_REVIEW'
|
||||||
|
| 'VERIFIED'
|
||||||
|
| 'REJECTED'
|
||||||
|
|
||||||
|
export type SalaryDeductionDetailStatus = 'PENDING' | 'VERIFIED' | 'REJECTED'
|
||||||
|
|
||||||
|
export type SalaryContribution = {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
investment_type_id: string
|
||||||
|
monthly_amount: string | number
|
||||||
|
start_date: string
|
||||||
|
end_date: string | null
|
||||||
|
status: SalaryContributionStatus
|
||||||
|
created_by: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
user?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
ic_number?: string | null
|
||||||
|
} | null
|
||||||
|
investment_type?: {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SalaryContributionPayload = {
|
||||||
|
user_id: string
|
||||||
|
investment_type_id: string
|
||||||
|
monthly_amount: number
|
||||||
|
start_date: string
|
||||||
|
end_date?: string | null
|
||||||
|
status: SalaryContributionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SalaryDeductionBatchDetail = {
|
||||||
|
id: string
|
||||||
|
batch_id: string
|
||||||
|
user_id: string
|
||||||
|
salary_contribution_id: string | null
|
||||||
|
investment_type_id: string
|
||||||
|
investment_id: string | null
|
||||||
|
amount: string | number
|
||||||
|
status: SalaryDeductionDetailStatus
|
||||||
|
remarks: string | null
|
||||||
|
verified_by: string | null
|
||||||
|
verified_at: string | null
|
||||||
|
created_at: string
|
||||||
|
user?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
ic_number?: string | null
|
||||||
|
} | null
|
||||||
|
investment_type?: {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
investment?: {
|
||||||
|
id: string
|
||||||
|
reference_no: string
|
||||||
|
status: string
|
||||||
|
amount: string | number
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SalaryDeductionBatch = {
|
||||||
|
id: string
|
||||||
|
batch_no: string
|
||||||
|
month: number
|
||||||
|
year: number
|
||||||
|
total_records: number
|
||||||
|
total_amount: string | number
|
||||||
|
status: SalaryDeductionBatchStatus
|
||||||
|
uploaded_by: string | null
|
||||||
|
verified_by: string | null
|
||||||
|
uploaded_at: string | null
|
||||||
|
verified_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
uploaded_by_user?: { id: string; name: string; email: string } | null
|
||||||
|
verified_by_user?: { id: string; name: string; email: string } | null
|
||||||
|
details?: SalaryDeductionBatchDetail[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadSalaryDeductionBatchPayload = {
|
||||||
|
month: number
|
||||||
|
year: number
|
||||||
|
details: Array<{
|
||||||
|
user_id: string
|
||||||
|
investment_type_id: string
|
||||||
|
amount: number
|
||||||
|
salary_contribution_id?: string | null
|
||||||
|
remarks?: string | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerateSalaryDeductionBatchPayload = {
|
||||||
|
month: number
|
||||||
|
year: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListSalaryContributionsParams = {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
search?: string
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
status?: SalaryContributionStatus | ''
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListSalaryDeductionBatchesParams = {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
status?: SalaryDeductionBatchStatus | ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||||
|
import type { InvestmentTypeStatus } from '../types/investment-type.types'
|
||||||
|
|
||||||
|
export const INVESTMENT_TYPE_STATUS_OPTIONS: { label: string; value: InvestmentTypeStatus }[] = [
|
||||||
|
{ label: 'Aktif', value: 'ACTIVE' },
|
||||||
|
{ label: 'Tidak Aktif', value: 'INACTIVE' },
|
||||||
|
{ label: 'Ditutup', value: 'CLOSED' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function investmentTypeStatusLabel(status: InvestmentTypeStatus): string {
|
||||||
|
return INVESTMENT_TYPE_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function investmentTypeStatusVariant(
|
||||||
|
status: InvestmentTypeStatus,
|
||||||
|
): BadgeVariants['variant'] {
|
||||||
|
if (status === 'ACTIVE') return 'success'
|
||||||
|
if (status === 'INACTIVE') return 'pending'
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInvestmentAmount(value: string | number | null | undefined): string {
|
||||||
|
if (value == null || value === '') return '-'
|
||||||
|
|
||||||
|
const amount = typeof value === 'number' ? value : Number(value)
|
||||||
|
if (Number.isNaN(amount)) return '-'
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('ms-MY', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MYR',
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOptionalNumber(value: string | number | null | undefined): number | null {
|
||||||
|
if (value == null || value === '') return null
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isNaN(value) ? null : value
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = String(value).trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
const amount = Number(trimmed)
|
||||||
|
return Number.isNaN(amount) ? null : amount
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||||
|
import type { InvestmentSource, InvestmentStatus } from '../types/investment.types'
|
||||||
|
import { formatInvestmentAmount } from './investment-type.utils'
|
||||||
|
|
||||||
|
export { formatInvestmentAmount }
|
||||||
|
|
||||||
|
export const INVESTMENT_STATUS_OPTIONS: { label: string; value: InvestmentStatus }[] = [
|
||||||
|
{ label: 'Draf', value: 'DRAFT' },
|
||||||
|
{ label: 'Menunggu Bayaran', value: 'PENDING_PAYMENT' },
|
||||||
|
{ label: 'Menunggu Pengesahan', value: 'PENDING_VERIFICATION' },
|
||||||
|
{ label: 'Selesai', value: 'COMPLETED' },
|
||||||
|
{ label: 'Ditolak', value: 'REJECTED' },
|
||||||
|
{ label: 'Dibatalkan', value: 'CANCELLED' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const INVESTMENT_SOURCE_OPTIONS: { label: string; value: InvestmentSource }[] = [
|
||||||
|
{ label: 'Manual', value: 'MANUAL' },
|
||||||
|
{ label: 'Billplz', value: 'BILLPLZ' },
|
||||||
|
{ label: 'Potongan Gaji', value: 'SALARY_DEDUCTION' },
|
||||||
|
{ label: 'Pelarasan', value: 'ADJUSTMENT' },
|
||||||
|
{ label: 'Bayaran Balik', value: 'REFUND' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function investmentStatusLabel(status: InvestmentStatus | string): string {
|
||||||
|
return INVESTMENT_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function investmentSourceLabel(source: InvestmentSource | string): string {
|
||||||
|
return INVESTMENT_SOURCE_OPTIONS.find((option) => option.value === source)?.label ?? source
|
||||||
|
}
|
||||||
|
|
||||||
|
export function investmentStatusVariant(
|
||||||
|
status: InvestmentStatus | string,
|
||||||
|
): BadgeVariants['variant'] {
|
||||||
|
switch (status) {
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'success'
|
||||||
|
case 'PENDING_PAYMENT':
|
||||||
|
case 'PENDING_VERIFICATION':
|
||||||
|
return 'pending'
|
||||||
|
case 'REJECTED':
|
||||||
|
case 'CANCELLED':
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||||
|
import type {
|
||||||
|
SalaryContributionStatus,
|
||||||
|
SalaryDeductionBatchStatus,
|
||||||
|
SalaryDeductionDetailStatus,
|
||||||
|
} from '../types/salary.types'
|
||||||
|
import { formatInvestmentAmount } from './investment-type.utils'
|
||||||
|
|
||||||
|
export { formatInvestmentAmount }
|
||||||
|
|
||||||
|
export const SALARY_CONTRIBUTION_STATUS_OPTIONS: {
|
||||||
|
label: string
|
||||||
|
value: SalaryContributionStatus
|
||||||
|
}[] = [
|
||||||
|
{ label: 'Aktif', value: 'ACTIVE' },
|
||||||
|
{ label: 'Ditangguh', value: 'PAUSED' },
|
||||||
|
{ label: 'Tamat', value: 'ENDED' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SALARY_BATCH_STATUS_OPTIONS: {
|
||||||
|
label: string
|
||||||
|
value: SalaryDeductionBatchStatus
|
||||||
|
}[] = [
|
||||||
|
{ label: 'Dimuat Naik', value: 'UPLOADED' },
|
||||||
|
{ label: 'Dalam Semakan', value: 'UNDER_REVIEW' },
|
||||||
|
{ label: 'Disahkan', value: 'VERIFIED' },
|
||||||
|
{ label: 'Ditolak', value: 'REJECTED' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SALARY_DETAIL_STATUS_OPTIONS: {
|
||||||
|
label: string
|
||||||
|
value: SalaryDeductionDetailStatus
|
||||||
|
}[] = [
|
||||||
|
{ label: 'Pending', value: 'PENDING' },
|
||||||
|
{ label: 'Disahkan', value: 'VERIFIED' },
|
||||||
|
{ label: 'Ditolak', value: 'REJECTED' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MONTH_OPTIONS = [
|
||||||
|
{ label: 'Januari', value: 1 },
|
||||||
|
{ label: 'Februari', value: 2 },
|
||||||
|
{ label: 'Mac', value: 3 },
|
||||||
|
{ label: 'April', value: 4 },
|
||||||
|
{ label: 'Mei', value: 5 },
|
||||||
|
{ label: 'Jun', value: 6 },
|
||||||
|
{ label: 'Julai', value: 7 },
|
||||||
|
{ label: 'Ogos', value: 8 },
|
||||||
|
{ label: 'September', value: 9 },
|
||||||
|
{ label: 'Oktober', value: 10 },
|
||||||
|
{ label: 'November', value: 11 },
|
||||||
|
{ label: 'Disember', value: 12 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function salaryContributionStatusLabel(status: string): string {
|
||||||
|
return SALARY_CONTRIBUTION_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function salaryBatchStatusLabel(status: string): string {
|
||||||
|
return SALARY_BATCH_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function salaryDetailStatusLabel(status: string): string {
|
||||||
|
return SALARY_DETAIL_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function salaryContributionStatusVariant(status: string): BadgeVariants['variant'] {
|
||||||
|
if (status === 'ACTIVE') return 'success'
|
||||||
|
if (status === 'PAUSED') return 'pending'
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function salaryBatchStatusVariant(status: string): BadgeVariants['variant'] {
|
||||||
|
if (status === 'VERIFIED') return 'success'
|
||||||
|
if (status === 'UNDER_REVIEW') return 'pending'
|
||||||
|
if (status === 'REJECTED') return 'danger'
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function salaryDetailStatusVariant(status: string): BadgeVariants['variant'] {
|
||||||
|
if (status === 'VERIFIED') return 'success'
|
||||||
|
if (status === 'REJECTED') return 'danger'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthLabel(month: number): string {
|
||||||
|
return MONTH_OPTIONS.find((item) => item.value === month)?.label ?? String(month)
|
||||||
|
}
|
||||||
@@ -136,13 +136,10 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
if (!status) throw new Error('Status pengguna diperlukan.')
|
if (!status) throw new Error('Status pengguna diperlukan.')
|
||||||
requireSelectValue(genderValue.value[0], 'Jantina')
|
requireSelectValue(genderValue.value[0], 'Jantina')
|
||||||
requireSelectValue(marriageStatusValue.value[0], 'Status perkahwinan')
|
|
||||||
requireSelectValue(memberTypeValue.value[0], 'Jenis anggota')
|
requireSelectValue(memberTypeValue.value[0], 'Jenis anggota')
|
||||||
|
|
||||||
if (!form.member_number) throw new Error('Nombor anggota diperlukan.')
|
if (!form.member_number) throw new Error('Nombor anggota diperlukan.')
|
||||||
if (!form.join_date) throw new Error('Tarikh sertai diperlukan.')
|
if (!form.join_date) throw new Error('Tarikh sertai diperlukan.')
|
||||||
if (!form.birth_date) throw new Error('Tarikh lahir diperlukan.')
|
|
||||||
if (!form.birth_place.trim()) throw new Error('Tempat lahir diperlukan.')
|
|
||||||
|
|
||||||
await createUser({
|
await createUser({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@@ -190,13 +187,8 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
<div class="w-full space-y-6">
|
<div class="w-full space-y-6">
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<h2 class="mr-auto text-lg font-medium">Daftar Pengguna</h2>
|
<h2 class="mr-auto text-lg font-medium">Daftar Pengguna</h2>
|
||||||
<Button
|
<Button look="outline" variant="secondary" type="button" :disabled="saving"
|
||||||
look="outline"
|
@click="router.push({ name: 'list-users' })">
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
:disabled="saving"
|
|
||||||
@click="router.push({ name: 'list-users' })"
|
|
||||||
>
|
|
||||||
Kembali
|
Kembali
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,86 +207,43 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-name">Nama</FieldLabel>
|
<FieldLabel for="user-name" required>Nama</FieldLabel>
|
||||||
<Input
|
<Input id="user-name" v-model="form.name" class="w-full" type="text" placeholder="Nama penuh"
|
||||||
id="user-name"
|
:disabled="formDisabled" required @input="handleNameInput" />
|
||||||
v-model="form.name"
|
|
||||||
class="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="Nama penuh"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
required
|
|
||||||
@input="handleNameInput"
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-email">Emel</FieldLabel>
|
<FieldLabel for="user-email" required>Emel</FieldLabel>
|
||||||
<Input
|
<Input id="user-email" v-model="form.email" class="w-full" type="email" placeholder="emel@example.com"
|
||||||
id="user-email"
|
:disabled="formDisabled" required />
|
||||||
v-model="form.email"
|
|
||||||
class="w-full"
|
|
||||||
type="email"
|
|
||||||
placeholder="emel@example.com"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-ic">Nombor Kad Pengenalan</FieldLabel>
|
<FieldLabel for="user-ic" required>Nombor Kad Pengenalan</FieldLabel>
|
||||||
<Input
|
<Input id="user-ic" v-model="form.ic_number" class="w-full" type="text" inputmode="numeric" maxlength="15"
|
||||||
id="user-ic"
|
placeholder="Contoh: 900101011234" :disabled="formDisabled" required @input="handleIcNumberInput" />
|
||||||
v-model="form.ic_number"
|
|
||||||
class="w-full"
|
|
||||||
type="text"
|
|
||||||
inputmode="numeric"
|
|
||||||
maxlength="15"
|
|
||||||
placeholder="Contoh: 900101011234"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
required
|
|
||||||
@input="handleIcNumberInput"
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-phone">Nombor Telefon</FieldLabel>
|
<FieldLabel for="user-phone" required>Nombor Telefon</FieldLabel>
|
||||||
<Input
|
<Input id="user-phone" v-model="form.phone_number" class="w-full" type="text" placeholder="Nombor telefon"
|
||||||
id="user-phone"
|
:disabled="formDisabled" />
|
||||||
v-model="form.phone_number"
|
|
||||||
class="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="Nombor telefon"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-position">Jawatan</FieldLabel>
|
<FieldLabel for="user-position">Jawatan</FieldLabel>
|
||||||
<Input
|
<Input id="user-position" v-model="form.position" class="w-full" type="text" placeholder="Jawatan"
|
||||||
id="user-position"
|
:disabled="formDisabled" />
|
||||||
v-model="form.position"
|
|
||||||
class="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="Jawatan"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Status Pengguna</FieldLabel>
|
<FieldLabel required>Status Pengguna</FieldLabel>
|
||||||
<SelectRoot
|
<SelectRoot class="w-full" :collection="statusCollection" :default-value="statusInitial"
|
||||||
class="w-full"
|
:disabled="formDisabled" @value-change="setStatusValue">
|
||||||
:collection="statusCollection"
|
|
||||||
:default-value="statusInitial"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
@value-change="setStatusValue"
|
|
||||||
>
|
|
||||||
<SelectControl>
|
<SelectControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValueText placeholder="Pilih status" />
|
<SelectValueText placeholder="Pilih status" />
|
||||||
@@ -303,11 +252,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItemGroup>
|
<SelectItemGroup>
|
||||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||||
<SelectItem
|
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||||
v-for="item in statusCollection.items"
|
|
||||||
:key="item.label"
|
|
||||||
:item="item"
|
|
||||||
>
|
|
||||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectItemGroup>
|
</SelectItemGroup>
|
||||||
@@ -318,13 +263,9 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Jantina</FieldLabel>
|
<FieldLabel required>Jantina</FieldLabel>
|
||||||
<SelectRoot
|
<SelectRoot class="w-full" :collection="genderCollection" :disabled="formDisabled"
|
||||||
class="w-full"
|
@value-change="setGenderValue">
|
||||||
:collection="genderCollection"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
@value-change="setGenderValue"
|
|
||||||
>
|
|
||||||
<SelectControl>
|
<SelectControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValueText placeholder="Pilih jantina" />
|
<SelectValueText placeholder="Pilih jantina" />
|
||||||
@@ -333,11 +274,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItemGroup>
|
<SelectItemGroup>
|
||||||
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
|
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
|
||||||
<SelectItem
|
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
|
||||||
v-for="item in genderCollection.items"
|
|
||||||
:key="item.label"
|
|
||||||
:item="item"
|
|
||||||
>
|
|
||||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectItemGroup>
|
</SelectItemGroup>
|
||||||
@@ -347,12 +284,8 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||||
<SelectRoot
|
<SelectRoot class="w-full" :collection="marriageStatusCollection" :disabled="formDisabled"
|
||||||
class="w-full"
|
@value-change="setMarriageStatusValue">
|
||||||
:collection="marriageStatusCollection"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
@value-change="setMarriageStatusValue"
|
|
||||||
>
|
|
||||||
<SelectControl>
|
<SelectControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValueText placeholder="Pilih status perkahwinan" />
|
<SelectValueText placeholder="Pilih status perkahwinan" />
|
||||||
@@ -361,11 +294,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItemGroup>
|
<SelectItemGroup>
|
||||||
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
|
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
|
||||||
<SelectItem
|
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
|
||||||
v-for="item in marriageStatusCollection.items"
|
|
||||||
:key="item.label"
|
|
||||||
:item="item"
|
|
||||||
>
|
|
||||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectItemGroup>
|
</SelectItemGroup>
|
||||||
@@ -376,27 +305,15 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-member-number">Nombor Anggota</FieldLabel>
|
<FieldLabel for="user-member-number" required>Nombor Anggota</FieldLabel>
|
||||||
<Input
|
<Input id="user-member-number" v-model="form.member_number" class="w-full" type="number" min="0"
|
||||||
id="user-member-number"
|
placeholder="Nombor anggota" :disabled="formDisabled" required />
|
||||||
v-model="form.member_number"
|
|
||||||
class="w-full"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
placeholder="Nombor anggota"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Jenis Anggota</FieldLabel>
|
<FieldLabel required>Jenis Anggota</FieldLabel>
|
||||||
<SelectRoot
|
<SelectRoot class="w-full" :collection="memberTypeCollection" :disabled="formDisabled"
|
||||||
class="w-full"
|
@value-change="setMemberTypeValue">
|
||||||
:collection="memberTypeCollection"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
@value-change="setMemberTypeValue"
|
|
||||||
>
|
|
||||||
<SelectControl>
|
<SelectControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValueText placeholder="Pilih jenis anggota" />
|
<SelectValueText placeholder="Pilih jenis anggota" />
|
||||||
@@ -405,11 +322,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItemGroup>
|
<SelectItemGroup>
|
||||||
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
|
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
|
||||||
<SelectItem
|
<SelectItem v-for="item in memberTypeCollection.items" :key="item.label" :item="item">
|
||||||
v-for="item in memberTypeCollection.items"
|
|
||||||
:key="item.label"
|
|
||||||
:item="item"
|
|
||||||
>
|
|
||||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectItemGroup>
|
</SelectItemGroup>
|
||||||
@@ -420,52 +333,26 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-join-date">Tarikh Sertai</FieldLabel>
|
<FieldLabel for="user-join-date" required>Tarikh Sertai</FieldLabel>
|
||||||
<Input
|
<Input id="user-join-date" v-model="form.join_date" class="w-full" type="date" :disabled="formDisabled"
|
||||||
id="user-join-date"
|
required />
|
||||||
v-model="form.join_date"
|
|
||||||
class="w-full"
|
|
||||||
type="date"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field v-if="isInactiveStatus">
|
<Field v-if="isInactiveStatus">
|
||||||
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
|
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
|
||||||
<Input
|
<Input id="user-leave-date" v-model="form.leave_date" class="w-full" type="date" :disabled="formDisabled" />
|
||||||
id="user-leave-date"
|
|
||||||
v-model="form.leave_date"
|
|
||||||
class="w-full"
|
|
||||||
type="date"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
|
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
|
||||||
<Input
|
<Input id="user-birth-date" v-model="form.birth_date" class="w-full" type="date" :disabled="formDisabled" />
|
||||||
id="user-birth-date"
|
|
||||||
v-model="form.birth_date"
|
|
||||||
class="w-full"
|
|
||||||
type="date"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
|
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
|
||||||
<Input
|
<Input id="user-birth-place" v-model="form.birth_place" class="w-full" type="text" placeholder="Tempat lahir"
|
||||||
id="user-birth-place"
|
:disabled="formDisabled" />
|
||||||
v-model="form.birth_place"
|
|
||||||
class="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="Tempat lahir"
|
|
||||||
:disabled="formDisabled"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-2 pt-2">
|
<div class="flex items-center justify-end gap-2 pt-2">
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ async function confirmRestore() {
|
|||||||
|
|
||||||
const headers: TableHeader[] = [
|
const headers: TableHeader[] = [
|
||||||
{ title: 'Bil.', key: '#', sortable: false },
|
{ title: 'Bil.', key: '#', sortable: false },
|
||||||
|
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||||
{ title: 'Name', key: 'name', sortable: true },
|
{ title: 'Name', key: 'name', sortable: true },
|
||||||
{
|
{
|
||||||
title: 'Unit',
|
title: 'Unit',
|
||||||
@@ -345,7 +346,6 @@ const headers: TableHeader[] = [
|
|||||||
exportValue: (item) => formatUserRoles(item.roles),
|
exportValue: (item) => formatUserRoles(item.roles),
|
||||||
},
|
},
|
||||||
{ title: 'Status', key: 'status', sortable: true },
|
{ title: 'Status', key: 'status', sortable: true },
|
||||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// deleted users table headers
|
// deleted users table headers
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { dashboardLayoutRoutes } from '@/modules/dashboard'
|
|||||||
import { externalSystemLayoutRoutes } from '@/modules/external-system'
|
import { externalSystemLayoutRoutes } from '@/modules/external-system'
|
||||||
import { activityLogLayoutRoutes } from '@/modules/activity-log'
|
import { activityLogLayoutRoutes } from '@/modules/activity-log'
|
||||||
import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback'
|
import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback'
|
||||||
|
import { investmentLayoutRoutes } from '@/modules/investment'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -32,6 +33,7 @@ const router = createRouter({
|
|||||||
...externalSystemLayoutRoutes,
|
...externalSystemLayoutRoutes,
|
||||||
...membershipApplicationLayoutRoutes,
|
...membershipApplicationLayoutRoutes,
|
||||||
...feedbackLayoutRoutes,
|
...feedbackLayoutRoutes,
|
||||||
|
...investmentLayoutRoutes,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...authPublicRoutes,
|
...authPublicRoutes,
|
||||||
|
|||||||
Reference in New Issue
Block a user