4 Commits

88 changed files with 8619 additions and 1774 deletions
-7
View File
@@ -1,7 +0,0 @@
node_modules
dist
.git
.gitea
.DS_Store
.idea
.vscode
-43
View File
@@ -1,43 +0,0 @@
name: Build Docker Image
on:
push:
tags:
- "v*"
jobs:
build-frontend:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.koppkb.com \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build & push frontend image
run: |
set -e
IMAGE="git.koppkb.com/kopkb/my-kopkb-fe"
TAGS="-t ${IMAGE}:${{ gitea.sha }}"
if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then
TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}"
fi
echo "Building and pushing frontend: ${TAGS}"
docker buildx build \
--platform linux/amd64 \
--cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f docker/Dockerfile \
--build-arg VITE_API_BASE_URL=https://api.koppkb.com \
--build-arg VITE_APP_URL=https://mykopkb.koppkb.com \
${TAGS} \
--push .
-113
View File
@@ -1,113 +0,0 @@
name: Deploy to Production
# Manual production deploy — pulls pre-built images from the registry
on:
workflow_dispatch:
inputs:
image_tag:
description: "Docker image tag to deploy (commit SHA or version tag, e.g. v1.0.0)"
required: true
default: "latest"
type: string
env:
FRONTEND_IMAGE: git.koppkb.com/kopkb/my-kopkb-fe
jobs:
deploy:
runs-on: mykopkb-production
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ inputs.image_tag }}
- name: Verify image exists in registry
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.koppkb.com \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
IMAGE="${FRONTEND_IMAGE}:${{ inputs.image_tag }}"
echo "Checking if image exists: ${IMAGE}"
docker manifest inspect "${IMAGE}" > /dev/null
echo "✓ ${IMAGE} found"
- name: Deploy over SSH
env:
PROD_HOST: ${{ secrets.PROD_HOST }}
PROD_USER: ${{ secrets.PROD_USER }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_DEPLOY_DIR: ${{ secrets.PROD_DEPLOY_DIR }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
if ! command -v ssh >/dev/null 2>&1; then
apt-get update && apt-get install -y openssh-client
fi
mkdir -p ~/.ssh
umask 077
# Gitea often stores a multiline key as one line. Prefer a base64
# secret (one line). A raw key that still contains BEGIN is accepted too.
if printf '%s' "$PROD_SSH_KEY" | grep -q -- '-----BEGIN'; then
printf '%b\n' "$PROD_SSH_KEY" | tr -d '\r' > ~/.ssh/id_ed25519
else
printf '%s' "$PROD_SSH_KEY" | tr -d '[:space:]' | base64 -d > ~/.ssh/id_ed25519
fi
chmod 600 ~/.ssh/id_ed25519
if ! ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null; then
echo "ERROR: PROD_SSH_KEY is not a readable private key."
echo "On the runner, run: base64 -w0 ~/.ssh/mykopkb_prod"
echo "Put that single line in the PROD_SSH_KEY secret."
exit 1
fi
ssh-keyscan -H "$PROD_HOST" >> ~/.ssh/known_hosts
SSH=(ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes "${PROD_USER}@${PROD_HOST}")
IMAGE="${FRONTEND_IMAGE}:${IMAGE_TAG}"
"${SSH[@]}" "mkdir -p '${PROD_DEPLOY_DIR}'"
scp -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes \
docker/docker-compose.production.yml \
"${PROD_USER}@${PROD_HOST}:${PROD_DEPLOY_DIR}/docker-compose.frontend.yml"
"${SSH[@]}" "bash -s" << EOF
set -euo pipefail
echo "${REGISTRY_PASSWORD}" | docker login git.koppkb.com \
-u "${REGISTRY_USERNAME}" --password-stdin
echo "Checking if image exists: ${IMAGE}"
docker manifest inspect "${IMAGE}" > /dev/null
echo "✓ ${IMAGE} found"
echo "Deploying frontend IMAGE=${IMAGE}"
docker network inspect mykopkb-production-network >/dev/null 2>&1 \
|| docker network create mykopkb-production-network
cd "${PROD_DEPLOY_DIR}"
export IMAGE_TAG="${IMAGE_TAG}"
docker rm -f mykopkb-fe-production || true
docker compose -f docker-compose.frontend.yml -p mykopkb-fe pull
docker compose -f docker-compose.frontend.yml -p mykopkb-fe up -d
sleep 10
STATUS=\$(docker compose -f docker-compose.frontend.yml -p mykopkb-fe ps --status running --format '{{.Name}}' frontend || true)
if [ -z "\$STATUS" ]; then
docker compose -f docker-compose.frontend.yml -p mykopkb-fe logs --tail=50 frontend || true
exit 1
fi
FE_PORT=\$(grep -E '^FE_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)
FE_PORT=\${FE_PORT:-8081}
curl -sf "http://127.0.0.1:\${FE_PORT}/health"
echo "✓ Deployed frontend IMAGE_TAG=${IMAGE_TAG}"
EOF
+3 -3
View File
@@ -1,12 +1,12 @@
# Stage 1: Build Vue SPA
FROM node:22 AS builder
WORKDIR /app
COPY package*.json pnpm-lock.yaml .npmrc pnpm-workspace.yaml ./
COPY fe/package*.json fe/pnpm-lock.yaml fe/.npmrc ./
# Skip prepare (cypress install); rebuild esbuild only — required for Vite
RUN npm install -g pnpm && \
pnpm install --frozen-lockfile --ignore-scripts && \
pnpm rebuild esbuild
COPY . ./
COPY fe/ ./
ARG VITE_APP_NAME="MyKOPKB"
ARG VITE_APP_VERSION="1.0"
ARG VITE_API_BASE_URL=https://api.koppkb.com
@@ -22,6 +22,6 @@ RUN pnpm exec vite build --mode=production
# Stage 2: Serve static files
FROM nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY fe/docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
-25
View File
@@ -1,25 +0,0 @@
services:
frontend:
image: git.koppkb.com/kopkb/my-kopkb-fe:${IMAGE_TAG:-latest}
container_name: mykopkb-fe-production
restart: unless-stopped
ports:
- "${FE_PORT:-8081}:80"
networks:
- mykopkb-production-network
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
mykopkb-production-network:
name: mykopkb-production-network
external: true
-5
View File
@@ -1,5 +0,0 @@
packages:
- .
allowBuilds:
esbuild: true
-99
View File
@@ -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>
-85
View File
@@ -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>
+80
View File
@@ -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>
-43
View File
@@ -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>
-43
View File
@@ -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>
-92
View File
@@ -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>
-72
View File
@@ -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>
-82
View File
@@ -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>
-42
View File
@@ -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>
+61
View File
@@ -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>
+6 -8
View File
@@ -1,8 +1,6 @@
export { default as Line1 } from './Line1.vue' //report-line-chart
export { default as Line2 } from './Line2.vue' //simple-line-chart-1
export { default as Line3 } from './Line3.vue' //simple-line-chart-3, simple-line-chart-4
export { default as Pie1 } from './Pie1.vue' //report-pie-chart
export { default as Donut1 } from './Donut1.vue' //report-donut-chart
export { default as Donut2 } from './Donut2.vue' //report-donut-chart-1
export { default as Bar1 } from './Bar1.vue' //report-bar-chart
export { default as Bar2 } from './Bar2.vue' //report-bar-chart-1
export { default as DonutChart } from './DonutChart.vue'
export { default as PieChart } from './PieChart.vue'
export { default as LineChart } from './LineChart.vue'
export { default as BarChart } from './BarChart.vue'
export type { LineChartDataset, BarChartDataset } from './types'
+17
View File
@@ -0,0 +1,17 @@
export type LineChartDataset = {
label?: string
data: number[]
/** CSS custom property name, e.g. `--color-primary` */
color?: string
dashed?: boolean
tension?: number
borderWidth?: number
}
export type BarChartDataset = {
label?: string
data: number[]
/** CSS custom property name, e.g. `--color-primary` */
color?: string
barThickness?: number
}
@@ -63,7 +63,6 @@ import {
} from '@/components/ui/pagination'
import { Map } from '@/components/ui/map'
import { Lucide } from '@/components/ui/lucide'
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
const pc = ref(false)
const electronic = ref(false)
@@ -63,7 +63,6 @@ import {
} from '@/components/ui/pagination'
import { Map } from '@/components/ui/map'
import { Lucide } from '@/components/ui/lucide'
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
const pc = ref(false)
const electronic = ref(false)
@@ -63,7 +63,6 @@ import {
} from '@/components/ui/pagination'
import { Map } from '@/components/ui/map'
import { Lucide } from '@/components/ui/lucide'
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
const pc = ref(false)
const electronic = ref(false)
-1
View File
@@ -63,7 +63,6 @@ import {
} from '@/components/ui/pagination'
import { Map } from '@/components/ui/map'
import { Lucide } from '@/components/ui/lucide'
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
const pc = ref(false)
const electronic = ref(false)
@@ -63,7 +63,6 @@ import {
} from '@/components/ui/pagination'
import { Map } from '@/components/ui/map'
import { Lucide } from '@/components/ui/lucide'
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
const pc = ref(false)
const electronic = ref(false)
+7 -1
View File
@@ -3,13 +3,19 @@ import { cn } from "@mykopkb/core/utils/cn";
import { Label } from "@/components/ui/label";
import { fieldLabel } from "@mykopkb/core/styles/field.styles";
const { class: className, ...props } = defineProps<{
const {
class: className,
required = false,
...props
} = defineProps<{
class?: string;
required?: boolean;
}>();
</script>
<template>
<Label data-part="field-label" :class="cn(fieldLabel, className)" v-bind="{ ...props }">
<slot />
<span v-if="required" class="text-danger" aria-hidden="true">*</span>
</Label>
</template>
+2 -1
View File
@@ -9,7 +9,8 @@ export const selectIndicator =
"ms-auto transition data-[state=open]:rotate-180";
export const selectClearTrigger =
"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 =
"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";
@@ -63,7 +63,6 @@ import {
} from '@/components/ui/pagination'
import { Map } from '@/components/ui/map'
import { Lucide } from '@/components/ui/lucide'
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
const pc = ref(false)
const electronic = ref(false)
@@ -62,7 +62,6 @@ import {
} from '@/components/ui/pagination'
import { Map } from '@/components/ui/map'
import { Lucide } from '@/components/ui/lucide'
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
const pc = ref(false)
const electronic = ref(false)
+3
View File
@@ -7,6 +7,7 @@ import { dashboardMenu } from '@/modules/dashboard/menu'
import { externalSystemMenu } from '@/modules/external-system/menu'
import { activityLogMenu } from '@/modules/activity-log/menu'
import { feedbackMenu } from '@/modules/feedback'
import { investmentMenu } from '@/modules/investment'
export type { Menu }
@@ -22,6 +23,8 @@ const mainMenu: (string | Menu)[] = [
'Pentadbiran',
...membershipApplicationMenu,
...userMenu,
'Akaun',
...investmentMenu,
]
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">&gt;= 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">&gt;= 50 Years old</span>
<span class="ms-auto">10%</span>
</div>
</div>
</Box>
</div>
<!-- END: Sales Report -->
<!-- BEGIN: Official Store -->
<div class="col-span-12 mt-6 xl:col-span-8">
<OfficialStores />
</div>
<!-- END: Official Store -->
<!-- BEGIN: Weekly Best Sellers -->
<div class="col-span-12 mt-6 xl:col-span-4">
<WeeklyBestSellers />
</div>
<!-- END: Weekly Best Sellers -->
<!-- BEGIN: General Report -->
<div class="col-span-12 mt-8 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
<Box class="p-5">
<div class="flex items-center">
<div class="w-2/4 flex-none">
<div class="truncate text-lg font-medium">Target Sales</div>
<div class="mt-1 opacity-70">300 Sales</div>
</div>
<div class="relative ms-auto flex-none">
<Donut2 class="w-[90px] h-[90px]" />
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
20%
</div>
</div>
</div>
</Box>
</div>
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
<Box class="p-5">
<div class="flex items-center">
<div class="me-3 truncate text-lg font-medium">Social Media</div>
<Badge variant="secondary" look="outline" class="ms-auto whitespace-nowrap">137 Sales</Badge>
</div>
<div class="mt-1">
<Line2 class="-ms-1 h-[58px]" />
</div>
</Box>
</div>
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
<Box class="p-5">
<div class="flex items-center">
<div class="w-2/4 flex-none">
<div class="truncate text-lg font-medium">New Products</div>
<div class="mt-1 opacity-70">1450 Products</div>
</div>
<div class="relative ms-auto flex-none">
<Donut2 class="w-[90px] h-[90px]" />
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
45%
</div>
</div>
</div>
</Box>
</div>
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
<Box class="p-5">
<div class="flex items-center">
<div class="me-3 truncate text-lg font-medium">Posted Ads</div>
<Badge variant="secondary" look="outline" class="ms-auto whitespace-nowrap">180 Campaign</Badge>
</div>
<div class="mt-1">
<Line2 class="-ms-1 h-[58px]" />
</div>
</Box>
</div>
</div>
<!-- END: General Report -->
<!-- BEGIN: Weekly Top Products -->
<div class="col-span-12 mt-6">
<WeeklyTopProducts />
</div>
<!-- END: Weekly Top Products -->
</div>
</div>
<div class="col-span-12 2xl:col-span-3">
<div class="-mb-10 h-full pb-10 2xl:border-l border-foreground/15">
<div class="grid grid-cols-12 gap-x-6 gap-y-6 2xl:gap-x-0 2xl:pl-6">
<!-- BEGIN: Transactions -->
<div class="col-span-12 md:col-span-6 xl:col-span-4 2xl:col-span-12">
<Transactions />
</div>
<!-- END: Transactions -->
<!-- BEGIN: Recent Activities -->
<div class="col-span-12 mt-3 md:col-span-6 xl:col-span-4 2xl:col-span-12">
<RecentActivities />
</div>
<!-- END: Recent Activities -->
<!-- BEGIN: Daily Notes -->
<div
class="col-span-12 mt-3 md:col-span-6 xl:col-span-12 xl:col-start-1 xl:row-start-1 2xl:col-start-auto 2xl:row-start-auto">
<DailyNotes />
</div>
<!-- END: Daily Notes -->
<!-- BEGIN: Schedules -->
<div
class="col-span-12 mt-3 md:col-span-6 xl:col-span-4 xl:col-start-1 xl:row-start-2 2xl:col-span-12 2xl:col-start-auto 2xl:row-start-auto">
<Schedules />
</div>
<!-- END: Schedules -->
</div>
</div>
</div>
</div>
<!-- BEGIN: Onboarding Dialog -->
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
<DialogContent class="sm:max-w-xl">
<DialogCloseTrigger />
<div class="overflow-hidden">
<div class="-my-5 -mx-10">
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
<CarouselItemGroup>
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
<div
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="phoneIllustration"
alt="MyKOPKB" />
</div>
<div class="px-8">
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
Sistem MyKOPKB adalah sistem yang membantu pengguna untuk menguruskan permohonan keahlian KOPKB.
</div>
</div>
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
<a class="text-danger flex items-center gap-3 font-medium cursor-pointer"
@click.prevent="onboardingDialog = false">
Skip Intro
</a>
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
Next
<Lucide icon="MoveRight" />
</CarouselNextTrigger>
</div>
</div>
</CarouselItem>
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
<div
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="womanIllustration"
alt="MyKOPKB" />
</div>
<div class="w-full">
<div class="text-center text-xl font-medium">Example Request Information</div>
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
Your premium admin dashboard template.
</div>
<div class="mt-8">
<FieldGroup class="px-5">
<div class="grid grid-cols-2 gap-5">
<Field>
<FieldLabel>Full Name</FieldLabel>
<Input type="text" placeholder="John Doe" />
</Field>
<Field>
<FieldLabel>Event</FieldLabel>
<NativeSelect>
<NativeSelectOption>Corporate Event</NativeSelectOption>
<NativeSelectOption>Wedding</NativeSelectOption>
<NativeSelectOption>Birthday</NativeSelectOption>
<NativeSelectOption>Other</NativeSelectOption>
</NativeSelect>
</Field>
</div>
</FieldGroup>
</div>
</div>
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
<Lucide icon="MoveLeft" /> Previous
</CarouselPrevTrigger>
<a class="text-primary flex items-center gap-3 font-medium cursor-pointer me-0 px-5"
@click.prevent="onboardingDialog = false">
Selesai
<Lucide icon="MoveRight" />
</a>
</div>
</div>
</CarouselItem>
</CarouselItemGroup>
</CarouselRoot>
</div>
</div>
</DialogContent>
</DialogRoot>
<!-- END: Onboarding Dialog -->
</div>
</template>
@@ -1,111 +1,13 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import phoneIllustration from '@/assets/images/phone-illustration.svg'
import womanIllustration from '@/assets/images/woman-illustration.svg'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import {
CarouselRoot,
CarouselPrevTrigger,
CarouselNextTrigger,
CarouselItemGroup,
CarouselItem,
} from '@/components/ui/carousel'
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
import { Lucide, type Icon } from '@/components/ui/lucide'
import { usePermissions } from '@/composables/usePermissions'
import { completeOnboarding } from '@/modules/profile/services/profile.service'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
const { hasPermission } = usePermissions()
const onboardingDialog = ref(false)
import { Lucide } from '@/components/ui/lucide'
import AvailableModules from '../components/AvailableModules.vue'
import DashboardGreeting from '../components/DashboardGreeting.vue'
import MemberInvestmentSummary from '../components/MemberInvestmentSummary.vue'
import OnboardingDialog from '../components/OnboardingDialog.vue'
const appName = import.meta.env.VITE_APP_NAME
const appVersion = import.meta.env.VITE_APP_VERSION
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Selamat pagi'
if (hour < 18) return 'Selamat petang'
return 'Selamat malam'
})
const availableModules = computed(() => [
{
title: 'Profil',
description: 'Kemas kini maklumat peribadi, pekerjaan, bank dan waris.',
route: 'profile-overview-2',
icon: 'User' as Icon,
visible: true,
},
{
title: 'Permohonan Keahlian',
description: 'Urus dan semak permohonan keahlian KOPKB.',
route: 'list-membership-applications',
icon: 'ClipboardList' as Icon,
visible: hasPermission('lihat permohonan keahlian'),
},
{
title: 'Aktiviti',
description: 'Lihat dan urus aktiviti serta laporan berkaitan.',
route: 'list-activities',
icon: 'CalendarDays' as Icon,
visible: hasPermission('lihat aktiviti'),
},
{
title: 'Senarai Daftar Anggota',
description: 'Pantau dan urus akaun pengguna sistem.',
route: 'list-users',
icon: 'Users' as Icon,
visible: hasPermission('lihat pengguna'),
},
].filter((module) => module.visible))
const upcomingFeatures = [
'Ringkasan statistik keahlian',
'Carta dan laporan bulanan',
'Notifikasi dan aktiviti terkini',
'Papan pemuka pentadbir',
]
onMounted(() => {
onboardingDialog.value = !authStore.user?.onboarding_completed_at
})
async function markOnboardingComplete() {
if (authStore.user?.onboarding_completed_at) {
return
}
try {
const res = await completeOnboarding()
if (res.success) {
authStore.setUserProfile(res.data)
}
} catch {
// Will retry on next visit if the request failed
}
}
async function finishOnboarding() {
onboardingDialog.value = false
await markOnboardingComplete()
await router.push({ name: 'profile-overview-2' })
}
function handleOnboardingOpenChange(details: { open: boolean }) {
onboardingDialog.value = details.open
if (!details.open) {
void markOnboardingComplete()
}
}
</script>
<template>
@@ -120,139 +22,11 @@ function handleOnboardingOpenChange(details: { open: boolean }) {
</AlertRoot>
<div class="grid grid-cols-12 gap-6">
<div class="col-span-12">
<Box class="relative overflow-hidden p-6 sm:p-8">
<div class="pointer-events-none absolute -right-10 -top-10 size-40 rounded-full bg-primary/10 blur-2xl" />
<div class="relative">
<Badge variant="pending" class="mb-4">Beta</Badge>
<h2 class="text-2xl font-semibold">
{{ greeting }}{{ authStore.userName ? `, ${authStore.userName}` : '' }}
</h2>
<p class="mt-2 max-w-2xl text-base leading-relaxed opacity-70">
Selamat datang ke {{ appName }}. Papan pemuka utama sedang dibangunkan — buat masa ini,
anda boleh mula dengan mengemas kini profil atau mengurus permohonan keahlian.
</p>
</div>
</Box>
</div>
<div class="col-span-12 lg:col-span-7">
<div class="mb-4 flex h-10 items-center">
<h3 class="text-lg font-medium">Modul Tersedia</h3>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<Box v-for="module in availableModules" :key="module.route"
class="flex h-full flex-col p-5 transition-colors hover:bg-foreground/2">
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Lucide :icon="module.icon" class="size-5" />
</div>
<div class="text-base font-medium">{{ module.title }}</div>
<p class="mt-2 flex-1 text-sm leading-relaxed opacity-70">
{{ module.description }}
</p>
<Button as-child variant="ghost" class="mt-5 w-full border border-foreground/15">
<RouterLink :to="{ name: module.route }">
Pergi ke {{ module.title }}
<Lucide icon="ArrowRight" class="size-4" />
</RouterLink>
</Button>
</Box>
</div>
</div>
<div class="col-span-12 lg:col-span-5">
<div class="mb-4 flex h-10 items-center">
<h3 class="text-lg font-medium">Akan Datang</h3>
</div>
<Box class="p-5">
<p class="text-sm leading-relaxed opacity-70">
Ciri-ciri berikut sedang dirancang untuk papan pemuka ini:
</p>
<ul class="mt-4 space-y-3">
<li v-for="feature in upcomingFeatures" :key="feature" class="flex items-start gap-3 text-sm">
<Lucide icon="CircleDashed" class="mt-0.5 size-4 shrink-0 text-primary/70" />
<span>{{ feature }}</span>
</li>
</ul>
</Box>
<Box class="mt-4 p-5">
<div class="flex items-start gap-3">
<Lucide icon="Lightbulb" class="mt-0.5 size-4 shrink-0 text-warning" />
<div>
<div class="text-sm font-medium">Petua</div>
<p class="mt-1 text-sm leading-relaxed opacity-70">
Pastikan profil anda lengkap dan terkini. Maklumat yang
tepat membantu proses kelulusan berjalan lebih lancar.
</p>
</div>
</div>
</Box>
</div>
<DashboardGreeting />
<MemberInvestmentSummary />
<AvailableModules />
</div>
<!-- BEGIN: Onboarding Dialog -->
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
<DialogContent class="sm:max-w-xl">
<DialogCloseTrigger />
<div class="overflow-hidden">
<div class="-my-5 -mx-10">
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
<CarouselItemGroup>
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
<div
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="phoneIllustration"
alt="MyKOPKB" />
</div>
<div class="px-8">
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
Sistem MyKOPKB adalah sistem integrasi bagi ahli Koperasi Permodalan Kelantan Berhad (KOPKB).
</div>
</div>
<div class="absolute inset-x-0 bottom-0 flex justify-end px-5">
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
Seterusnya
<Lucide icon="MoveRight" />
</CarouselNextTrigger>
</div>
</div>
</CarouselItem>
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
<div
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="womanIllustration"
alt="MyKOPKB" />
</div>
<div class="px-8">
<div class="text-center text-xl font-medium">Mulakan dengan Profil Anda</div>
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
Lengkapkan maklumat peribadi, pekerjaan dan butiran bank.
</div>
</div>
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
<Lucide icon="MoveLeft" /> Kembali
</CarouselPrevTrigger>
<a class="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 font-semibold text-white shadow-md transition-all duration-200 hover:bg-primary/90 hover:shadow-lg active:scale-95 cursor-pointer"
@click.prevent="finishOnboarding">
<span>Selesai</span>
<Lucide icon="Check" class="h-5 w-5" />
</a>
</div>
</div>
</CarouselItem>
</CarouselItemGroup>
</CarouselRoot>
</div>
</div>
</DialogContent>
</DialogRoot>
<!-- END: Onboarding Dialog -->
<OnboardingDialog />
</div>
</template>
@@ -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,
}
}
+2
View File
@@ -0,0 +1,2 @@
export { investmentLayoutRoutes } from './routes'
export { investmentMenu } from './menu'
+46
View File
@@ -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>
+204
View File
@@ -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)
}
+41 -154
View File
@@ -136,13 +136,10 @@ async function handleSubmit() {
if (!status) throw new Error('Status pengguna diperlukan.')
requireSelectValue(genderValue.value[0], 'Jantina')
requireSelectValue(marriageStatusValue.value[0], 'Status perkahwinan')
requireSelectValue(memberTypeValue.value[0], 'Jenis anggota')
if (!form.member_number) throw new Error('Nombor anggota 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({
name: form.name.trim(),
@@ -190,13 +187,8 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<div class="w-full space-y-6">
<div class="flex flex-wrap items-center gap-3">
<h2 class="mr-auto text-lg font-medium">Daftar Pengguna</h2>
<Button
look="outline"
variant="secondary"
type="button"
:disabled="saving"
@click="router.push({ name: 'list-users' })"
>
<Button look="outline" variant="secondary" type="button" :disabled="saving"
@click="router.push({ name: 'list-users' })">
Kembali
</Button>
</div>
@@ -215,86 +207,43 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-name">Nama</FieldLabel>
<Input
id="user-name"
v-model="form.name"
class="w-full"
type="text"
placeholder="Nama penuh"
:disabled="formDisabled"
required
@input="handleNameInput"
/>
<FieldLabel for="user-name" required>Nama</FieldLabel>
<Input id="user-name" v-model="form.name" class="w-full" type="text" placeholder="Nama penuh"
:disabled="formDisabled" required @input="handleNameInput" />
</Field>
<Field>
<FieldLabel for="user-email">Emel</FieldLabel>
<Input
id="user-email"
v-model="form.email"
class="w-full"
type="email"
placeholder="emel@example.com"
:disabled="formDisabled"
required
/>
<FieldLabel for="user-email" required>Emel</FieldLabel>
<Input id="user-email" v-model="form.email" class="w-full" type="email" placeholder="emel@example.com"
:disabled="formDisabled" required />
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-ic">Nombor Kad Pengenalan</FieldLabel>
<Input
id="user-ic"
v-model="form.ic_number"
class="w-full"
type="text"
inputmode="numeric"
maxlength="15"
placeholder="Contoh: 900101011234"
:disabled="formDisabled"
required
@input="handleIcNumberInput"
/>
<FieldLabel for="user-ic" required>Nombor Kad Pengenalan</FieldLabel>
<Input id="user-ic" v-model="form.ic_number" class="w-full" type="text" inputmode="numeric" maxlength="15"
placeholder="Contoh: 900101011234" :disabled="formDisabled" required @input="handleIcNumberInput" />
</Field>
<Field>
<FieldLabel for="user-phone">Nombor Telefon</FieldLabel>
<Input
id="user-phone"
v-model="form.phone_number"
class="w-full"
type="text"
placeholder="Nombor telefon"
:disabled="formDisabled"
/>
<FieldLabel for="user-phone" required>Nombor Telefon</FieldLabel>
<Input id="user-phone" v-model="form.phone_number" class="w-full" type="text" placeholder="Nombor telefon"
:disabled="formDisabled" />
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-position">Jawatan</FieldLabel>
<Input
id="user-position"
v-model="form.position"
class="w-full"
type="text"
placeholder="Jawatan"
:disabled="formDisabled"
required
/>
<Input id="user-position" v-model="form.position" class="w-full" type="text" placeholder="Jawatan"
:disabled="formDisabled" />
</Field>
<Field>
<FieldLabel>Status Pengguna</FieldLabel>
<SelectRoot
class="w-full"
:collection="statusCollection"
:default-value="statusInitial"
:disabled="formDisabled"
@value-change="setStatusValue"
>
<FieldLabel required>Status Pengguna</FieldLabel>
<SelectRoot class="w-full" :collection="statusCollection" :default-value="statusInitial"
:disabled="formDisabled" @value-change="setStatusValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih status" />
@@ -303,11 +252,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
<SelectItem
v-for="item in statusCollection.items"
:key="item.label"
:item="item"
>
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
@@ -318,13 +263,9 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel>Jantina</FieldLabel>
<SelectRoot
class="w-full"
:collection="genderCollection"
:disabled="formDisabled"
@value-change="setGenderValue"
>
<FieldLabel required>Jantina</FieldLabel>
<SelectRoot class="w-full" :collection="genderCollection" :disabled="formDisabled"
@value-change="setGenderValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih jantina" />
@@ -333,11 +274,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
<SelectItem
v-for="item in genderCollection.items"
:key="item.label"
:item="item"
>
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
@@ -347,12 +284,8 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<Field>
<FieldLabel>Status Perkahwinan</FieldLabel>
<SelectRoot
class="w-full"
:collection="marriageStatusCollection"
:disabled="formDisabled"
@value-change="setMarriageStatusValue"
>
<SelectRoot class="w-full" :collection="marriageStatusCollection" :disabled="formDisabled"
@value-change="setMarriageStatusValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih status perkahwinan" />
@@ -361,11 +294,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
<SelectItem
v-for="item in marriageStatusCollection.items"
:key="item.label"
:item="item"
>
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
@@ -376,27 +305,15 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-member-number">Nombor Anggota</FieldLabel>
<Input
id="user-member-number"
v-model="form.member_number"
class="w-full"
type="number"
min="0"
placeholder="Nombor anggota"
:disabled="formDisabled"
required
/>
<FieldLabel for="user-member-number" required>Nombor Anggota</FieldLabel>
<Input id="user-member-number" v-model="form.member_number" class="w-full" type="number" min="0"
placeholder="Nombor anggota" :disabled="formDisabled" required />
</Field>
<Field>
<FieldLabel>Jenis Anggota</FieldLabel>
<SelectRoot
class="w-full"
:collection="memberTypeCollection"
:disabled="formDisabled"
@value-change="setMemberTypeValue"
>
<FieldLabel required>Jenis Anggota</FieldLabel>
<SelectRoot class="w-full" :collection="memberTypeCollection" :disabled="formDisabled"
@value-change="setMemberTypeValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih jenis anggota" />
@@ -405,11 +322,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
<SelectItem
v-for="item in memberTypeCollection.items"
:key="item.label"
:item="item"
>
<SelectItem v-for="item in memberTypeCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
@@ -420,52 +333,26 @@ watch(selectedStatus, (newStatus, oldStatus) => {
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-join-date">Tarikh Sertai</FieldLabel>
<Input
id="user-join-date"
v-model="form.join_date"
class="w-full"
type="date"
:disabled="formDisabled"
required
/>
<FieldLabel for="user-join-date" required>Tarikh Sertai</FieldLabel>
<Input id="user-join-date" v-model="form.join_date" class="w-full" type="date" :disabled="formDisabled"
required />
</Field>
<Field v-if="isInactiveStatus">
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
<Input
id="user-leave-date"
v-model="form.leave_date"
class="w-full"
type="date"
:disabled="formDisabled"
/>
<Input id="user-leave-date" v-model="form.leave_date" class="w-full" type="date" :disabled="formDisabled" />
</Field>
<Field>
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
<Input
id="user-birth-date"
v-model="form.birth_date"
class="w-full"
type="date"
:disabled="formDisabled"
required
/>
<Input id="user-birth-date" v-model="form.birth_date" class="w-full" type="date" :disabled="formDisabled" />
</Field>
</div>
<Field>
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
<Input
id="user-birth-place"
v-model="form.birth_place"
class="w-full"
type="text"
placeholder="Tempat lahir"
:disabled="formDisabled"
required
/>
<Input id="user-birth-place" v-model="form.birth_place" class="w-full" type="text" placeholder="Tempat lahir"
:disabled="formDisabled" />
</Field>
<div class="flex items-center justify-end gap-2 pt-2">
-41
View File
@@ -24,7 +24,6 @@ import {
SelectItemText,
} from '@/components/ui/select'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import type { Heir } from '@/modules/profile/types/heir.types'
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import {
@@ -132,7 +131,6 @@ const employmentTypeInitial = ref<string[]>([])
const companyNameValue = ref<string[]>([])
const companyNameInitial = ref<string[]>([])
const heirs = ref<Heir[]>([])
const employments = ref<Employment[]>([])
const savingEmployment = ref(false)
const deletingEmploymentId = ref<string | null>(null)
@@ -278,7 +276,6 @@ function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
genderInitial.value = [...genderValue.value]
marriageStatusInitial.value = [...marriageStatusValue.value]
memberTypeInitial.value = [...memberTypeValue.value]
heirs.value = user.heirs ?? []
employments.value = user.employments ?? []
}
@@ -749,44 +746,6 @@ onMounted(() => {
</form>
</Box>
<Box>
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold text-slate-900">Penama</h3>
<p class="mt-1 text-sm text-slate-500">Paparan maklumat penama anggota (baca sahaja).</p>
</div>
<div v-if="loading" class="text-sm text-slate-500">Memuatkan penama...</div>
<div v-else-if="error" class="text-sm text-slate-500">Maklumat penama tidak dapat dimuatkan.</div>
<div v-else-if="heirs.length" class="space-y-3">
<div v-for="heir in heirs" :key="heir.id" class="rounded-lg border border-foreground/10 p-4">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ heir.name }}</span>
<Badge v-if="heir.is_primary" class="bg-green-500 text-white">Utama</Badge>
<Badge look="outline">{{ heir.relationship }}</Badge>
</div>
<dl class="mt-3 grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
<div>
<dt class="text-slate-500">No. Kad Pengenalan</dt>
<dd class="text-slate-700">{{ heir.ic_number || '-' }}</dd>
</div>
<div>
<dt class="text-slate-500">No. Telefon</dt>
<dd class="text-slate-700">{{ heir.phone_number || '-' }}</dd>
</div>
<div class="md:col-span-2">
<dt class="text-slate-500">Alamat</dt>
<dd class="whitespace-pre-line text-slate-700">{{ heir.address || '-' }}</dd>
</div>
</dl>
</div>
</div>
<div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
Tiada penama direkodkan.
</div>
</div>
</Box>
<Box>
<div class="space-y-6">
<div>
+1 -1
View File
@@ -351,7 +351,6 @@ const headers: TableHeader[] = [
// deleted users table headers
const deletedHeaders: TableHeader[] = [
{ title: 'Bil.', key: '#', sortable: false },
{ title: 'Tindakan', key: 'actions', sortable: false },
{ title: 'Name', key: 'name', sortable: true },
{
title: 'Unit',
@@ -369,6 +368,7 @@ const deletedHeaders: TableHeader[] = [
},
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Dipadam Pada', key: 'deleted_at', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false },
]
const {
+6 -20
View File
@@ -5,7 +5,6 @@ import { Box } from '@/components/ui/box'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { Address } from '@/modules/profile/types/address.types'
import type { Heir } from '@/modules/profile/types/heir.types'
import type { UserDetail } from '../types/user.types'
const props = defineProps<{
@@ -32,8 +31,6 @@ const statusLabel = computed(() => {
const roleNames = computed(() => props.user.roles?.map((role) => role.name).join(', ') || '-')
const heirs = computed<Heir[]>(() => props.user.heirs ?? [])
function formatDate(value: string | null | undefined): string {
if (!value) return '-'
return value.slice(0, 10)
@@ -160,30 +157,19 @@ function formatAddressLine(address: Address) {
<Box raised="single" class="p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-slate-900">Penama</h3>
<p class="mt-1 text-sm text-slate-500">Paparan maklumat penama anggota (baca sahaja).</p>
<p class="mt-1 text-sm text-slate-500">Senarai penama pengguna.</p>
</div>
<div v-if="heirs.length" class="space-y-3">
<div v-for="heir in heirs" :key="heir.id" class="rounded-lg border border-foreground/10 p-4">
<div v-if="user.heirs?.length" class="space-y-3">
<div v-for="heir in user.heirs" :key="heir.id" class="rounded-lg border border-foreground/10 p-4">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ heir.name }}</span>
<Badge v-if="heir.is_primary" class="bg-green-500 text-white">Utama</Badge>
<Badge look="outline">{{ heir.relationship }}</Badge>
</div>
<dl class="mt-3 grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
<div>
<dt class="text-slate-500">No. Kad Pengenalan</dt>
<dd class="text-slate-700">{{ heir.ic_number || '-' }}</dd>
</div>
<div>
<dt class="text-slate-500">No. Telefon</dt>
<dd class="text-slate-700">{{ heir.phone_number || '-' }}</dd>
</div>
<div class="md:col-span-2">
<dt class="text-slate-500">Alamat</dt>
<dd class="whitespace-pre-line text-slate-700">{{ heir.address || '-' }}</dd>
</div>
</dl>
<p class="mt-1 text-sm text-slate-500">{{ heir.ic_number }}</p>
<p class="mt-1 text-sm text-slate-500">{{ heir.phone_number }}</p>
<p class="mt-1 text-sm text-slate-700">{{ heir.address }}</p>
</div>
</div>
+2
View File
@@ -14,6 +14,7 @@ import { dashboardLayoutRoutes } from '@/modules/dashboard'
import { externalSystemLayoutRoutes } from '@/modules/external-system'
import { activityLogLayoutRoutes } from '@/modules/activity-log'
import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback'
import { investmentLayoutRoutes } from '@/modules/investment'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -32,6 +33,7 @@ const router = createRouter({
...externalSystemLayoutRoutes,
...membershipApplicationLayoutRoutes,
...feedbackLayoutRoutes,
...investmentLayoutRoutes,
],
},
...authPublicRoutes,