Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82665e576a | |||
| 1b7df24ee9 | |||
| 8f1dddb80a | |||
| 007cf1f804 |
@@ -1,7 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitea
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
@@ -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 .
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
packages:
|
||||
- .
|
||||
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -1,99 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
import type { Chart as ChartInstance } from 'chart.js'
|
||||
|
||||
// Fake visitor data
|
||||
const chartData = new Array(40).fill(0).map((data, key) => {
|
||||
if (key % 3 == 0 || key % 5 == 0) {
|
||||
return Math.ceil(Math.random() * (0 - 20) + 20)
|
||||
} else {
|
||||
return Math.ceil(Math.random() * (0 - 7) + 7)
|
||||
}
|
||||
})
|
||||
|
||||
// Fake visitor bar color
|
||||
const chartColor = () =>
|
||||
chartData.map((data) => {
|
||||
if (data >= 8 && data <= 14) {
|
||||
return getColor('--color-primary', 0.5)
|
||||
} else if (data >= 15) {
|
||||
return getColor('--color-primary', 0.6)
|
||||
}
|
||||
|
||||
return getColor('--color-primary', 0.4)
|
||||
})
|
||||
|
||||
const getRef = (chart: ChartInstance<'bar'>) => {
|
||||
setInterval(() => {
|
||||
const datasets = chart.data.datasets[0]
|
||||
if (datasets) {
|
||||
// Swap visitor data
|
||||
let newData = datasets.data[0]
|
||||
datasets.data.shift()
|
||||
newData !== undefined && datasets.data.push(newData)
|
||||
|
||||
// Swap visitor bar color
|
||||
if (datasets.backgroundColor && Array.isArray(datasets.backgroundColor)) {
|
||||
let newColor = datasets.backgroundColor[0]
|
||||
datasets.backgroundColor.shift()
|
||||
datasets.backgroundColor.push(newColor)
|
||||
}
|
||||
}
|
||||
|
||||
chart.update()
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: chartData,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Html Template',
|
||||
barThickness: 6,
|
||||
data: chartData,
|
||||
backgroundColor: chartColor(),
|
||||
borderColor: () => getColor('--color-primary', 0.1),
|
||||
borderWidth: 0.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
:get-ref="getRef"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,85 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Html Template',
|
||||
barThickness: 8,
|
||||
maxBarThickness: 6,
|
||||
data: [60, 150, 30, 200, 180, 50, 180, 120, 230, 180, 250, 270],
|
||||
backgroundColor: () => getColor('--color-primary', 0.3),
|
||||
borderColor: () => getColor('--color-primary', 0.6),
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
{
|
||||
label: 'VueJs Template',
|
||||
barThickness: 8,
|
||||
maxBarThickness: 6,
|
||||
data: [50, 135, 40, 180, 190, 60, 150, 90, 250, 170, 240, 250],
|
||||
backgroundColor: () => getColor('--color-foreground', 0.2),
|
||||
borderColor: () => getColor('--color-foreground', 0.3),
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
color: () => getColor('--color-foreground', 0.2),
|
||||
},
|
||||
border: {
|
||||
dash: [2, 2],
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { ChartConfiguration } from 'chart.js'
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
import type { BarChartDataset } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[]
|
||||
datasets: BarChartDataset[]
|
||||
showAxes?: boolean
|
||||
class?: string
|
||||
}>(),
|
||||
{
|
||||
showAxes: true,
|
||||
},
|
||||
)
|
||||
|
||||
const config = computed<ChartConfiguration<'bar'>>(() => ({
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets: props.datasets.map((dataset) => {
|
||||
const color = dataset.color ?? '--color-primary'
|
||||
|
||||
return {
|
||||
label: dataset.label ?? '',
|
||||
data: dataset.data,
|
||||
barThickness: dataset.barThickness ?? 8,
|
||||
maxBarThickness: 6,
|
||||
backgroundColor: () => getColor(color, 0.3),
|
||||
borderColor: () => getColor(color, 0.6),
|
||||
borderWidth: 0.5,
|
||||
}
|
||||
}),
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: props.showAxes,
|
||||
font: { size: 11 },
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: props.showAxes,
|
||||
color: () => getColor('--color-foreground', 0.2),
|
||||
},
|
||||
border: {
|
||||
dash: props.showAxes ? [2, 2] : undefined,
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<Chart :config="config" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Html', 'Vuejs', 'Laravel'],
|
||||
datasets: [
|
||||
{
|
||||
data: [15, 10, 65],
|
||||
backgroundColor: [
|
||||
getColor('--color-pending', 0.4),
|
||||
getColor('--color-primary', 0.4),
|
||||
getColor('--color-warning', 0.4),
|
||||
],
|
||||
borderWidth: 1,
|
||||
borderColor: [
|
||||
getColor('--color-pending', 0.9),
|
||||
getColor('--color-primary', 0.9),
|
||||
getColor('--color-warning', 0.9),
|
||||
],
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
cutout: '85%',
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Yellow', 'Dark'],
|
||||
datasets: [
|
||||
{
|
||||
data: [15, 10, 65],
|
||||
backgroundColor: [
|
||||
getColor('--color-pending', 0.4),
|
||||
getColor('--color-warning', 0.4),
|
||||
getColor('--color-primary', 0.4),
|
||||
],
|
||||
borderWidth: 1,
|
||||
borderColor: [
|
||||
getColor('--color-pending', 0.9),
|
||||
getColor('--color-warning', 0.9),
|
||||
getColor('--color-primary', 0.9),
|
||||
],
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
cutout: '83%',
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { ChartConfiguration } from 'chart.js'
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[]
|
||||
values: number[]
|
||||
/** CSS custom property names, e.g. `--color-primary` */
|
||||
colors?: string[]
|
||||
cutout?: string
|
||||
class?: string
|
||||
}>(),
|
||||
{
|
||||
colors: () => [
|
||||
'--color-primary',
|
||||
'--color-pending',
|
||||
'--color-warning',
|
||||
'--color-success',
|
||||
'--color-danger',
|
||||
],
|
||||
cutout: '85%',
|
||||
},
|
||||
)
|
||||
|
||||
const config = computed<ChartConfiguration<'doughnut'>>(() => {
|
||||
const palette = props.values.map(
|
||||
(_, index) => props.colors[index % props.colors.length] ?? '--color-primary',
|
||||
)
|
||||
|
||||
return {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: props.values,
|
||||
backgroundColor: palette.map((token) => getColor(token, 0.4)),
|
||||
borderWidth: 1,
|
||||
borderColor: palette.map((token) => getColor(token, 0.9)),
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
cutout: props.cutout,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<Chart :config="config" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,92 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 200, 250, 200, 700, 550, 650, 1050, 950, 1100, 900, 1200],
|
||||
borderWidth: 1.5,
|
||||
borderColor: () => getColor('--color-primary', 0.5),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 300, 400, 560, 320, 600, 720, 850, 690, 805, 1200, 1010],
|
||||
borderWidth: 1.5,
|
||||
borderDash: [1, 4],
|
||||
borderColor: () => getColor('--color-foreground'),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
callback: function (value, index, values) {
|
||||
return '$' + value
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: () => getColor('--color-foreground', 0.2),
|
||||
},
|
||||
border: {
|
||||
dash: [2, 2],
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 200, 250, 200, 500, 450, 850, 1050, 950, 1100, 900, 1200],
|
||||
borderWidth: 1,
|
||||
borderColor: () => getColor('--color-primary', 0.8),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 200, 250, 200, 700, 550, 650, 1050, 950, 1100, 900, 1200],
|
||||
borderWidth: 1,
|
||||
borderColor: () => getColor('--color-primary', 0.8),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.4,
|
||||
},
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [0, 300, 400, 560, 320, 600, 720, 850, 690, 805, 1200, 1010],
|
||||
borderDash: [2, 2],
|
||||
borderWidth: 1,
|
||||
borderColor: () => getColor('--color-foreground', 0.8),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { ChartConfiguration } from 'chart.js'
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
import type { LineChartDataset } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[]
|
||||
datasets: LineChartDataset[]
|
||||
showAxes?: boolean
|
||||
yTickPrefix?: string
|
||||
class?: string
|
||||
}>(),
|
||||
{
|
||||
showAxes: true,
|
||||
yTickPrefix: '',
|
||||
},
|
||||
)
|
||||
|
||||
const config = computed<ChartConfiguration<'line'>>(() => ({
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets: props.datasets.map((dataset) => {
|
||||
const color = dataset.color ?? '--color-primary'
|
||||
|
||||
return {
|
||||
label: dataset.label ?? '',
|
||||
data: dataset.data,
|
||||
borderWidth: dataset.borderWidth ?? 1.5,
|
||||
borderDash: dataset.dashed ? [2, 2] : undefined,
|
||||
borderColor: () => getColor(color, props.showAxes ? 0.5 : 0.8),
|
||||
backgroundColor: 'transparent',
|
||||
pointBorderColor: 'transparent',
|
||||
tension: dataset.tension ?? 0.3,
|
||||
}
|
||||
}),
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
display: props.showAxes,
|
||||
font: { size: 12 },
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
display: props.showAxes,
|
||||
font: { size: 12 },
|
||||
color: () => getColor('--color-foreground', 0.5),
|
||||
callback(value) {
|
||||
return `${props.yTickPrefix}${value}`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
display: props.showAxes,
|
||||
color: () => getColor('--color-foreground', 0.2),
|
||||
},
|
||||
border: {
|
||||
dash: props.showAxes ? [2, 2] : undefined,
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<Chart :config="config" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Chart
|
||||
:config="{
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: ['31 - 50 Years old', '>= 50 Years old', '17 - 30 Years old'],
|
||||
datasets: [
|
||||
{
|
||||
data: [15, 10, 65],
|
||||
backgroundColor: [
|
||||
getColor('--color-pending', 0.4),
|
||||
getColor('--color-warning', 0.4),
|
||||
getColor('--color-primary', 0.4),
|
||||
],
|
||||
borderWidth: 1,
|
||||
borderColor: [
|
||||
getColor('--color-pending', 0.9),
|
||||
getColor('--color-warning', 0.9),
|
||||
getColor('--color-primary', 0.9),
|
||||
],
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { ChartConfiguration } from 'chart.js'
|
||||
import { Chart, getColor } from '@/components/ui/chart'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[]
|
||||
values: number[]
|
||||
/** CSS custom property names, e.g. `--color-primary` */
|
||||
colors?: string[]
|
||||
class?: string
|
||||
}>(),
|
||||
{
|
||||
colors: () => [
|
||||
'--color-primary',
|
||||
'--color-pending',
|
||||
'--color-warning',
|
||||
'--color-success',
|
||||
'--color-danger',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
const config = computed<ChartConfiguration<'pie'>>(() => {
|
||||
const palette = props.values.map(
|
||||
(_, index) => props.colors[index % props.colors.length] ?? '--color-primary',
|
||||
)
|
||||
|
||||
return {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: props.values,
|
||||
backgroundColor: palette.map((token) => getColor(token, 0.4)),
|
||||
borderWidth: 1,
|
||||
borderColor: palette.map((token) => getColor(token, 0.9)),
|
||||
offset: 20,
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<Chart :config="config" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,6 @@
|
||||
export { default as Line1 } from './Line1.vue' //report-line-chart
|
||||
export { default as Line2 } from './Line2.vue' //simple-line-chart-1
|
||||
export { default as Line3 } from './Line3.vue' //simple-line-chart-3, simple-line-chart-4
|
||||
export { default as Pie1 } from './Pie1.vue' //report-pie-chart
|
||||
export { default as Donut1 } from './Donut1.vue' //report-donut-chart
|
||||
export { default as Donut2 } from './Donut2.vue' //report-donut-chart-1
|
||||
export { default as Bar1 } from './Bar1.vue' //report-bar-chart
|
||||
export { default as Bar2 } from './Bar2.vue' //report-bar-chart-1
|
||||
export { default as DonutChart } from './DonutChart.vue'
|
||||
export { default as PieChart } from './PieChart.vue'
|
||||
export { default as LineChart } from './LineChart.vue'
|
||||
export { default as BarChart } from './BarChart.vue'
|
||||
|
||||
export type { LineChartDataset, BarChartDataset } from './types'
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type LineChartDataset = {
|
||||
label?: string
|
||||
data: number[]
|
||||
/** CSS custom property name, e.g. `--color-primary` */
|
||||
color?: string
|
||||
dashed?: boolean
|
||||
tension?: number
|
||||
borderWidth?: number
|
||||
}
|
||||
|
||||
export type BarChartDataset = {
|
||||
label?: string
|
||||
data: number[]
|
||||
/** CSS custom property name, e.g. `--color-primary` */
|
||||
color?: string
|
||||
barThickness?: number
|
||||
}
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { Map } from '@/components/ui/map'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">>= 50 Years old</span>
|
||||
<span class="ms-auto">10%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Weekly Top Seller -->
|
||||
<!-- BEGIN: Sales Report -->
|
||||
<div class="col-span-12 mt-5 sm:col-span-6 lg:col-span-3">
|
||||
<div class="flex h-10 items-center">
|
||||
<h2 class="me-5 truncate text-lg font-medium">Sales Report</h2>
|
||||
<a class="text-primary ms-auto truncate" href=""> Show More </a>
|
||||
</div>
|
||||
<Box class="mt-5 p-5">
|
||||
<div class="mt-3">
|
||||
<Donut1 class="h-[213px]" />
|
||||
</div>
|
||||
<div class="mx-auto mt-8 w-52 sm:w-auto">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]">
|
||||
</div>
|
||||
<span class="truncate">17 - 30 Years old</span>
|
||||
<span class="ms-auto">62%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]">
|
||||
</div>
|
||||
<span class="truncate">31 - 50 Years old</span>
|
||||
<span class="ms-auto">33%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]">
|
||||
</div>
|
||||
<span class="truncate">>= 50 Years old</span>
|
||||
<span class="ms-auto">10%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Sales Report -->
|
||||
<!-- BEGIN: Official Store -->
|
||||
<div class="col-span-12 mt-6 xl:col-span-8">
|
||||
<OfficialStores />
|
||||
</div>
|
||||
<!-- END: Official Store -->
|
||||
<!-- BEGIN: Weekly Best Sellers -->
|
||||
<div class="col-span-12 mt-6 xl:col-span-4">
|
||||
<WeeklyBestSellers />
|
||||
</div>
|
||||
<!-- END: Weekly Best Sellers -->
|
||||
<!-- BEGIN: General Report -->
|
||||
<div class="col-span-12 mt-8 grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2/4 flex-none">
|
||||
<div class="truncate text-lg font-medium">Target Sales</div>
|
||||
<div class="mt-1 opacity-70">300 Sales</div>
|
||||
</div>
|
||||
<div class="relative ms-auto flex-none">
|
||||
<Donut2 class="w-[90px] h-[90px]" />
|
||||
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
|
||||
20%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="me-3 truncate text-lg font-medium">Social Media</div>
|
||||
<Badge variant="secondary" look="outline" class="ms-auto whitespace-nowrap">137 Sales</Badge>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<Line2 class="-ms-1 h-[58px]" />
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2/4 flex-none">
|
||||
<div class="truncate text-lg font-medium">New Products</div>
|
||||
<div class="mt-1 opacity-70">1450 Products</div>
|
||||
</div>
|
||||
<div class="relative ms-auto flex-none">
|
||||
<Donut2 class="w-[90px] h-[90px]" />
|
||||
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
|
||||
45%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="me-3 truncate text-lg font-medium">Posted Ads</div>
|
||||
<Badge variant="secondary" look="outline" class="ms-auto whitespace-nowrap">180 Campaign</Badge>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<Line2 class="-ms-1 h-[58px]" />
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END: General Report -->
|
||||
<!-- BEGIN: Weekly Top Products -->
|
||||
<div class="col-span-12 mt-6">
|
||||
<WeeklyTopProducts />
|
||||
</div>
|
||||
<!-- END: Weekly Top Products -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 2xl:col-span-3">
|
||||
<div class="-mb-10 h-full pb-10 2xl:border-l border-foreground/15">
|
||||
<div class="grid grid-cols-12 gap-x-6 gap-y-6 2xl:gap-x-0 2xl:pl-6">
|
||||
<!-- BEGIN: Transactions -->
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4 2xl:col-span-12">
|
||||
<Transactions />
|
||||
</div>
|
||||
<!-- END: Transactions -->
|
||||
<!-- BEGIN: Recent Activities -->
|
||||
<div class="col-span-12 mt-3 md:col-span-6 xl:col-span-4 2xl:col-span-12">
|
||||
<RecentActivities />
|
||||
</div>
|
||||
<!-- END: Recent Activities -->
|
||||
<!-- BEGIN: Daily Notes -->
|
||||
<div
|
||||
class="col-span-12 mt-3 md:col-span-6 xl:col-span-12 xl:col-start-1 xl:row-start-1 2xl:col-start-auto 2xl:row-start-auto">
|
||||
<DailyNotes />
|
||||
</div>
|
||||
<!-- END: Daily Notes -->
|
||||
<!-- BEGIN: Schedules -->
|
||||
<div
|
||||
class="col-span-12 mt-3 md:col-span-6 xl:col-span-4 xl:col-start-1 xl:row-start-2 2xl:col-span-12 2xl:col-start-auto 2xl:row-start-auto">
|
||||
<Schedules />
|
||||
</div>
|
||||
<!-- END: Schedules -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BEGIN: Onboarding Dialog -->
|
||||
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogCloseTrigger />
|
||||
<div class="overflow-hidden">
|
||||
<div class="-my-5 -mx-10">
|
||||
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="phoneIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Sistem MyKOPKB adalah sistem yang membantu pengguna untuk menguruskan permohonan keahlian KOPKB.
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<a class="text-danger flex items-center gap-3 font-medium cursor-pointer"
|
||||
@click.prevent="onboardingDialog = false">
|
||||
Skip Intro
|
||||
</a>
|
||||
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
|
||||
Next
|
||||
<Lucide icon="MoveRight" />
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="womanIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="text-center text-xl font-medium">Example Request Information</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Your premium admin dashboard template.
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<FieldGroup class="px-5">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<Field>
|
||||
<FieldLabel>Full Name</FieldLabel>
|
||||
<Input type="text" placeholder="John Doe" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Event</FieldLabel>
|
||||
<NativeSelect>
|
||||
<NativeSelectOption>Corporate Event</NativeSelectOption>
|
||||
<NativeSelectOption>Wedding</NativeSelectOption>
|
||||
<NativeSelectOption>Birthday</NativeSelectOption>
|
||||
<NativeSelectOption>Other</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
|
||||
<Lucide icon="MoveLeft" /> Previous
|
||||
</CarouselPrevTrigger>
|
||||
<a class="text-primary flex items-center gap-3 font-medium cursor-pointer me-0 px-5"
|
||||
@click.prevent="onboardingDialog = false">
|
||||
Selesai
|
||||
<Lucide icon="MoveRight" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</CarouselRoot>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
<!-- END: Onboarding Dialog -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,111 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import phoneIllustration from '@/assets/images/phone-illustration.svg'
|
||||
import womanIllustration from '@/assets/images/woman-illustration.svg'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Lucide, type Icon } from '@/components/ui/lucide'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { completeOnboarding } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const onboardingDialog = ref(false)
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import AvailableModules from '../components/AvailableModules.vue'
|
||||
import DashboardGreeting from '../components/DashboardGreeting.vue'
|
||||
import MemberInvestmentSummary from '../components/MemberInvestmentSummary.vue'
|
||||
import OnboardingDialog from '../components/OnboardingDialog.vue'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Selamat pagi'
|
||||
if (hour < 18) return 'Selamat petang'
|
||||
return 'Selamat malam'
|
||||
})
|
||||
|
||||
const availableModules = computed(() => [
|
||||
{
|
||||
title: 'Profil',
|
||||
description: 'Kemas kini maklumat peribadi, pekerjaan, bank dan waris.',
|
||||
route: 'profile-overview-2',
|
||||
icon: 'User' as Icon,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: 'Permohonan Keahlian',
|
||||
description: 'Urus dan semak permohonan keahlian KOPKB.',
|
||||
route: 'list-membership-applications',
|
||||
icon: 'ClipboardList' as Icon,
|
||||
visible: hasPermission('lihat permohonan keahlian'),
|
||||
},
|
||||
{
|
||||
title: 'Aktiviti',
|
||||
description: 'Lihat dan urus aktiviti serta laporan berkaitan.',
|
||||
route: 'list-activities',
|
||||
icon: 'CalendarDays' as Icon,
|
||||
visible: hasPermission('lihat aktiviti'),
|
||||
},
|
||||
{
|
||||
title: 'Senarai Daftar Anggota',
|
||||
description: 'Pantau dan urus akaun pengguna sistem.',
|
||||
route: 'list-users',
|
||||
icon: 'Users' as Icon,
|
||||
visible: hasPermission('lihat pengguna'),
|
||||
},
|
||||
].filter((module) => module.visible))
|
||||
|
||||
const upcomingFeatures = [
|
||||
'Ringkasan statistik keahlian',
|
||||
'Carta dan laporan bulanan',
|
||||
'Notifikasi dan aktiviti terkini',
|
||||
'Papan pemuka pentadbir',
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
onboardingDialog.value = !authStore.user?.onboarding_completed_at
|
||||
})
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
if (authStore.user?.onboarding_completed_at) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await completeOnboarding()
|
||||
if (res.success) {
|
||||
authStore.setUserProfile(res.data)
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next visit if the request failed
|
||||
}
|
||||
}
|
||||
|
||||
async function finishOnboarding() {
|
||||
onboardingDialog.value = false
|
||||
await markOnboardingComplete()
|
||||
await router.push({ name: 'profile-overview-2' })
|
||||
}
|
||||
|
||||
function handleOnboardingOpenChange(details: { open: boolean }) {
|
||||
onboardingDialog.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
void markOnboardingComplete()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -120,139 +22,11 @@ function handleOnboardingOpenChange(details: { open: boolean }) {
|
||||
</AlertRoot>
|
||||
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12">
|
||||
<Box class="relative overflow-hidden p-6 sm:p-8">
|
||||
<div class="pointer-events-none absolute -right-10 -top-10 size-40 rounded-full bg-primary/10 blur-2xl" />
|
||||
<div class="relative">
|
||||
<Badge variant="pending" class="mb-4">Beta</Badge>
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ greeting }}{{ authStore.userName ? `, ${authStore.userName}` : '' }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-2xl text-base leading-relaxed opacity-70">
|
||||
Selamat datang ke {{ appName }}. Papan pemuka utama sedang dibangunkan — buat masa ini,
|
||||
anda boleh mula dengan mengemas kini profil atau mengurus permohonan keahlian.
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-7">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Modul Tersedia</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Box v-for="module in availableModules" :key="module.route"
|
||||
class="flex h-full flex-col p-5 transition-colors hover:bg-foreground/2">
|
||||
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Lucide :icon="module.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="text-base font-medium">{{ module.title }}</div>
|
||||
<p class="mt-2 flex-1 text-sm leading-relaxed opacity-70">
|
||||
{{ module.description }}
|
||||
</p>
|
||||
<Button as-child variant="ghost" class="mt-5 w-full border border-foreground/15">
|
||||
<RouterLink :to="{ name: module.route }">
|
||||
Pergi ke {{ module.title }}
|
||||
<Lucide icon="ArrowRight" class="size-4" />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-5">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Akan Datang</h3>
|
||||
</div>
|
||||
|
||||
<Box class="p-5">
|
||||
<p class="text-sm leading-relaxed opacity-70">
|
||||
Ciri-ciri berikut sedang dirancang untuk papan pemuka ini:
|
||||
</p>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li v-for="feature in upcomingFeatures" :key="feature" class="flex items-start gap-3 text-sm">
|
||||
<Lucide icon="CircleDashed" class="mt-0.5 size-4 shrink-0 text-primary/70" />
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
|
||||
<Box class="mt-4 p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<Lucide icon="Lightbulb" class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">Petua</div>
|
||||
<p class="mt-1 text-sm leading-relaxed opacity-70">
|
||||
Pastikan profil anda lengkap dan terkini. Maklumat yang
|
||||
tepat membantu proses kelulusan berjalan lebih lancar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<DashboardGreeting />
|
||||
<MemberInvestmentSummary />
|
||||
<AvailableModules />
|
||||
</div>
|
||||
|
||||
<!-- BEGIN: Onboarding Dialog -->
|
||||
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogCloseTrigger />
|
||||
<div class="overflow-hidden">
|
||||
<div class="-my-5 -mx-10">
|
||||
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="phoneIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Sistem MyKOPKB adalah sistem integrasi bagi ahli Koperasi Permodalan Kelantan Berhad (KOPKB).
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex justify-end px-5">
|
||||
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
|
||||
Seterusnya
|
||||
<Lucide icon="MoveRight" />
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="womanIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Mulakan dengan Profil Anda</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Lengkapkan maklumat peribadi, pekerjaan dan butiran bank.
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
|
||||
<Lucide icon="MoveLeft" /> Kembali
|
||||
</CarouselPrevTrigger>
|
||||
<a class="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 font-semibold text-white shadow-md transition-all duration-200 hover:bg-primary/90 hover:shadow-lg active:scale-95 cursor-pointer"
|
||||
@click.prevent="finishOnboarding">
|
||||
<span>Selesai</span>
|
||||
<Lucide icon="Check" class="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</CarouselRoot>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
<!-- END: Onboarding Dialog -->
|
||||
<OnboardingDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
INVESTMENT_TYPE_STATUS_OPTIONS,
|
||||
toOptionalNumber,
|
||||
} from '../utils/investment-type.utils'
|
||||
import type {
|
||||
InvestmentType,
|
||||
InvestmentTypePayload,
|
||||
InvestmentTypeStatus,
|
||||
} from '../types/investment-type.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const props = defineProps<{
|
||||
initial?: InvestmentType | null
|
||||
saving?: boolean
|
||||
submitLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: InvestmentTypePayload]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
status: 'ACTIVE' as InvestmentTypeStatus,
|
||||
minimum_amount: '0',
|
||||
maximum_amount: '',
|
||||
total_fund_limit: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
allow_billplz: true,
|
||||
allow_salary_deduction: false,
|
||||
billplz_collection_id: '',
|
||||
})
|
||||
|
||||
const statusCollection = select.collection({
|
||||
items: INVESTMENT_TYPE_STATUS_OPTIONS as SelectOption[],
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
|
||||
const statusInitial = computed(() => {
|
||||
const option = INVESTMENT_TYPE_STATUS_OPTIONS.find((item) => item.value === form.status)
|
||||
return option ? [option.label] : ['Aktif']
|
||||
})
|
||||
|
||||
function labelToStatus(label: string | undefined): InvestmentTypeStatus {
|
||||
return (
|
||||
INVESTMENT_TYPE_STATUS_OPTIONS.find((item) => item.label === label)?.value ?? 'ACTIVE'
|
||||
)
|
||||
}
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
form.status = labelToStatus(details.value[0])
|
||||
}
|
||||
|
||||
function syncFromInitial(item: InvestmentType | null | undefined) {
|
||||
if (!item) return
|
||||
|
||||
form.code = item.code ?? ''
|
||||
form.name = item.name ?? ''
|
||||
form.description = item.description ?? ''
|
||||
form.status = item.status ?? 'ACTIVE'
|
||||
form.minimum_amount = item.minimum_amount == null ? '0' : String(item.minimum_amount)
|
||||
form.maximum_amount = item.maximum_amount == null ? '' : String(item.maximum_amount)
|
||||
form.total_fund_limit = item.total_fund_limit == null ? '' : String(item.total_fund_limit)
|
||||
form.start_date = item.start_date ?? ''
|
||||
form.end_date = item.end_date ?? ''
|
||||
form.allow_billplz = Boolean(item.allow_billplz)
|
||||
form.allow_salary_deduction = Boolean(item.allow_salary_deduction)
|
||||
form.billplz_collection_id = item.billplz_collection_id ?? ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initial,
|
||||
(value) => {
|
||||
syncFromInitial(value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function handleSubmit() {
|
||||
const minimumAmount = Number(form.minimum_amount)
|
||||
const payload: InvestmentTypePayload = {
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
status: form.status,
|
||||
minimum_amount: Number.isNaN(minimumAmount) ? 0 : minimumAmount,
|
||||
maximum_amount: toOptionalNumber(form.maximum_amount),
|
||||
total_fund_limit: toOptionalNumber(form.total_fund_limit),
|
||||
start_date: form.start_date || null,
|
||||
end_date: form.end_date || null,
|
||||
allow_billplz: form.allow_billplz,
|
||||
allow_salary_deduction: form.allow_salary_deduction,
|
||||
billplz_collection_id: form.billplz_collection_id.trim() || null,
|
||||
}
|
||||
|
||||
emit('submit', payload)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="investment-type-code" required>Kod</FieldLabel>
|
||||
<Input id="investment-type-code" v-model="form.code" class="w-full" type="text" placeholder="Contoh: GOLD_FUND"
|
||||
:disabled="saving" required />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="investment-type-name" required>Nama</FieldLabel>
|
||||
<Input id="investment-type-name" v-model="form.name" class="w-full" type="text" placeholder="Contoh: Dana Emas"
|
||||
:disabled="saving" required />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="investment-type-description">Penerangan</FieldLabel>
|
||||
<Textarea id="investment-type-description" v-model="form.description" class="w-full" rows="3"
|
||||
placeholder="Penerangan produk pelaburan" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel required>Status</FieldLabel>
|
||||
<SelectRoot :key="statusInitial[0]" class="w-full" :collection="statusCollection" :default-value="statusInitial"
|
||||
:disabled="saving" @value-change="setStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="investment-type-billplz-collection">Billplz Collection ID</FieldLabel>
|
||||
<Input id="investment-type-billplz-collection" v-model="form.billplz_collection_id" class="w-full" type="text"
|
||||
placeholder="Pilihan (kosong = koleksi global)" :disabled="saving" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Field>
|
||||
<FieldLabel for="investment-type-min" required>Jumlah Minimum (MYR)</FieldLabel>
|
||||
<Input id="investment-type-min" v-model="form.minimum_amount" class="w-full" type="number" min="0" step="0.01"
|
||||
:disabled="saving" required />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="investment-type-max">Jumlah Maksimum (MYR)</FieldLabel>
|
||||
<Input id="investment-type-max" v-model="form.maximum_amount" class="w-full" type="number" min="0" step="0.01"
|
||||
placeholder="Pilihan" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="investment-type-fund-limit">Had Dana (MYR)</FieldLabel>
|
||||
<Input id="investment-type-fund-limit" v-model="form.total_fund_limit" class="w-full" type="number" min="0"
|
||||
step="0.01" placeholder="Pilihan" :disabled="saving" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="investment-type-start">Tarikh Mula</FieldLabel>
|
||||
<Input id="investment-type-start" v-model="form.start_date" class="w-full" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="investment-type-end">Tarikh Tamat</FieldLabel>
|
||||
<Input id="investment-type-end" v-model="form.end_date" class="w-full" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CheckboxRoot :checked="form.allow_billplz" :disabled="saving"
|
||||
@checked-change="({ checked }) => (form.allow_billplz = checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Benarkan pembayaran Billplz</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
|
||||
<CheckboxRoot :checked="form.allow_salary_deduction" :disabled="saving"
|
||||
@checked-change="({ checked }) => (form.allow_salary_deduction = checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Benarkan potongan gaji</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2 pt-2">
|
||||
<Button type="button" look="outline" variant="secondary" :disabled="saving" @click="emit('cancel')">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" look="outline" variant="primary" :disabled="saving">
|
||||
{{ saving ? 'Menyimpan...' : submitLabel || 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,393 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { Trash } from '@lucide/vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { input as inputStyles } from '@/components/ui/styles/input.styles'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@mykopkb/core/utils/cn'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUsers } from '@/modules/user/services/user.service'
|
||||
import type { UserListItem } from '@/modules/user/types/user.types'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import type {
|
||||
ManualInvestmentFiles,
|
||||
ManualInvestmentPayload,
|
||||
} from '../types/investment.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
type StagedDocumentFile = {
|
||||
id: string
|
||||
file: File
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE_MB = 10
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
const props = defineProps<{
|
||||
saving?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: ManualInvestmentPayload, files: ManualInvestmentFiles]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
user_id: '',
|
||||
investment_type_id: '',
|
||||
amount: '',
|
||||
remarks: '',
|
||||
})
|
||||
|
||||
const documentFiles = ref<StagedDocumentFile[]>([])
|
||||
const documentError = ref<string | null>(null)
|
||||
|
||||
const memberSearch = ref('')
|
||||
const memberResults = ref<UserListItem[]>([])
|
||||
const selectedMember = ref<UserListItem | null>(null)
|
||||
const searchingMembers = ref(false)
|
||||
const memberSearchError = ref<string | null>(null)
|
||||
|
||||
const investmentTypes = ref<InvestmentType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
const typesError = ref<string | null>(null)
|
||||
|
||||
const typeOptions = computed<SelectOption[]>(() =>
|
||||
investmentTypes.value.map((type) => ({
|
||||
label: `${type.code} — ${type.name}`,
|
||||
value: type.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const typeCollection = computed(() =>
|
||||
select.collection({
|
||||
items: typeOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeInitial = computed(() => {
|
||||
const option = typeOptions.value.find((item) => item.value === form.investment_type_id)
|
||||
return option ? [option.label] : []
|
||||
})
|
||||
|
||||
function setTypeValue(details: { value: string[] }) {
|
||||
const label = details.value[0]
|
||||
form.investment_type_id =
|
||||
typeOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
async function loadInvestmentTypes() {
|
||||
loadingTypes.value = true
|
||||
typesError.value = null
|
||||
|
||||
try {
|
||||
const data = await listInvestmentTypes({
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
investmentTypes.value = data.data.filter((type) => type.status === 'ACTIVE')
|
||||
} catch (err) {
|
||||
typesError.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||
investmentTypes.value = []
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function searchMembers(page = 1) {
|
||||
const query = memberSearch.value.trim()
|
||||
if (query.length < 2) {
|
||||
memberResults.value = []
|
||||
memberSearchError.value = null
|
||||
return
|
||||
}
|
||||
|
||||
searchingMembers.value = true
|
||||
memberSearchError.value = null
|
||||
|
||||
try {
|
||||
const data = await listUsers({
|
||||
page,
|
||||
per_page: 8,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
search: query,
|
||||
status: 'active',
|
||||
})
|
||||
memberResults.value = data.data
|
||||
} catch (err) {
|
||||
memberSearchError.value = getApiErrorMessage(err, 'Gagal mencari ahli.')
|
||||
memberResults.value = []
|
||||
} finally {
|
||||
searchingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedMemberSearch = debounce(() => {
|
||||
searchMembers(1)
|
||||
}, 350)
|
||||
|
||||
watch(memberSearch, () => {
|
||||
if (selectedMember.value) return
|
||||
debouncedMemberSearch()
|
||||
})
|
||||
|
||||
function selectMember(member: UserListItem) {
|
||||
selectedMember.value = member
|
||||
form.user_id = member.id
|
||||
memberSearch.value = `${member.name} (${member.ic_number})`
|
||||
memberResults.value = []
|
||||
memberSearchError.value = null
|
||||
}
|
||||
|
||||
function clearMember() {
|
||||
selectedMember.value = null
|
||||
form.user_id = ''
|
||||
memberSearch.value = ''
|
||||
memberResults.value = []
|
||||
}
|
||||
|
||||
function onDocumentFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
const oversized = files.find((file) => file.size > MAX_FILE_SIZE_BYTES)
|
||||
if (oversized) {
|
||||
documentError.value = `Saiz fail melebihi had maksimum ${MAX_FILE_SIZE_MB}MB.`
|
||||
return
|
||||
}
|
||||
|
||||
documentError.value = null
|
||||
documentFiles.value.push(
|
||||
...files.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function removeDocumentFile(id: string) {
|
||||
documentFiles.value = documentFiles.value.filter((file) => file.id !== id)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const amount = Number(form.amount)
|
||||
|
||||
emit(
|
||||
'submit',
|
||||
{
|
||||
user_id: form.user_id,
|
||||
investment_type_id: form.investment_type_id,
|
||||
amount: Number.isNaN(amount) ? 0 : amount,
|
||||
remarks: form.remarks.trim() || null,
|
||||
},
|
||||
{
|
||||
documents: documentFiles.value.map((item) => item.file),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInvestmentTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<Field>
|
||||
<FieldLabel required>Ahli</FieldLabel>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="memberSearch"
|
||||
type="search"
|
||||
placeholder="Cari nama, e-mel atau no. KP..."
|
||||
:disabled="props.saving || Boolean(selectedMember)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
v-if="selectedMember"
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 px-3 text-sm opacity-70 hover:opacity-100"
|
||||
:disabled="props.saving"
|
||||
@click="clearMember"
|
||||
>
|
||||
Tukar
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="searchingMembers" class="mt-1 text-xs opacity-60">Mencari ahli...</p>
|
||||
<p v-if="memberSearchError" class="mt-1 text-xs text-danger">{{ memberSearchError }}</p>
|
||||
|
||||
<div
|
||||
v-if="memberResults.length && !selectedMember"
|
||||
class="mt-2 max-h-56 overflow-y-auto rounded-md border border-foreground/10"
|
||||
>
|
||||
<button
|
||||
v-for="member in memberResults"
|
||||
:key="member.id"
|
||||
type="button"
|
||||
class="flex w-full flex-col items-start px-3 py-2 text-left text-sm hover:bg-foreground/5"
|
||||
@click="selectMember(member)"
|
||||
>
|
||||
<span class="font-medium">{{ member.name }}</span>
|
||||
<span class="text-xs opacity-60">
|
||||
{{ member.ic_number }}
|
||||
<span v-if="member.member_number"> · No. {{ member.member_number }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan jenis pelaburan...</p>
|
||||
<!-- Mount after load: SelectRoot/zag machine keeps initial disabled/collection -->
|
||||
<SelectRoot
|
||||
v-else-if="typeOptions.length"
|
||||
:key="typeOptions.map((item) => item.value).join(',')"
|
||||
class="w-full"
|
||||
:collection="typeCollection"
|
||||
:default-value="typeInitial"
|
||||
:disabled="props.saving"
|
||||
@value-change="setTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in typeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<p v-if="typesError" class="mt-1 text-xs text-danger">{{ typesError }}</p>
|
||||
<p v-else-if="!loadingTypes && !typeOptions.length" class="mt-1 text-xs opacity-60">
|
||||
Tiada jenis pelaburan aktif.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jumlah (MYR)</FieldLabel>
|
||||
<Input
|
||||
v-model="form.amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="props.saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Catatan</FieldLabel>
|
||||
<Textarea
|
||||
v-model="form.remarks"
|
||||
rows="3"
|
||||
placeholder="Catatan pilihan..."
|
||||
:disabled="props.saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="space-y-3 rounded-lg border border-foreground/10 p-4">
|
||||
<div>
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Pilihan. Tambah bukti atau dokumen sokongan (PDF, JPG, PNG, DOC, DOCX).
|
||||
Saiz maksimum setiap fail: {{ MAX_FILE_SIZE_MB }}MB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="documentFiles.length" class="space-y-2">
|
||||
<div
|
||||
v-for="document in documentFiles"
|
||||
:key="document.id"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-foreground/10 p-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ document.file.name }}</div>
|
||||
<div class="text-xs opacity-70">{{ document.file.type || 'Dokumen' }}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="props.saving"
|
||||
@click="removeDocumentFile(document.id)"
|
||||
>
|
||||
<Trash class="mr-2 size-4" />
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="manual-investment-documents">Tambah Dokumen</FieldLabel>
|
||||
<input
|
||||
id="manual-investment-documents"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,application/pdf,image/*"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="props.saving"
|
||||
@change="onDocumentFilesChange"
|
||||
/>
|
||||
<FieldError v-if="documentError">{{ documentError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
look="outline"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
:disabled="props.saving"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:disabled="
|
||||
props.saving ||
|
||||
!form.user_id ||
|
||||
!form.investment_type_id ||
|
||||
!form.amount ||
|
||||
Number(form.amount) <= 0
|
||||
"
|
||||
>
|
||||
{{ props.saving ? 'Menyimpan...' : 'Rekod Pelaburan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUsers } from '@/modules/user/services/user.service'
|
||||
import type { UserListItem } from '@/modules/user/types/user.types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
selectedLabel?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
select: [member: UserListItem]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const memberSearch = ref(props.selectedLabel ?? '')
|
||||
const memberResults = ref<UserListItem[]>([])
|
||||
const searchingMembers = ref(false)
|
||||
const memberSearchError = ref<string | null>(null)
|
||||
const hasSelection = ref(Boolean(props.modelValue))
|
||||
|
||||
watch(
|
||||
() => props.selectedLabel,
|
||||
(value) => {
|
||||
if (value) {
|
||||
memberSearch.value = value
|
||||
hasSelection.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function searchMembers() {
|
||||
const query = memberSearch.value.trim()
|
||||
if (query.length < 2 || hasSelection.value) {
|
||||
memberResults.value = []
|
||||
memberSearchError.value = null
|
||||
return
|
||||
}
|
||||
|
||||
searchingMembers.value = true
|
||||
memberSearchError.value = null
|
||||
|
||||
try {
|
||||
const data = await listUsers({
|
||||
page: 1,
|
||||
per_page: 8,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
search: query,
|
||||
status: 'active',
|
||||
})
|
||||
memberResults.value = data.data
|
||||
} catch (err) {
|
||||
memberSearchError.value = getApiErrorMessage(err, 'Gagal mencari ahli.')
|
||||
memberResults.value = []
|
||||
} finally {
|
||||
searchingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedMemberSearch = debounce(() => {
|
||||
searchMembers()
|
||||
}, 350)
|
||||
|
||||
watch(memberSearch, () => {
|
||||
if (hasSelection.value) return
|
||||
debouncedMemberSearch()
|
||||
})
|
||||
|
||||
function selectMember(member: UserListItem) {
|
||||
hasSelection.value = true
|
||||
memberSearch.value = `${member.name} (${member.ic_number})`
|
||||
memberResults.value = []
|
||||
memberSearchError.value = null
|
||||
emit('update:modelValue', member.id)
|
||||
emit('select', member)
|
||||
}
|
||||
|
||||
function clearMember() {
|
||||
hasSelection.value = false
|
||||
memberSearch.value = ''
|
||||
memberResults.value = []
|
||||
emit('update:modelValue', '')
|
||||
emit('clear')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Field>
|
||||
<FieldLabel :required="required">{{ label ?? 'Ahli' }}</FieldLabel>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="memberSearch"
|
||||
type="search"
|
||||
placeholder="Cari nama, e-mel atau no. KP..."
|
||||
:disabled="disabled || hasSelection"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
v-if="hasSelection"
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 px-3 text-sm opacity-70 hover:opacity-100"
|
||||
:disabled="disabled"
|
||||
@click="clearMember"
|
||||
>
|
||||
Tukar
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="searchingMembers" class="mt-1 text-xs opacity-60">Mencari ahli...</p>
|
||||
<p v-if="memberSearchError" class="mt-1 text-xs text-danger">{{ memberSearchError }}</p>
|
||||
|
||||
<div
|
||||
v-if="memberResults.length && !hasSelection"
|
||||
class="mt-2 max-h-56 overflow-y-auto rounded-md border border-foreground/10"
|
||||
>
|
||||
<button
|
||||
v-for="member in memberResults"
|
||||
:key="member.id"
|
||||
type="button"
|
||||
class="flex w-full flex-col items-start px-3 py-2 text-left text-sm hover:bg-foreground/5"
|
||||
@click="selectMember(member)"
|
||||
>
|
||||
<span class="font-medium">{{ member.name }}</span>
|
||||
<span class="text-xs opacity-60">
|
||||
{{ member.ic_number }}
|
||||
<span v-if="member.member_number"> · No. {{ member.member_number }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</template>
|
||||
@@ -0,0 +1,273 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import MemberSearchField from './MemberSearchField.vue'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import type {
|
||||
SalaryContribution,
|
||||
SalaryContributionPayload,
|
||||
SalaryContributionStatus,
|
||||
} from '../types/salary.types'
|
||||
import { SALARY_CONTRIBUTION_STATUS_OPTIONS } from '../utils/salary.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const props = defineProps<{
|
||||
initial?: SalaryContribution | null
|
||||
saving?: boolean
|
||||
submitLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: SalaryContributionPayload]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
user_id: '',
|
||||
investment_type_id: '',
|
||||
monthly_amount: '',
|
||||
start_date: dayjs().format('YYYY-MM-DD'),
|
||||
end_date: '',
|
||||
status: 'ACTIVE' as SalaryContributionStatus,
|
||||
})
|
||||
|
||||
const investmentTypes = ref<InvestmentType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
const typesError = ref<string | null>(null)
|
||||
const selectedMemberLabel = ref('')
|
||||
|
||||
const isEdit = computed(() => Boolean(props.initial))
|
||||
|
||||
const typeOptions = computed<SelectOption[]>(() =>
|
||||
investmentTypes.value
|
||||
.filter((type) => type.status === 'ACTIVE' && type.allow_salary_deduction)
|
||||
.map((type) => ({
|
||||
label: `${type.code} — ${type.name}`,
|
||||
value: type.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const typeCollection = computed(() =>
|
||||
select.collection({
|
||||
items: typeOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeInitial = computed(() => {
|
||||
const option = typeOptions.value.find((item) => item.value === form.investment_type_id)
|
||||
return option ? [option.label] : []
|
||||
})
|
||||
|
||||
const statusCollection = select.collection({
|
||||
items: SALARY_CONTRIBUTION_STATUS_OPTIONS as SelectOption[],
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
|
||||
const statusInitial = computed(() => {
|
||||
const option = SALARY_CONTRIBUTION_STATUS_OPTIONS.find((item) => item.value === form.status)
|
||||
return option ? [option.label] : ['Aktif']
|
||||
})
|
||||
|
||||
function setTypeValue(details: { value: string[] }) {
|
||||
form.investment_type_id =
|
||||
typeOptions.value.find((item) => item.label === details.value[0])?.value ?? ''
|
||||
}
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
form.status =
|
||||
SALARY_CONTRIBUTION_STATUS_OPTIONS.find((item) => item.label === details.value[0])?.value
|
||||
?? 'ACTIVE'
|
||||
}
|
||||
|
||||
function syncFromInitial(item: SalaryContribution | null | undefined) {
|
||||
if (!item) return
|
||||
|
||||
form.user_id = item.user_id
|
||||
form.investment_type_id = item.investment_type_id
|
||||
form.monthly_amount = String(item.monthly_amount)
|
||||
form.start_date = item.start_date
|
||||
form.end_date = item.end_date ?? ''
|
||||
form.status = item.status
|
||||
selectedMemberLabel.value = item.user
|
||||
? `${item.user.name}${item.user.ic_number ? ` (${item.user.ic_number})` : ''}`
|
||||
: ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initial,
|
||||
(value) => {
|
||||
syncFromInitial(value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadInvestmentTypes() {
|
||||
loadingTypes.value = true
|
||||
typesError.value = null
|
||||
|
||||
try {
|
||||
const data = await listInvestmentTypes({
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
investmentTypes.value = data.data
|
||||
} catch (err) {
|
||||
typesError.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||
investmentTypes.value = []
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const amount = Number(form.monthly_amount)
|
||||
emit('submit', {
|
||||
user_id: form.user_id,
|
||||
investment_type_id: form.investment_type_id,
|
||||
monthly_amount: Number.isNaN(amount) ? 0 : amount,
|
||||
start_date: form.start_date,
|
||||
end_date: form.end_date || null,
|
||||
status: form.status,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInvestmentTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<MemberSearchField
|
||||
v-model="form.user_id"
|
||||
:selected-label="selectedMemberLabel"
|
||||
:disabled="saving || isEdit"
|
||||
required
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan jenis pelaburan...</p>
|
||||
<SelectRoot
|
||||
v-else-if="typeOptions.length"
|
||||
:key="`${form.investment_type_id}-${typeOptions.map((item) => item.value).join(',')}`"
|
||||
class="w-full"
|
||||
:collection="typeCollection"
|
||||
:default-value="typeInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||
<SelectValueText placeholder="Pilih jenis (benarkan potongan gaji)" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in typeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<p v-if="typesError" class="mt-1 text-xs text-danger">{{ typesError }}</p>
|
||||
<p v-else-if="!loadingTypes && !typeOptions.length" class="mt-1 text-xs opacity-60">
|
||||
Tiada jenis pelaburan aktif yang membenarkan potongan gaji.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jumlah Bulanan (MYR)</FieldLabel>
|
||||
<Input
|
||||
v-model="form.monthly_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel required>Tarikh Mula</FieldLabel>
|
||||
<Input v-model="form.start_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Tarikh Tamat</FieldLabel>
|
||||
<Input v-model="form.end_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Status</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="statusCollection"
|
||||
:default-value="statusInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setStatusValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih status">
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in statusCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="emit('cancel')">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:disabled="
|
||||
saving ||
|
||||
!form.user_id ||
|
||||
!form.investment_type_id ||
|
||||
!form.monthly_amount ||
|
||||
Number(form.monthly_amount) <= 0 ||
|
||||
!form.start_date
|
||||
"
|
||||
>
|
||||
{{ saving ? 'Menyimpan...' : (submitLabel ?? 'Simpan') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import MemberSearchField from './MemberSearchField.vue'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import type { UserInvestmentLimit, UserInvestmentLimitPayload } from '../types/investment-limit.types'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import { toOptionalNumber } from '../utils/investment-type.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const props = defineProps<{
|
||||
initial?: UserInvestmentLimit | null
|
||||
saving?: boolean
|
||||
submitLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: UserInvestmentLimitPayload]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
user_id: '',
|
||||
investment_type_id: '',
|
||||
max_investment_amount: '',
|
||||
max_transaction_amount: '',
|
||||
effective_date: dayjs().format('YYYY-MM-DD'),
|
||||
expiry_date: '',
|
||||
})
|
||||
|
||||
const investmentTypes = ref<InvestmentType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
const typesError = ref<string | null>(null)
|
||||
const selectedMemberLabel = ref('')
|
||||
|
||||
const isEdit = computed(() => Boolean(props.initial))
|
||||
|
||||
const typeOptions = computed<SelectOption[]>(() =>
|
||||
investmentTypes.value
|
||||
.filter((type) => type.status === 'ACTIVE')
|
||||
.map((type) => ({
|
||||
label: `${type.code} — ${type.name}`,
|
||||
value: type.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const typeCollection = computed(() =>
|
||||
select.collection({
|
||||
items: typeOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeInitial = computed(() => {
|
||||
const option = typeOptions.value.find((item) => item.value === form.investment_type_id)
|
||||
return option ? [option.label] : []
|
||||
})
|
||||
|
||||
function setTypeValue(details: { value: string[] }) {
|
||||
const label = details.value[0]
|
||||
form.investment_type_id =
|
||||
typeOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function syncFromInitial(item: UserInvestmentLimit | null | undefined) {
|
||||
if (!item) return
|
||||
|
||||
form.user_id = item.user_id
|
||||
form.investment_type_id = item.investment_type_id
|
||||
form.max_investment_amount =
|
||||
item.max_investment_amount == null ? '' : String(item.max_investment_amount)
|
||||
form.max_transaction_amount =
|
||||
item.max_transaction_amount == null ? '' : String(item.max_transaction_amount)
|
||||
form.effective_date = item.effective_date
|
||||
form.expiry_date = item.expiry_date ?? ''
|
||||
selectedMemberLabel.value = item.user
|
||||
? `${item.user.name}${item.user.ic_number ? ` (${item.user.ic_number})` : ''}`
|
||||
: ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initial,
|
||||
(value) => {
|
||||
syncFromInitial(value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadInvestmentTypes() {
|
||||
loadingTypes.value = true
|
||||
typesError.value = null
|
||||
|
||||
try {
|
||||
const data = await listInvestmentTypes({
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
investmentTypes.value = data.data
|
||||
} catch (err) {
|
||||
typesError.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||
investmentTypes.value = []
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
emit('submit', {
|
||||
user_id: form.user_id,
|
||||
investment_type_id: form.investment_type_id,
|
||||
max_investment_amount: toOptionalNumber(form.max_investment_amount),
|
||||
max_transaction_amount: toOptionalNumber(form.max_transaction_amount),
|
||||
effective_date: form.effective_date,
|
||||
expiry_date: form.expiry_date || null,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInvestmentTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<MemberSearchField
|
||||
v-model="form.user_id"
|
||||
:selected-label="selectedMemberLabel"
|
||||
:disabled="saving || isEdit"
|
||||
required
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan jenis pelaburan...</p>
|
||||
<SelectRoot
|
||||
v-else-if="typeOptions.length"
|
||||
:key="`${form.investment_type_id}-${typeOptions.map((item) => item.value).join(',')}`"
|
||||
class="w-full"
|
||||
:collection="typeCollection"
|
||||
:default-value="typeInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in typeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<p v-if="typesError" class="mt-1 text-xs text-danger">{{ typesError }}</p>
|
||||
<p v-else-if="!loadingTypes && !typeOptions.length" class="mt-1 text-xs opacity-60">
|
||||
Tiada jenis pelaburan aktif.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Had Kumulatif (MYR)</FieldLabel>
|
||||
<Input
|
||||
v-model="form.max_investment_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
:disabled="saving"
|
||||
placeholder="Kosong = tiada had"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Had Transaksi (MYR)</FieldLabel>
|
||||
<Input
|
||||
v-model="form.max_transaction_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
:disabled="saving"
|
||||
placeholder="Kosong = tiada had"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel required>Tarikh Berkuat Kuasa</FieldLabel>
|
||||
<Input v-model="form.effective_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Tarikh Tamat</FieldLabel>
|
||||
<Input v-model="form.expiry_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="emit('cancel')">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:disabled="
|
||||
saving ||
|
||||
!form.user_id ||
|
||||
!form.investment_type_id ||
|
||||
!form.effective_date
|
||||
"
|
||||
>
|
||||
{{ saving ? 'Menyimpan...' : (submitLabel ?? 'Simpan') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listInvestmentLimitProfiles } from '../services/investment-limit.service'
|
||||
import type { InvestmentLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
export function useInvestmentLimitProfileList() {
|
||||
const items = ref<InvestmentLimitProfile[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listInvestmentLimitProfiles({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'name',
|
||||
sort_order: activeSort?.order ?? 'asc',
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil had pelaburan.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchItems(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchItems(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listInvestments } from '../services/investment.service'
|
||||
import type {
|
||||
Investment,
|
||||
InvestmentSource,
|
||||
InvestmentStatus,
|
||||
} from '../types/investment.types'
|
||||
|
||||
export function useInvestmentList() {
|
||||
const items = ref<Investment[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const statusFilter = ref<InvestmentStatus | ''>('')
|
||||
const sourceFilter = ref<InvestmentSource | ''>('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listInvestments({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'created_at',
|
||||
sort_order: activeSort?.order ?? 'desc',
|
||||
search: search.value.trim() || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
source: sourceFilter.value || undefined,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pelaburan.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchItems(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchItems(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch([statusFilter, sourceFilter], () => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sourceFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
|
||||
export function useInvestmentTypeList() {
|
||||
const items = ref<InvestmentType[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(12)
|
||||
const sortBy = ref('name')
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 12 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listInvestmentTypes({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value,
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai jenis pelaburan.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchItems(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
pagination,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Icon } from '@/components/ui/lucide'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getMemberInvestmentSummary } from '../services/investment.service'
|
||||
import type { MemberInvestmentSummary } from '../types/investment.types'
|
||||
import { formatInvestmentAmount } from '../utils/investment.utils'
|
||||
|
||||
const CHART_COLOR_TOKENS = [
|
||||
'--color-primary',
|
||||
'--color-pending',
|
||||
'--color-warning',
|
||||
'--color-success',
|
||||
'--color-danger',
|
||||
] as const
|
||||
|
||||
export function useMemberInvestmentSummary(options?: { immediate?: boolean }) {
|
||||
const immediate = options?.immediate ?? true
|
||||
|
||||
const summary = ref<MemberInvestmentSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const summaryCards = computed(() => {
|
||||
const data = summary.value
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'current',
|
||||
label: 'Baki Semasa',
|
||||
value: formatInvestmentAmount(data?.total_current_balance),
|
||||
hint: `${data?.account_count ?? 0} akaun pelaburan`,
|
||||
icon: 'Wallet' as Icon,
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: 'Dalam Proses',
|
||||
value: formatInvestmentAmount(data?.total_pending_balance),
|
||||
hint: `${data?.pending_count ?? 0} transaksi menunggu`,
|
||||
icon: 'Hourglass' as Icon,
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
label: 'Transaksi Selesai',
|
||||
value: String(data?.completed_count ?? 0),
|
||||
hint: `${data?.transaction_count ?? 0} jumlah transaksi`,
|
||||
icon: 'CircleCheck' as Icon,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const balanceByTypeChart = computed(() => {
|
||||
const accounts = summary.value?.accounts ?? []
|
||||
const total = accounts.reduce(
|
||||
(sum, account) => sum + (Number(account.current_balance) || 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const segments = accounts.map((account, index) => {
|
||||
const value = Number(account.current_balance) || 0
|
||||
const color = CHART_COLOR_TOKENS[index % CHART_COLOR_TOKENS.length]
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
label: account.investment_type?.name ?? 'Jenis tidak diketahui',
|
||||
code: account.investment_type?.code ?? '-',
|
||||
value,
|
||||
color,
|
||||
percent: total > 0 ? Math.round((value / total) * 100) : 0,
|
||||
formatted: formatInvestmentAmount(account.current_balance),
|
||||
pendingFormatted: formatInvestmentAmount(account.pending_balance),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels: segments.map((segment) => segment.label),
|
||||
values: segments.map((segment) => segment.value),
|
||||
colors: segments.map((segment) => segment.color),
|
||||
segments,
|
||||
total,
|
||||
hasData: segments.some((segment) => segment.value > 0),
|
||||
chartKey: segments.map((segment) => `${segment.id}:${segment.value}`).join('|'),
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchSummary() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
summary.value = await getMemberInvestmentSummary()
|
||||
} catch (err) {
|
||||
summary.value = null
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan ringkasan pelaburan.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
onMounted(() => {
|
||||
void fetchSummary()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
loading,
|
||||
error,
|
||||
summaryCards,
|
||||
balanceByTypeChart,
|
||||
fetchSummary,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listSalaryContributions } from '../services/salary.service'
|
||||
import type {
|
||||
SalaryContribution,
|
||||
SalaryContributionStatus,
|
||||
} from '../types/salary.types'
|
||||
|
||||
export function useSalaryContributionList() {
|
||||
const items = ref<SalaryContribution[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const statusFilter = ref<SalaryContributionStatus | ''>('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listSalaryContributions({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'created_at',
|
||||
sort_order: activeSort?.order ?? 'desc',
|
||||
search: search.value.trim() || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan caruman potongan gaji.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchItems(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchItems(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listSalaryDeductionBatches } from '../services/salary.service'
|
||||
import type {
|
||||
SalaryDeductionBatch,
|
||||
SalaryDeductionBatchStatus,
|
||||
} from '../types/salary.types'
|
||||
|
||||
export function useSalaryDeductionBatchList() {
|
||||
const items = ref<SalaryDeductionBatch[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const statusFilter = ref<SalaryDeductionBatchStatus | ''>('')
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listSalaryDeductionBatches({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan batch potongan gaji.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(statusFilter, () => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
statusFilter,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUserInvestmentLimits } from '../services/investment-limit.service'
|
||||
import type { UserInvestmentLimit } from '../types/investment-limit.types'
|
||||
|
||||
export function useUserInvestmentLimitList() {
|
||||
const items = ref<UserInvestmentLimit[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listUserInvestmentLimits({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'created_at',
|
||||
sort_order: activeSort?.order ?? 'desc',
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan had pelaburan ahli.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchItems(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchItems(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUserLimitProfiles } from '../services/investment-limit.service'
|
||||
import type { UserLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
export function useUserLimitProfileList() {
|
||||
const items = ref<UserLimitProfile[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const activeOnly = ref(true)
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listUserLimitProfiles({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
active_only: activeOnly.value ? 1 : 0,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan tugasan profil had.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeOnly, () => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
activeOnly,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { investmentLayoutRoutes } from './routes'
|
||||
export { investmentMenu } from './menu'
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const investmentMenu: Menu[] = [
|
||||
{
|
||||
icon: 'Wallet',
|
||||
route_name: 'list-investments',
|
||||
title: 'Pelaburan',
|
||||
permission: 'lihat pelaburan',
|
||||
},
|
||||
{
|
||||
icon: 'PiggyBank',
|
||||
route_name: 'list-investment-types',
|
||||
title: 'Jenis Pelaburan',
|
||||
permission: 'lihat jenis pelaburan',
|
||||
},
|
||||
{
|
||||
icon: 'Shield',
|
||||
route_name: 'list-investment-limit-profiles',
|
||||
title: 'Profil Had Pelaburan',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
{
|
||||
icon: 'UserCog',
|
||||
route_name: 'list-user-limit-profiles',
|
||||
title: 'Tugasan Profil Had',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
{
|
||||
icon: 'SlidersHorizontal',
|
||||
route_name: 'list-user-investment-limits',
|
||||
title: 'Had Pelaburan Ahli',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
{
|
||||
icon: 'Banknote',
|
||||
route_name: 'list-salary-contributions',
|
||||
title: 'Caruman Potongan Gaji',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
{
|
||||
icon: 'ClipboardList',
|
||||
route_name: 'list-salary-deduction-batches',
|
||||
title: 'Batch Potongan Gaji',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,412 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Download, Eye } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
downloadInvestmentDocument,
|
||||
fetchInvestmentDocument,
|
||||
getInvestment,
|
||||
} from '../services/investment.service'
|
||||
import type { Investment, InvestmentDocument } from '../types/investment.types'
|
||||
import {
|
||||
formatInvestmentAmount,
|
||||
investmentSourceLabel,
|
||||
investmentStatusLabel,
|
||||
investmentStatusVariant,
|
||||
} from '../utils/investment.utils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const investment = ref<Investment | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const documentPreviewOpen = ref(false)
|
||||
const previewDocument = ref<InvestmentDocument | null>(null)
|
||||
const documentPreviewUrl = ref<string | null>(null)
|
||||
const documentPreviewLoading = ref(false)
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
|
||||
const documents = computed(() => investment.value?.documents ?? [])
|
||||
|
||||
const isDocumentPreviewImage = computed(
|
||||
() => previewDocument.value?.mime_type?.startsWith('image/') ?? false,
|
||||
)
|
||||
|
||||
const isDocumentPreviewPdf = computed(
|
||||
() =>
|
||||
previewDocument.value?.mime_type === 'application/pdf' ||
|
||||
(previewDocument.value?.name.toLowerCase().endsWith('.pdf') ?? false),
|
||||
)
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function revokeDocumentPreviewUrl() {
|
||||
if (documentPreviewUrl.value) {
|
||||
window.URL.revokeObjectURL(documentPreviewUrl.value)
|
||||
documentPreviewUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInvestment() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
investment.value = await getInvestment(String(route.params.id))
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan pelaburan.')
|
||||
investment.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: InvestmentDocument) {
|
||||
if (!investment.value || downloadingDocumentId.value) return
|
||||
|
||||
downloadingDocumentId.value = document.id
|
||||
|
||||
try {
|
||||
await downloadInvestmentDocument(
|
||||
investment.value.id,
|
||||
document.id,
|
||||
document.name,
|
||||
document.mime_type,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat turun dokumen.')
|
||||
} finally {
|
||||
downloadingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDocument(document: InvestmentDocument) {
|
||||
if (!investment.value) return
|
||||
|
||||
previewDocument.value = document
|
||||
documentPreviewOpen.value = true
|
||||
documentPreviewLoading.value = true
|
||||
revokeDocumentPreviewUrl()
|
||||
|
||||
try {
|
||||
const blob = await fetchInvestmentDocument(
|
||||
investment.value.id,
|
||||
document.id,
|
||||
document.mime_type,
|
||||
)
|
||||
documentPreviewUrl.value = window.URL.createObjectURL(blob)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan pratonton dokumen.')
|
||||
documentPreviewOpen.value = false
|
||||
} finally {
|
||||
documentPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocumentPreview() {
|
||||
documentPreviewOpen.value = false
|
||||
previewDocument.value = null
|
||||
revokeDocumentPreviewUrl()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-investments' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInvestment()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeDocumentPreviewUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Butiran Pelaburan</h2>
|
||||
<p v-if="investment" class="mt-1 text-sm opacity-70">
|
||||
{{ investment.reference_no }}
|
||||
</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="loading" @click="goBack">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||
|
||||
<template v-else-if="investment">
|
||||
<Box class="space-y-6 p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm opacity-70">Jumlah</div>
|
||||
<div class="mt-1 text-2xl font-medium">
|
||||
{{ formatInvestmentAmount(investment.amount) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge look="outline" :variant="investmentStatusVariant(investment.status)">
|
||||
{{ investmentStatusLabel(investment.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Ahli</div>
|
||||
<div class="mt-1 font-medium">{{ investment.user?.name ?? '-' }}</div>
|
||||
<div class="text-sm opacity-70">{{ investment.user?.ic_number ?? '' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Jenis Pelaburan</div>
|
||||
<div class="mt-1 font-medium">{{ investment.investment_type?.name ?? '-' }}</div>
|
||||
<div class="text-sm uppercase opacity-70">
|
||||
{{ investment.investment_type?.code ?? '' }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Sumber</div>
|
||||
<div class="mt-1 font-medium">{{ investmentSourceLabel(investment.source) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Dicipta</div>
|
||||
<div class="mt-1">{{ formatDate(investment.created_at) }}</div>
|
||||
<div v-if="investment.created_by_user" class="text-sm opacity-70">
|
||||
oleh {{ investment.created_by_user.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Diluluskan</div>
|
||||
<div class="mt-1">{{ formatDate(investment.approved_at) }}</div>
|
||||
<div v-if="investment.approved_by_user" class="text-sm opacity-70">
|
||||
oleh {{ investment.approved_by_user.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Selesai</div>
|
||||
<div class="mt-1">{{ formatDate(investment.completed_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="investment.remarks">
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Catatan</div>
|
||||
<div class="mt-1 whitespace-pre-wrap">{{ investment.remarks }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="investment.account">
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Baki Akaun</div>
|
||||
<div class="mt-1 text-sm">
|
||||
Semasa: {{ formatInvestmentAmount(investment.account.current_balance) }}
|
||||
· Pending: {{ formatInvestmentAmount(investment.account.pending_balance) }}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="p-6">
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
{{ documents.length }} dokumen · muat turun atau lihat
|
||||
</p>
|
||||
|
||||
<div v-if="!documents.length" class="mt-5 opacity-70">
|
||||
Tiada dokumen lampiran.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-3">
|
||||
<div
|
||||
v-for="document in documents"
|
||||
:key="document.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">{{ document.name }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">
|
||||
{{ formatFileSize(document.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="documentPreviewLoading && previewDocument?.id === document.id"
|
||||
@click="handleViewDocument(document)"
|
||||
>
|
||||
<Eye class="mr-2 size-4" />
|
||||
{{
|
||||
documentPreviewLoading && previewDocument?.id === document.id
|
||||
? 'Memuatkan...'
|
||||
: 'Lihat'
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="downloadingDocumentId === document.id"
|
||||
@click="handleDownloadDocument(document)"
|
||||
>
|
||||
<Download class="mr-2 size-4" />
|
||||
{{
|
||||
downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="investment.ledger_entries?.length" class="p-6">
|
||||
<h3 class="text-base font-medium">Entri Ledger</h3>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
|
||||
<tr>
|
||||
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||
<th class="py-2 pr-4 font-medium">Kredit</th>
|
||||
<th class="py-2 pr-4 font-medium">Debit</th>
|
||||
<th class="py-2 pr-4 font-medium">Baki</th>
|
||||
<th class="py-2 font-medium">Tarikh</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="entry in investment.ledger_entries"
|
||||
:key="entry.id"
|
||||
class="border-b border-foreground/5"
|
||||
>
|
||||
<td class="py-2 pr-4">{{ entry.transaction_type }}</td>
|
||||
<td class="py-2 pr-4">{{ formatInvestmentAmount(entry.credit) }}</td>
|
||||
<td class="py-2 pr-4">{{ formatInvestmentAmount(entry.debit) }}</td>
|
||||
<td class="py-2 pr-4">{{ formatInvestmentAmount(entry.balance_after) }}</td>
|
||||
<td class="py-2">{{ formatDate(entry.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="investment.payment" class="p-6">
|
||||
<h3 class="text-base font-medium">Pembayaran</h3>
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 text-sm">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Gateway</div>
|
||||
<div class="mt-1">{{ investment.payment.gateway }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Status</div>
|
||||
<div class="mt-1">{{ investment.payment.status }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Dibayar</div>
|
||||
<div class="mt-1">{{ formatDate(investment.payment.paid_at) }}</div>
|
||||
</div>
|
||||
<div v-if="investment.payment.billplz?.bill_id">
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Billplz Bill ID</div>
|
||||
<div class="mt-1">{{ investment.payment.billplz.bill_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="documentPreviewOpen"
|
||||
class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/80"
|
||||
aria-label="Tutup pratonton"
|
||||
@click="closeDocumentPreview"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-10 flex h-[min(90vh,920px)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-2xl"
|
||||
>
|
||||
<div class="shrink-0 border-b border-foreground/10 px-5 py-4">
|
||||
<div class="text-lg font-medium">
|
||||
{{ previewDocument?.name ?? 'Pratonton Dokumen' }}
|
||||
</div>
|
||||
<div v-if="previewDocument" class="mt-1 text-sm opacity-70">
|
||||
{{ formatFileSize(previewDocument.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto p-5">
|
||||
<div v-if="documentPreviewLoading" class="flex h-full items-center justify-center opacity-70">
|
||||
Memuatkan dokumen...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="documentPreviewUrl && isDocumentPreviewImage"
|
||||
class="flex h-full items-center justify-center"
|
||||
>
|
||||
<img
|
||||
:src="documentPreviewUrl"
|
||||
:alt="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
class="block h-auto max-h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
v-else-if="documentPreviewUrl && isDocumentPreviewPdf"
|
||||
:src="documentPreviewUrl"
|
||||
class="block h-full w-full rounded-lg border border-foreground/10"
|
||||
:title="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
/>
|
||||
|
||||
<div v-else-if="documentPreviewUrl" class="flex h-full items-center justify-center opacity-70">
|
||||
Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-foreground/10 px-5 py-4">
|
||||
<Button type="button" look="outline" @click="closeDocumentPreview">
|
||||
Tutup
|
||||
</Button>
|
||||
<Button
|
||||
v-if="previewDocument"
|
||||
type="button"
|
||||
look="outline"
|
||||
:disabled="downloadingDocumentId === previewDocument.id"
|
||||
@click="handleDownloadDocument(previewDocument)"
|
||||
>
|
||||
<Download class="mr-2 size-4" />
|
||||
{{
|
||||
downloadingDocumentId === previewDocument.id ? 'Memuat turun...' : 'Muat Turun'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { createInvestmentLimitProfile } from '../services/investment-limit.service'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const profile = await createInvestmentLimitProfile({
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
})
|
||||
router.push({ name: 'view-investment-limit-profile', params: { id: profile.id } })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menambah profil had.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-investment-limit-profiles' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Tambah Profil Had</h2>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box class="p-6">
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<Field>
|
||||
<FieldLabel required>Nama</FieldLabel>
|
||||
<Input v-model="form.name" :disabled="saving" placeholder="cth. Ahli Biasa" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Keterangan</FieldLabel>
|
||||
<Textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
:disabled="saving"
|
||||
placeholder="Keterangan pilihan..."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="saving || !form.name.trim()">
|
||||
{{ saving ? 'Menyimpan...' : 'Tambah & Urus Had' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,512 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Pencil, Trash2 } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import {
|
||||
createProfileInvestmentLimit,
|
||||
deleteProfileInvestmentLimit,
|
||||
getInvestmentLimitProfile,
|
||||
updateInvestmentLimitProfile,
|
||||
updateProfileInvestmentLimit,
|
||||
} from '../services/investment-limit.service'
|
||||
import type {
|
||||
InvestmentLimitProfile,
|
||||
ProfileInvestmentLimit,
|
||||
} from '../types/investment-limit.types'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import { formatInvestmentAmount, toOptionalNumber } from '../utils/investment-type.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canUpdate = computed(() => hasPermission('kemaskini had pelaburan'))
|
||||
const canCreate = computed(() => hasPermission('tambah had pelaburan'))
|
||||
const canDelete = computed(() => hasPermission('hapus had pelaburan'))
|
||||
|
||||
const profile = ref<InvestmentLimitProfile | null>(null)
|
||||
const limits = ref<ProfileInvestmentLimit[]>([])
|
||||
const investmentTypes = ref<InvestmentType[]>([])
|
||||
|
||||
const loading = ref(true)
|
||||
const savingProfile = ref(false)
|
||||
const savingLimit = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const profileForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const limitDialogOpen = ref(false)
|
||||
const editingLimit = ref<ProfileInvestmentLimit | null>(null)
|
||||
const limitForm = reactive({
|
||||
investment_type_id: '',
|
||||
max_investment_amount: '',
|
||||
max_transaction_amount: '',
|
||||
})
|
||||
const limitError = ref<string | null>(null)
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const limitToDelete = ref<ProfileInvestmentLimit | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const typeOptions = computed<SelectOption[]>(() => {
|
||||
const usedIds = new Set(
|
||||
limits.value
|
||||
.filter((limit) => limit.id !== editingLimit.value?.id)
|
||||
.map((limit) => limit.investment_type_id),
|
||||
)
|
||||
|
||||
return investmentTypes.value
|
||||
.filter((type) => type.status === 'ACTIVE' && !usedIds.has(type.id))
|
||||
.map((type) => ({
|
||||
label: `${type.code} — ${type.name}`,
|
||||
value: type.id,
|
||||
}))
|
||||
})
|
||||
|
||||
const typeCollection = computed(() =>
|
||||
select.collection({
|
||||
items: typeOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeInitial = computed(() => {
|
||||
const option = typeOptions.value.find((item) => item.value === limitForm.investment_type_id)
|
||||
if (option) return [option.label]
|
||||
|
||||
// Keep current type visible when editing even if already "used"
|
||||
if (editingLimit.value?.investment_type) {
|
||||
return [
|
||||
`${editingLimit.value.investment_type.code} — ${editingLimit.value.investment_type.name}`,
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const editTypeOptions = computed<SelectOption[]>(() => {
|
||||
if (!editingLimit.value?.investment_type) return typeOptions.value
|
||||
|
||||
const current = {
|
||||
label: `${editingLimit.value.investment_type.code} — ${editingLimit.value.investment_type.name}`,
|
||||
value: editingLimit.value.investment_type_id,
|
||||
}
|
||||
|
||||
if (typeOptions.value.some((item) => item.value === current.value)) {
|
||||
return typeOptions.value
|
||||
}
|
||||
|
||||
return [current, ...typeOptions.value]
|
||||
})
|
||||
|
||||
const editTypeCollection = computed(() =>
|
||||
select.collection({
|
||||
items: editTypeOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
function setTypeValue(details: { value: string[] }) {
|
||||
const label = details.value[0]
|
||||
limitForm.investment_type_id =
|
||||
editTypeOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const [profileData, typesData] = await Promise.all([
|
||||
getInvestmentLimitProfile(String(route.params.id)),
|
||||
listInvestmentTypes({ page: 1, per_page: 100, sort_by: 'name', sort_order: 'asc' }),
|
||||
])
|
||||
|
||||
profile.value = profileData
|
||||
profileForm.name = profileData.name
|
||||
profileForm.description = profileData.description ?? ''
|
||||
limits.value = profileData.profile_limits ?? []
|
||||
investmentTypes.value = typesData.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil had.')
|
||||
profile.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!profile.value || !canUpdate.value) return
|
||||
|
||||
savingProfile.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const updated = await updateInvestmentLimitProfile(profile.value.id, {
|
||||
name: profileForm.name.trim(),
|
||||
description: profileForm.description.trim() || null,
|
||||
})
|
||||
profile.value = { ...profile.value, ...updated }
|
||||
successMessage.value = 'Profil had berjaya dikemaskini.'
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini profil had.')
|
||||
} finally {
|
||||
savingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateLimit() {
|
||||
editingLimit.value = null
|
||||
limitForm.investment_type_id = ''
|
||||
limitForm.max_investment_amount = ''
|
||||
limitForm.max_transaction_amount = ''
|
||||
limitError.value = null
|
||||
limitDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEditLimit(limit: ProfileInvestmentLimit) {
|
||||
editingLimit.value = limit
|
||||
limitForm.investment_type_id = limit.investment_type_id
|
||||
limitForm.max_investment_amount =
|
||||
limit.max_investment_amount == null ? '' : String(limit.max_investment_amount)
|
||||
limitForm.max_transaction_amount =
|
||||
limit.max_transaction_amount == null ? '' : String(limit.max_transaction_amount)
|
||||
limitError.value = null
|
||||
limitDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function saveLimit() {
|
||||
if (!profile.value) return
|
||||
|
||||
savingLimit.value = true
|
||||
limitError.value = null
|
||||
|
||||
const payload = {
|
||||
investment_type_id: limitForm.investment_type_id,
|
||||
max_investment_amount: toOptionalNumber(limitForm.max_investment_amount),
|
||||
max_transaction_amount: toOptionalNumber(limitForm.max_transaction_amount),
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingLimit.value) {
|
||||
await updateProfileInvestmentLimit(profile.value.id, editingLimit.value.id, payload)
|
||||
} else {
|
||||
await createProfileInvestmentLimit(profile.value.id, payload)
|
||||
}
|
||||
|
||||
limitDialogOpen.value = false
|
||||
await loadAll()
|
||||
successMessage.value = editingLimit.value
|
||||
? 'Had profil berjaya dikemaskini.'
|
||||
: 'Had profil berjaya ditambah.'
|
||||
} catch (err) {
|
||||
limitError.value = getApiErrorMessage(err, 'Gagal menyimpan had profil.')
|
||||
} finally {
|
||||
savingLimit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteLimit(limit: ProfileInvestmentLimit) {
|
||||
limitToDelete.value = limit
|
||||
deleteError.value = null
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDeleteLimit() {
|
||||
if (!profile.value || !limitToDelete.value || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteProfileInvestmentLimit(profile.value.id, limitToDelete.value.id)
|
||||
deleteOpen.value = false
|
||||
limitToDelete.value = null
|
||||
await loadAll()
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus had profil.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-investment-limit-profiles' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Urus Profil Had</h2>
|
||||
<p v-if="profile" class="mt-1 text-sm opacity-70">{{ profile.name }}</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="loading" @click="goBack">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||
|
||||
<template v-else-if="profile">
|
||||
<Box class="p-6">
|
||||
<h3 class="text-base font-medium">Maklumat Profil</h3>
|
||||
<form class="mt-4 space-y-4" @submit.prevent="saveProfile">
|
||||
<Field>
|
||||
<FieldLabel required>Nama</FieldLabel>
|
||||
<Input v-model="profileForm.name" :disabled="savingProfile || !canUpdate" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Keterangan</FieldLabel>
|
||||
<Textarea
|
||||
v-model="profileForm.description"
|
||||
rows="3"
|
||||
:disabled="savingProfile || !canUpdate"
|
||||
/>
|
||||
</Field>
|
||||
<div v-if="canUpdate" class="flex justify-end">
|
||||
<Button type="submit" variant="primary" :disabled="savingProfile || !profileForm.name.trim()">
|
||||
{{ savingProfile ? 'Menyimpan...' : 'Simpan Profil' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
<Box class="p-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h3 class="text-base font-medium">Had Mengikut Jenis Pelaburan</h3>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Had kumulatif dan had setiap transaksi untuk produk dalam profil ini.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="canCreate"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
type="button"
|
||||
@click="openCreateLimit"
|
||||
>
|
||||
Tambah Had
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
|
||||
<tr>
|
||||
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||
<th class="py-2 pr-4 font-medium">Had Kumulatif</th>
|
||||
<th class="py-2 pr-4 font-medium">Had Transaksi</th>
|
||||
<th class="py-2 font-medium">Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="limit in limits" :key="limit.id" class="border-b border-foreground/5">
|
||||
<td class="py-3 pr-4">
|
||||
<div class="font-medium">{{ limit.investment_type?.name ?? '-' }}</div>
|
||||
<div class="text-xs uppercase opacity-60">
|
||||
{{ limit.investment_type?.code ?? '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
{{ formatInvestmentAmount(limit.max_investment_amount) }}
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
{{ formatInvestmentAmount(limit.max_transaction_amount) }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canUpdate"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Edit"
|
||||
@click="openEditLimit(limit)"
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-danger"
|
||||
title="Hapus"
|
||||
@click="openDeleteLimit(limit)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!limits.length">
|
||||
<td colspan="4" class="py-6 text-center opacity-60">
|
||||
Tiada had ditetapkan untuk profil ini.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<DialogRoot
|
||||
:open="limitDialogOpen"
|
||||
:close-on-interact-outside="false"
|
||||
@openChange="(details) => (limitDialogOpen = details.open)"
|
||||
>
|
||||
<DialogContent>
|
||||
<div class="space-y-4 p-5">
|
||||
<div class="text-xl font-medium">
|
||||
{{ editingLimit ? 'Kemaskini Had' : 'Tambah Had' }}
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="limitError" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ limitError }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||
<!-- Mount only while open so zag select gets the current collection -->
|
||||
<SelectRoot
|
||||
v-if="limitDialogOpen && editTypeOptions.length"
|
||||
:key="`limit-type-${editingLimit?.id ?? 'new'}-${editTypeOptions.map((i) => i.value).join(',')}`"
|
||||
class="w-full"
|
||||
:collection="editTypeCollection"
|
||||
:default-value="typeInitial"
|
||||
:disabled="savingLimit"
|
||||
@value-change="setTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in editTypeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<p v-else-if="limitDialogOpen" class="text-sm opacity-60">
|
||||
Tiada jenis pelaburan aktif yang tersedia.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Had Kumulatif (MYR)</FieldLabel>
|
||||
<Input
|
||||
v-model="limitForm.max_investment_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
:disabled="savingLimit"
|
||||
placeholder="Kosong = tiada had"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Had Transaksi (MYR)</FieldLabel>
|
||||
<Input
|
||||
v-model="limitForm.max_transaction_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
:disabled="savingLimit"
|
||||
placeholder="Kosong = tiada had"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 px-5 pb-5">
|
||||
<DialogCloseTrigger :disabled="savingLimit">Batal</DialogCloseTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="savingLimit || !limitForm.investment_type_id"
|
||||
@click="saveLimit"
|
||||
>
|
||||
{{ savingLimit ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-5 text-2xl font-medium">Hapus had ini?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
{{ limitToDelete?.investment_type?.name ?? 'Had profil' }} akan dipadam.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">{{ deleteError }}</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deleting"
|
||||
@click="confirmDeleteLimit"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Trash2 } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { useInvestmentLimitProfileList } from '../composables/useInvestmentLimitProfileList'
|
||||
import { deleteInvestmentLimitProfile } from '../services/investment-limit.service'
|
||||
import type { InvestmentLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreate = computed(() => hasPermission('tambah had pelaburan'))
|
||||
const canView = computed(() => hasPermission('lihat had pelaburan'))
|
||||
const canDelete = computed(() => hasPermission('hapus had pelaburan'))
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const itemToDelete = ref<InvestmentLimitProfile | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
} = useInvestmentLimitProfileList()
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Nama', key: 'name', sortable: true },
|
||||
{ title: 'Keterangan', key: 'description', sortable: false },
|
||||
{ title: 'Bil. Had', key: 'profile_limits_count', sortable: false },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-investment-limit-profile' })
|
||||
}
|
||||
|
||||
function goToDetail(id: string) {
|
||||
router.push({ name: 'view-investment-limit-profile', params: { id } })
|
||||
}
|
||||
|
||||
function openDelete(item: InvestmentLimitProfile) {
|
||||
itemToDelete.value = item
|
||||
deleteError.value = null
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!itemToDelete.value || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteInvestmentLimitProfile(itemToDelete.value.id)
|
||||
deleteOpen.value = false
|
||||
itemToDelete.value = null
|
||||
await fetchItems(page.value)
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus profil had.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Profil Had Pelaburan</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Tetapkan had kumulatif dan had transaksi mengikut kumpulan ahli.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canCreate"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="loading"
|
||||
@click="goToCreate"
|
||||
>
|
||||
Tambah Profil
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:current-sort="sortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="profil-had-pelaburan"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate"
|
||||
>
|
||||
<template #toolbar>
|
||||
<Input
|
||||
v-model="search"
|
||||
class="w-full max-w-md"
|
||||
type="search"
|
||||
placeholder="Cari nama atau keterangan..."
|
||||
aria-label="Cari profil had"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item.description="{ item }">
|
||||
<span class="line-clamp-2">
|
||||
{{ (item as InvestmentLimitProfile).description || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.profile_limits_count="{ item }">
|
||||
{{ (item as InvestmentLimitProfile).profile_limits_count ?? 0 }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canView"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-green-600 text-white"
|
||||
title="Lihat / urus"
|
||||
@click="goToDetail((item as InvestmentLimitProfile).id)"
|
||||
>
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-danger text-white"
|
||||
title="Hapus"
|
||||
@click="openDelete(item as InvestmentLimitProfile)"
|
||||
>
|
||||
<Trash2 class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-5 text-2xl font-medium">Hapus profil had?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Hapus
|
||||
<span v-if="itemToDelete" class="font-medium">{{ itemToDelete.name }}</span>?
|
||||
Profil yang masih ditugaskan kepada ahli tidak boleh dipadam.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">{{ deleteError }}</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useInvestmentList } from '../composables/useInvestmentList'
|
||||
import type { Investment, InvestmentSource, InvestmentStatus } from '../types/investment.types'
|
||||
import {
|
||||
INVESTMENT_SOURCE_OPTIONS,
|
||||
INVESTMENT_STATUS_OPTIONS,
|
||||
formatInvestmentAmount,
|
||||
investmentSourceLabel,
|
||||
investmentStatusLabel,
|
||||
investmentStatusVariant,
|
||||
} from '../utils/investment.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Status', value: '' },
|
||||
...INVESTMENT_STATUS_OPTIONS,
|
||||
]
|
||||
|
||||
const SOURCE_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Sumber', value: '' },
|
||||
...INVESTMENT_SOURCE_OPTIONS,
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [options[0]?.label ?? '']
|
||||
}
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Rujukan', key: 'reference_no', sortable: true },
|
||||
{ title: 'Ahli', key: 'user', sortable: false },
|
||||
{ title: 'Jenis', key: 'investment_type', sortable: false },
|
||||
{ title: 'Jumlah', key: 'amount', sortable: true },
|
||||
{ title: 'Sumber', key: 'source', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: 'Tarikh', key: 'created_at', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||
const sourceCollection = createSelectCollection(SOURCE_FILTER_OPTIONS)
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sourceFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
} = useInvestmentList()
|
||||
|
||||
const canCreateManual = computed(() => hasPermission('tambah pelaburan manual'))
|
||||
const canView = computed(() => hasPermission('lihat pelaburan'))
|
||||
|
||||
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||
const sourceInitial = computed(() => valueToLabel(SOURCE_FILTER_OPTIONS, sourceFilter.value))
|
||||
|
||||
function setStatusFilter(details: { value: string[] }) {
|
||||
statusFilter.value = labelToValue(STATUS_FILTER_OPTIONS, details.value[0]) as InvestmentStatus | ''
|
||||
}
|
||||
|
||||
function setSourceFilter(details: { value: string[] }) {
|
||||
sourceFilter.value = labelToValue(SOURCE_FILTER_OPTIONS, details.value[0]) as InvestmentSource | ''
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function goToDetail(id: string) {
|
||||
router.push({ name: 'view-investment', params: { id } })
|
||||
}
|
||||
|
||||
function goToCreateManual() {
|
||||
router.push({ name: 'create-manual-investment' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Pelaburan</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Senarai transaksi pelaburan ahli.</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canCreateManual"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="loading"
|
||||
@click="goToCreateManual"
|
||||
>
|
||||
Rekod Pelaburan Manual
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:current-sort="sortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="pelaburan"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model="search"
|
||||
class="w-full max-w-md"
|
||||
type="search"
|
||||
placeholder="Cari rujukan, ahli, jenis..."
|
||||
aria-label="Cari pelaburan"
|
||||
/>
|
||||
|
||||
<SelectRoot
|
||||
class="w-full sm:w-52"
|
||||
:collection="statusCollection"
|
||||
:default-value="statusInitial"
|
||||
@value-change="setStatusFilter"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis status">
|
||||
<SelectValueText placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in statusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
|
||||
<SelectRoot
|
||||
class="w-full sm:w-52"
|
||||
:collection="sourceCollection"
|
||||
:default-value="sourceInitial"
|
||||
@value-change="setSourceFilter"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis sumber">
|
||||
<SelectValueText placeholder="Semua Sumber" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Sumber</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in sourceCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.reference_no="{ item }">
|
||||
<div class="font-medium">{{ (item as Investment).reference_no }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.user="{ item }">
|
||||
<div>{{ (item as Investment).user?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs opacity-60">
|
||||
{{ (item as Investment).user?.ic_number ?? '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.investment_type="{ item }">
|
||||
<div>{{ (item as Investment).investment_type?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs uppercase opacity-60">
|
||||
{{ (item as Investment).investment_type?.code ?? '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.amount="{ item }">
|
||||
{{ formatInvestmentAmount((item as Investment).amount) }}
|
||||
</template>
|
||||
|
||||
<template #item.source="{ item }">
|
||||
{{ investmentSourceLabel((item as Investment).source) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge look="outline" :variant="investmentStatusVariant((item as Investment).status)">
|
||||
{{ investmentStatusLabel((item as Investment).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.created_at="{ item }">
|
||||
{{ formatDate((item as Investment).created_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<Button
|
||||
v-if="canView"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-green-600 text-white"
|
||||
title="Lihat butiran"
|
||||
@click="goToDetail((item as Investment).id)"
|
||||
>
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import ManualInvestmentForm from '../components/ManualInvestmentForm.vue'
|
||||
import { createManualInvestment } from '../services/investment.service'
|
||||
import type {
|
||||
ManualInvestmentFiles,
|
||||
ManualInvestmentPayload,
|
||||
} from '../types/investment.types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit(payload: ManualInvestmentPayload, files: ManualInvestmentFiles = {}) {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const investment = await createManualInvestment(payload, files)
|
||||
successMessage.value = 'Pelaburan manual berjaya direkodkan.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'view-investment', params: { id: investment.id } })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal merekod pelaburan manual.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-investments' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Rekod Pelaburan Manual</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Rekod pelaburan bagi pihak ahli. Transaksi akan terus dihantar ke ledger.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
look="outline"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="handleCancel"
|
||||
>
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box class="p-6">
|
||||
<ManualInvestmentForm
|
||||
:saving="saving"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import InvestmentTypeForm from '../components/InvestmentTypeForm.vue'
|
||||
import { createInvestmentType } from '../services/investment-type.service'
|
||||
import type { InvestmentTypePayload } from '../types/investment-type.types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit(payload: InvestmentTypePayload) {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await createInvestmentType(payload)
|
||||
successMessage.value = 'Jenis pelaburan berjaya ditambah.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'list-investment-types' })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menambah jenis pelaburan.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-investment-types' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Tambah Jenis Pelaburan</h2>
|
||||
<Button
|
||||
look="outline"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="handleCancel"
|
||||
>
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box>
|
||||
<InvestmentTypeForm
|
||||
:saving="saving"
|
||||
submit-label="Tambah"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import InvestmentTypeForm from '../components/InvestmentTypeForm.vue'
|
||||
import {
|
||||
getInvestmentType,
|
||||
updateInvestmentType,
|
||||
} from '../services/investment-type.service'
|
||||
import type { InvestmentType, InvestmentTypePayload } from '../types/investment-type.types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const investmentTypeId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
const item = ref<InvestmentType | null>(null)
|
||||
|
||||
async function fetchItem() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
item.value = await getInvestmentType(investmentTypeId.value)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||
item.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: InvestmentTypePayload) {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
item.value = await updateInvestmentType(investmentTypeId.value, payload)
|
||||
successMessage.value = 'Jenis pelaburan berjaya dikemaskini.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'list-investment-types' })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini jenis pelaburan.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-investment-types' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchItem()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Kemaskini Jenis Pelaburan</h2>
|
||||
<Button
|
||||
look="outline"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
:disabled="saving || loading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||
|
||||
<Box v-else-if="item">
|
||||
<InvestmentTypeForm
|
||||
:initial="item"
|
||||
:saving="saving"
|
||||
submit-label="Kemaskini"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
PaginationContext,
|
||||
PaginationRoot,
|
||||
PaginationItem,
|
||||
PaginationPrevTrigger,
|
||||
PaginationNextTrigger,
|
||||
PaginationEllipsis,
|
||||
} from '@/components/ui/pagination'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { useInvestmentTypeList } from '../composables/useInvestmentTypeList'
|
||||
import { deleteInvestmentType } from '../services/investment-type.service'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import {
|
||||
formatInvestmentAmount,
|
||||
investmentTypeStatusLabel,
|
||||
investmentTypeStatusVariant,
|
||||
} from '../utils/investment-type.utils'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreate = computed(() => hasPermission('tambah jenis pelaburan'))
|
||||
const canEdit = computed(() => hasPermission('kemaskini jenis pelaburan'))
|
||||
const canDelete = computed(() => hasPermission('hapus jenis pelaburan'))
|
||||
|
||||
const deleteConfirmationOpen = ref(false)
|
||||
const itemToDelete = ref<InvestmentType | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
fetchItems,
|
||||
} = useInvestmentTypeList()
|
||||
|
||||
function handlePageChange(details: { page: number }) {
|
||||
page.value = details.page
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-investment-type' })
|
||||
}
|
||||
|
||||
function goToEdit(id: string) {
|
||||
router.push({ name: 'edit-investment-type', params: { id } })
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(item: InvestmentType) {
|
||||
itemToDelete.value = item
|
||||
deleteError.value = null
|
||||
deleteConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!itemToDelete.value || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteInvestmentType(itemToDelete.value.id)
|
||||
deleteConfirmationOpen.value = false
|
||||
itemToDelete.value = null
|
||||
await fetchItems(page.value)
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus jenis pelaburan.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function channelLabel(item: InvestmentType): string {
|
||||
const channels: string[] = []
|
||||
if (item.allow_billplz) channels.push('Billplz')
|
||||
if (item.allow_salary_deduction) channels.push('Potongan Gaji')
|
||||
return channels.length ? channels.join(' · ') : 'Tiada saluran'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Jenis Pelaburan</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Urus produk pelaburan koperasi.</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canCreate"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="loading"
|
||||
@click="goToCreate"
|
||||
>
|
||||
Tambah Jenis Pelaburan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<div class="col-span-12 mt-2 flex flex-wrap items-center sm:flex-nowrap">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="relative w-56">
|
||||
<Input
|
||||
v-model="search"
|
||||
class="w-56 pr-10"
|
||||
type="search"
|
||||
placeholder="Cari jenis pelaburan..."
|
||||
:disabled="loading"
|
||||
/>
|
||||
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 h-4 w-4" icon="Search" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3"
|
||||
>
|
||||
<Box class="p-0">
|
||||
<div class="p-5">
|
||||
<div class="rounded-lg border border-foreground/10 bg-foreground/2 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-base font-medium">{{ item.name }}</div>
|
||||
<div class="mt-1 truncate text-xs uppercase tracking-wide opacity-70">
|
||||
{{ item.code }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
class="shrink-0"
|
||||
look="outline"
|
||||
:variant="investmentTypeStatusVariant(item.status)"
|
||||
>
|
||||
{{ investmentTypeStatusLabel(item.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-2 text-sm opacity-70">
|
||||
<div class="flex items-center">
|
||||
<Lucide class="mr-2 h-4 w-4" icon="Wallet" />
|
||||
Min: {{ formatInvestmentAmount(item.minimum_amount) }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Lucide class="mr-2 h-4 w-4" icon="Landmark" />
|
||||
Had dana: {{ formatInvestmentAmount(item.total_fund_limit) }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Lucide class="mr-2 h-4 w-4" icon="CreditCard" />
|
||||
{{ channelLabel(item) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center border-t border-foreground/8 p-5 lg:justify-end">
|
||||
<a
|
||||
v-if="canEdit"
|
||||
class="mr-3 flex items-center"
|
||||
href="#"
|
||||
@click.prevent="goToEdit(item.id)"
|
||||
>
|
||||
<Lucide class="mr-1 h-4 w-4" icon="CheckSquare" /> Edit
|
||||
</a>
|
||||
<a
|
||||
v-if="canDelete"
|
||||
class="text-danger flex items-center"
|
||||
href="#"
|
||||
@click.prevent="openDeleteConfirmation(item)"
|
||||
>
|
||||
<Lucide class="mr-1 h-4 w-4" icon="Trash" /> Hapus
|
||||
</a>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && !items.length" class="col-span-12">
|
||||
<Box class="p-6 text-center opacity-70">Tiada rekod ditemui</Box>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 flex flex-wrap items-center sm:flex-row sm:flex-nowrap">
|
||||
<PaginationRoot
|
||||
:count="pagination.total"
|
||||
:page="page"
|
||||
:page-size="itemsPerPage"
|
||||
:siblingCount="1"
|
||||
:onPageChange="handlePageChange"
|
||||
class="w-full sm:mr-auto sm:w-auto"
|
||||
>
|
||||
<PaginationPrevTrigger>Previous</PaginationPrevTrigger>
|
||||
<PaginationContext v-slot="{ pagination: pages }">
|
||||
<template v-for="(pageItem, index) in pages?.pages" :key="index">
|
||||
<PaginationItem v-if="pageItem.type === 'page'" v-bind="{ ...pageItem }">
|
||||
{{ pageItem.value }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else :index="index" />
|
||||
</template>
|
||||
</PaginationContext>
|
||||
<PaginationNextTrigger>Next</PaginationNextTrigger>
|
||||
</PaginationRoot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogRoot
|
||||
:open="deleteConfirmationOpen"
|
||||
@openChange="(details) => (deleteConfirmationOpen = details.open)"
|
||||
>
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
|
||||
<div class="mt-5 text-2xl font-medium">Adakah anda yakin?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Hapus jenis pelaburan
|
||||
<span v-if="itemToDelete" class="font-medium">{{ itemToDelete.name }}</span>?
|
||||
<br />
|
||||
Proses ini boleh dikembalikan melalui soft delete di pangkalan data.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import SalaryContributionForm from '../components/SalaryContributionForm.vue'
|
||||
import { createSalaryContribution } from '../services/salary.service'
|
||||
import type { SalaryContributionPayload } from '../types/salary.types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit(payload: SalaryContributionPayload) {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await createSalaryContribution(payload)
|
||||
router.push({ name: 'list-salary-contributions' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menambah caruman potongan gaji.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-salary-contributions' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Tambah Caruman Potongan Gaji</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Tetapkan arahan potongan bulanan untuk ahli.</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box class="p-6">
|
||||
<SalaryContributionForm
|
||||
:saving="saving"
|
||||
submit-label="Tambah Caruman"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import SalaryContributionForm from '../components/SalaryContributionForm.vue'
|
||||
import {
|
||||
getSalaryContribution,
|
||||
updateSalaryContribution,
|
||||
} from '../services/salary.service'
|
||||
import type {
|
||||
SalaryContribution,
|
||||
SalaryContributionPayload,
|
||||
} from '../types/salary.types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const item = ref<SalaryContribution | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function loadItem() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
item.value = await getSalaryContribution(String(route.params.id))
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan caruman.')
|
||||
item.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: SalaryContributionPayload) {
|
||||
if (!item.value) return
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await updateSalaryContribution(item.value.id, payload)
|
||||
router.push({ name: 'list-salary-contributions' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini caruman.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-salary-contributions' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItem()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Kemaskini Caruman Potongan Gaji</h2>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||
|
||||
<Box v-else-if="item" class="p-6">
|
||||
<SalaryContributionForm
|
||||
:initial="item"
|
||||
:saving="saving"
|
||||
submit-label="Kemaskini"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Pencil, Trash2 } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { useSalaryContributionList } from '../composables/useSalaryContributionList'
|
||||
import { deleteSalaryContribution } from '../services/salary.service'
|
||||
import type { SalaryContribution, SalaryContributionStatus } from '../types/salary.types'
|
||||
import {
|
||||
SALARY_CONTRIBUTION_STATUS_OPTIONS,
|
||||
formatInvestmentAmount,
|
||||
salaryContributionStatusLabel,
|
||||
salaryContributionStatusVariant,
|
||||
} from '../utils/salary.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Status', value: '' },
|
||||
...SALARY_CONTRIBUTION_STATUS_OPTIONS,
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [options[0]?.label ?? '']
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreate = computed(() => hasPermission('tambah potongan gaji'))
|
||||
const canUpdate = computed(() => hasPermission('kemaskini potongan gaji'))
|
||||
const canDelete = computed(() => hasPermission('hapus potongan gaji'))
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const itemToDelete = ref<SalaryContribution | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
} = useSalaryContributionList()
|
||||
|
||||
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Ahli', key: 'user', sortable: false },
|
||||
{ title: 'Jenis', key: 'investment_type', sortable: false },
|
||||
{ title: 'Jumlah Bulanan', key: 'monthly_amount', sortable: true },
|
||||
{ title: 'Mula', key: 'start_date', sortable: true },
|
||||
{ title: 'Tamat', key: 'end_date', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
function setStatusFilter(details: { value: string[] }) {
|
||||
statusFilter.value = labelToValue(
|
||||
STATUS_FILTER_OPTIONS,
|
||||
details.value[0],
|
||||
) as SalaryContributionStatus | ''
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY')
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-salary-contribution' })
|
||||
}
|
||||
|
||||
function goToEdit(id: string) {
|
||||
router.push({ name: 'edit-salary-contribution', params: { id } })
|
||||
}
|
||||
|
||||
function openDelete(item: SalaryContribution) {
|
||||
itemToDelete.value = item
|
||||
deleteError.value = null
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!itemToDelete.value || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteSalaryContribution(itemToDelete.value.id)
|
||||
deleteOpen.value = false
|
||||
itemToDelete.value = null
|
||||
await fetchItems(page.value)
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus caruman.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Caruman Potongan Gaji</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Arahan potongan bulanan ahli. Digunakan untuk menjana batch bulanan.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canCreate"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="loading"
|
||||
@click="goToCreate"
|
||||
>
|
||||
Tambah Caruman
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:current-sort="sortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="caruman-potongan-gaji"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model="search"
|
||||
class="w-full max-w-md"
|
||||
type="search"
|
||||
placeholder="Cari ahli atau jenis..."
|
||||
/>
|
||||
<SelectRoot
|
||||
class="w-full sm:w-52"
|
||||
:collection="statusCollection"
|
||||
:default-value="statusInitial"
|
||||
@value-change="setStatusFilter"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis status">
|
||||
<SelectValueText placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in statusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.user="{ item }">
|
||||
<div>{{ (item as SalaryContribution).user?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs opacity-60">
|
||||
{{ (item as SalaryContribution).user?.ic_number
|
||||
|| (item as SalaryContribution).user?.email
|
||||
|| '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.investment_type="{ item }">
|
||||
<div>{{ (item as SalaryContribution).investment_type?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs uppercase opacity-60">
|
||||
{{ (item as SalaryContribution).investment_type?.code ?? '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.monthly_amount="{ item }">
|
||||
{{ formatInvestmentAmount((item as SalaryContribution).monthly_amount) }}
|
||||
</template>
|
||||
|
||||
<template #item.start_date="{ item }">
|
||||
{{ formatDate((item as SalaryContribution).start_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.end_date="{ item }">
|
||||
{{ formatDate((item as SalaryContribution).end_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
look="outline"
|
||||
:variant="salaryContributionStatusVariant((item as SalaryContribution).status)"
|
||||
>
|
||||
{{ salaryContributionStatusLabel((item as SalaryContribution).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canUpdate"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Edit"
|
||||
@click="goToEdit((item as SalaryContribution).id)"
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-danger text-white"
|
||||
title="Hapus"
|
||||
@click="openDelete(item as SalaryContribution)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-5 text-2xl font-medium">Hapus caruman?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Caruman
|
||||
<span v-if="itemToDelete" class="font-medium">
|
||||
{{ itemToDelete.user?.name }} / {{ itemToDelete.investment_type?.name }}
|
||||
</span>
|
||||
akan dipadam.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">{{ deleteError }}</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,482 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Trash2 } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import MemberSearchField from '../components/MemberSearchField.vue'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import {
|
||||
generateSalaryDeductionBatch,
|
||||
uploadSalaryDeductionBatch,
|
||||
} from '../services/salary.service'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import type { UserListItem } from '@/modules/user/types/user.types'
|
||||
import { MONTH_OPTIONS, formatInvestmentAmount } from '../utils/salary.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
type Mode = 'generate' | 'manual'
|
||||
|
||||
type ManualDetail = {
|
||||
key: string
|
||||
user_id: string
|
||||
user_label: string
|
||||
investment_type_id: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const mode = ref<Mode>('generate')
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
month: dayjs().month() + 1,
|
||||
year: dayjs().year(),
|
||||
})
|
||||
|
||||
const investmentTypes = ref<InvestmentType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
|
||||
const draftDetail = reactive({
|
||||
user_id: '',
|
||||
investment_type_id: '',
|
||||
amount: '',
|
||||
})
|
||||
const draftMemberLabel = ref('')
|
||||
const draftMemberKey = ref(0)
|
||||
const details = ref<ManualDetail[]>([])
|
||||
|
||||
const yearOptions = computed(() => {
|
||||
const current = dayjs().year()
|
||||
return Array.from({ length: 6 }, (_, index) => {
|
||||
const year = current - 2 + index
|
||||
return { label: String(year), value: String(year) }
|
||||
})
|
||||
})
|
||||
|
||||
const monthOptions = computed<SelectOption[]>(() =>
|
||||
MONTH_OPTIONS.map((item) => ({ label: item.label, value: String(item.value) })),
|
||||
)
|
||||
|
||||
const monthCollection = computed(() =>
|
||||
select.collection({
|
||||
items: monthOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const yearCollection = computed(() =>
|
||||
select.collection({
|
||||
items: yearOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const monthInitial = computed(() => {
|
||||
const option = monthOptions.value.find((item) => item.value === String(form.month))
|
||||
return option ? [option.label] : []
|
||||
})
|
||||
|
||||
const yearInitial = computed(() => {
|
||||
const option = yearOptions.value.find((item) => item.value === String(form.year))
|
||||
return option ? [option.label] : []
|
||||
})
|
||||
|
||||
const typeOptions = computed<SelectOption[]>(() =>
|
||||
investmentTypes.value
|
||||
.filter((type) => type.status === 'ACTIVE' && type.allow_salary_deduction)
|
||||
.map((type) => ({
|
||||
label: `${type.code} — ${type.name}`,
|
||||
value: type.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const typeCollection = computed(() =>
|
||||
select.collection({
|
||||
items: typeOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
details.value.reduce((sum, detail) => sum + (Number(detail.amount) || 0), 0),
|
||||
)
|
||||
|
||||
function setMonthValue(event: { value: string[] }) {
|
||||
form.month = Number(
|
||||
monthOptions.value.find((item) => item.label === event.value[0])?.value ?? form.month,
|
||||
)
|
||||
}
|
||||
|
||||
function setYearValue(event: { value: string[] }) {
|
||||
form.year = Number(
|
||||
yearOptions.value.find((item) => item.label === event.value[0])?.value ?? form.year,
|
||||
)
|
||||
}
|
||||
|
||||
function setDraftTypeValue(event: { value: string[] }) {
|
||||
draftDetail.investment_type_id =
|
||||
typeOptions.value.find((item) => item.label === event.value[0])?.value ?? ''
|
||||
}
|
||||
|
||||
function onMemberSelect(member: UserListItem) {
|
||||
draftDetail.user_id = member.id
|
||||
draftMemberLabel.value = `${member.name} (${member.ic_number})`
|
||||
}
|
||||
|
||||
function clearDraftMember() {
|
||||
draftDetail.user_id = ''
|
||||
draftMemberLabel.value = ''
|
||||
}
|
||||
|
||||
function addDetail() {
|
||||
if (!draftDetail.user_id || !draftDetail.investment_type_id || Number(draftDetail.amount) <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
details.value.push({
|
||||
key: `${Date.now()}-${details.value.length}`,
|
||||
user_id: draftDetail.user_id,
|
||||
user_label: draftMemberLabel.value || draftDetail.user_id,
|
||||
investment_type_id: draftDetail.investment_type_id,
|
||||
amount: draftDetail.amount,
|
||||
})
|
||||
|
||||
draftDetail.user_id = ''
|
||||
draftDetail.investment_type_id = ''
|
||||
draftDetail.amount = ''
|
||||
draftMemberLabel.value = ''
|
||||
draftMemberKey.value += 1
|
||||
}
|
||||
|
||||
function removeDetail(key: string) {
|
||||
details.value = details.value.filter((detail) => detail.key !== key)
|
||||
}
|
||||
|
||||
function typeLabel(typeId: string): string {
|
||||
return typeOptions.value.find((item) => item.value === typeId)?.label ?? typeId
|
||||
}
|
||||
|
||||
async function loadTypes() {
|
||||
loadingTypes.value = true
|
||||
try {
|
||||
const data = await listInvestmentTypes({
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
investmentTypes.value = data.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const batch = await generateSalaryDeductionBatch({
|
||||
month: form.month,
|
||||
year: form.year,
|
||||
})
|
||||
router.push({ name: 'view-salary-deduction-batch', params: { id: batch.id } })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menjana batch.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!details.value.length) {
|
||||
error.value = 'Tambah sekurang-kurangnya satu rekod potongan.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const batch = await uploadSalaryDeductionBatch({
|
||||
month: form.month,
|
||||
year: form.year,
|
||||
details: details.value.map((detail) => ({
|
||||
user_id: detail.user_id,
|
||||
investment_type_id: detail.investment_type_id,
|
||||
amount: Number(detail.amount),
|
||||
})),
|
||||
})
|
||||
router.push({ name: 'view-salary-deduction-batch', params: { id: batch.id } })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat naik batch.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-salary-deduction-batches' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Jana / Muat Naik Batch</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Jana daripada caruman aktif, atau muat naik rekod secara manual.
|
||||
</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
:variant="mode === 'generate' ? 'primary' : 'secondary'"
|
||||
@click="mode = 'generate'"
|
||||
>
|
||||
Jana dari Caruman
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
:variant="mode === 'manual' ? 'primary' : 'secondary'"
|
||||
@click="mode = 'manual'"
|
||||
>
|
||||
Muat Naik Manual
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Box class="space-y-6 p-6">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel required>Bulan</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`month-${form.month}`"
|
||||
class="w-full"
|
||||
:collection="monthCollection"
|
||||
:default-value="monthInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setMonthValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih bulan">
|
||||
<SelectValueText placeholder="Pilih bulan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in monthCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Tahun</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`year-${form.year}`"
|
||||
class="w-full"
|
||||
:collection="yearCollection"
|
||||
:default-value="yearInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setYearValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih tahun">
|
||||
<SelectValueText placeholder="Pilih tahun" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in yearCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<template v-if="mode === 'generate'">
|
||||
<p class="text-sm opacity-70">
|
||||
Sistem akan menjana satu rekod untuk setiap caruman aktif yang meliputi bulan/tahun ini.
|
||||
Satu batch sahaja dibenarkan setiap tempoh.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="button" variant="primary" :disabled="saving" @click="handleGenerate">
|
||||
{{ saving ? 'Menjana...' : 'Jana Batch' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-4 rounded-md border border-foreground/10 p-4">
|
||||
<h3 class="text-sm font-medium">Tambah Rekod</h3>
|
||||
<MemberSearchField
|
||||
:key="draftMemberKey"
|
||||
v-model="draftDetail.user_id"
|
||||
required
|
||||
:disabled="saving"
|
||||
@select="onMemberSelect"
|
||||
@clear="clearDraftMember"
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan...</p>
|
||||
<SelectRoot
|
||||
v-else-if="typeOptions.length"
|
||||
:key="`${draftMemberKey}-${typeOptions.map((item) => item.value).join(',')}`"
|
||||
class="w-full"
|
||||
:collection="typeCollection"
|
||||
:disabled="saving"
|
||||
@value-change="setDraftTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih jenis">
|
||||
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in typeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jumlah (MYR)</FieldLabel>
|
||||
<Input v-model="draftDetail.amount" type="number" min="0" step="0.01" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="
|
||||
saving ||
|
||||
!draftDetail.user_id ||
|
||||
!draftDetail.investment_type_id ||
|
||||
Number(draftDetail.amount) <= 0
|
||||
"
|
||||
@click="addDetail"
|
||||
>
|
||||
Tambah ke Senarai
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
|
||||
<tr>
|
||||
<th class="py-2 pr-4 font-medium">Ahli</th>
|
||||
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||
<th class="py-2 pr-4 font-medium">Jumlah</th>
|
||||
<th class="py-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="detail in details" :key="detail.key" class="border-b border-foreground/5">
|
||||
<td class="py-2 pr-4">{{ detail.user_label }}</td>
|
||||
<td class="py-2 pr-4">{{ typeLabel(detail.investment_type_id) }}</td>
|
||||
<td class="py-2 pr-4">{{ formatInvestmentAmount(detail.amount) }}</td>
|
||||
<td class="py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-danger"
|
||||
@click="removeDetail(detail.key)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!details.length">
|
||||
<td colspan="4" class="py-6 text-center opacity-60">Belum ada rekod ditambah.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-sm opacity-70">
|
||||
{{ details.length }} rekod · Jumlah {{ formatInvestmentAmount(totalAmount) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="saving || !details.length"
|
||||
@click="handleUpload"
|
||||
>
|
||||
{{ saving ? 'Memuat naik...' : 'Muat Naik Batch' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,378 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Check, X } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
getSalaryDeductionBatch,
|
||||
markSalaryDeductionBatchUnderReview,
|
||||
rejectSalaryDeductionBatch,
|
||||
rejectSalaryDeductionDetail,
|
||||
verifyAllSalaryDeductionBatch,
|
||||
verifySalaryDeductionDetail,
|
||||
} from '../services/salary.service'
|
||||
import type {
|
||||
SalaryDeductionBatch,
|
||||
SalaryDeductionBatchDetail,
|
||||
} from '../types/salary.types'
|
||||
import {
|
||||
formatInvestmentAmount,
|
||||
monthLabel,
|
||||
salaryBatchStatusLabel,
|
||||
salaryBatchStatusVariant,
|
||||
salaryDetailStatusLabel,
|
||||
salaryDetailStatusVariant,
|
||||
} from '../utils/salary.utils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canVerify = computed(() => hasPermission('sahkan potongan gaji'))
|
||||
|
||||
const batch = ref<SalaryDeductionBatch | null>(null)
|
||||
const loading = ref(true)
|
||||
const acting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const actionDialogOpen = ref(false)
|
||||
const actionType = ref<'verify-detail' | 'reject-detail' | 'reject-batch' | null>(null)
|
||||
const actionDetail = ref<SalaryDeductionBatchDetail | null>(null)
|
||||
const actionRemarks = ref('')
|
||||
const actionError = ref<string | null>(null)
|
||||
|
||||
const pendingCount = computed(
|
||||
() => batch.value?.details?.filter((detail) => detail.status === 'PENDING').length ?? 0,
|
||||
)
|
||||
|
||||
const canProcessBatch = computed(
|
||||
() =>
|
||||
batch.value?.status === 'UPLOADED' || batch.value?.status === 'UNDER_REVIEW',
|
||||
)
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
async function loadBatch() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
batch.value = await getSalaryDeductionBatch(String(route.params.id))
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan batch.')
|
||||
batch.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markUnderReview() {
|
||||
if (!batch.value || acting.value) return
|
||||
|
||||
acting.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
batch.value = await markSalaryDeductionBatchUnderReview(batch.value.id)
|
||||
successMessage.value = 'Batch dihantar untuk semakan.'
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menghantar untuk semakan.')
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAll() {
|
||||
if (!batch.value || acting.value) return
|
||||
|
||||
acting.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
batch.value = await verifyAllSalaryDeductionBatch(batch.value.id)
|
||||
successMessage.value = 'Semua rekod pending berjaya disahkan.'
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengesahkan semua rekod.')
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openVerifyDetail(detail: SalaryDeductionBatchDetail) {
|
||||
actionType.value = 'verify-detail'
|
||||
actionDetail.value = detail
|
||||
actionRemarks.value = ''
|
||||
actionError.value = null
|
||||
actionDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openRejectDetail(detail: SalaryDeductionBatchDetail) {
|
||||
actionType.value = 'reject-detail'
|
||||
actionDetail.value = detail
|
||||
actionRemarks.value = ''
|
||||
actionError.value = null
|
||||
actionDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openRejectBatch() {
|
||||
actionType.value = 'reject-batch'
|
||||
actionDetail.value = null
|
||||
actionRemarks.value = ''
|
||||
actionError.value = null
|
||||
actionDialogOpen.value = true
|
||||
}
|
||||
|
||||
const actionTitle = computed(() => {
|
||||
if (actionType.value === 'verify-detail') return 'Sahkan rekod ini?'
|
||||
if (actionType.value === 'reject-detail') return 'Tolak rekod ini?'
|
||||
if (actionType.value === 'reject-batch') return 'Tolak keseluruhan batch?'
|
||||
return ''
|
||||
})
|
||||
|
||||
async function confirmAction() {
|
||||
if (!batch.value || !actionType.value || acting.value) return
|
||||
|
||||
acting.value = true
|
||||
actionError.value = null
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
if (actionType.value === 'verify-detail' && actionDetail.value) {
|
||||
await verifySalaryDeductionDetail(
|
||||
batch.value.id,
|
||||
actionDetail.value.id,
|
||||
actionRemarks.value.trim() || null,
|
||||
)
|
||||
successMessage.value = 'Rekod berjaya disahkan dan dihantar ke ledger.'
|
||||
} else if (actionType.value === 'reject-detail' && actionDetail.value) {
|
||||
await rejectSalaryDeductionDetail(
|
||||
batch.value.id,
|
||||
actionDetail.value.id,
|
||||
actionRemarks.value.trim() || null,
|
||||
)
|
||||
successMessage.value = 'Rekod berjaya ditolak.'
|
||||
} else if (actionType.value === 'reject-batch') {
|
||||
await rejectSalaryDeductionBatch(
|
||||
batch.value.id,
|
||||
actionRemarks.value.trim() || null,
|
||||
)
|
||||
successMessage.value = 'Batch berjaya ditolak.'
|
||||
}
|
||||
|
||||
actionDialogOpen.value = false
|
||||
await loadBatch()
|
||||
} catch (err) {
|
||||
actionError.value = getApiErrorMessage(err, 'Tindakan gagal.')
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-salary-deduction-batches' })
|
||||
}
|
||||
|
||||
function goToInvestment(id: string) {
|
||||
router.push({ name: 'view-investment', params: { id } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBatch()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Butiran Batch Potongan Gaji</h2>
|
||||
<p v-if="batch" class="mt-1 text-sm opacity-70">{{ batch.batch_no }}</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="loading || acting" @click="goBack">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||
|
||||
<template v-else-if="batch">
|
||||
<Box class="space-y-4 p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm opacity-70">Tempoh</div>
|
||||
<div class="mt-1 text-xl font-medium">
|
||||
{{ monthLabel(batch.month) }} {{ batch.year }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge look="outline" :variant="salaryBatchStatusVariant(batch.status)">
|
||||
{{ salaryBatchStatusLabel(batch.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4 text-sm">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Jumlah Rekod</div>
|
||||
<div class="mt-1 font-medium">{{ batch.total_records }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Jumlah Amaun</div>
|
||||
<div class="mt-1 font-medium">{{ formatInvestmentAmount(batch.total_amount) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Dimuat Naik</div>
|
||||
<div class="mt-1">{{ formatDate(batch.uploaded_at) }}</div>
|
||||
<div v-if="batch.uploaded_by_user" class="opacity-70">
|
||||
oleh {{ batch.uploaded_by_user.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Disahkan</div>
|
||||
<div class="mt-1">{{ formatDate(batch.verified_at) }}</div>
|
||||
<div v-if="batch.verified_by_user" class="opacity-70">
|
||||
oleh {{ batch.verified_by_user.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canVerify && canProcessBatch" class="flex flex-wrap gap-2 border-t border-foreground/10 pt-4">
|
||||
<Button v-if="batch.status === 'UPLOADED'" type="button" look="outline" variant="secondary" :disabled="acting"
|
||||
@click="markUnderReview">
|
||||
Sahkan Semakan
|
||||
</Button>
|
||||
<Button v-if="pendingCount > 0" type="button" variant="primary" :disabled="acting" @click="verifyAll">
|
||||
Sahkan Semua ({{ pendingCount }})
|
||||
</Button>
|
||||
<Button type="button" look="outline" variant="danger" :disabled="acting" @click="openRejectBatch">
|
||||
Tolak Semua
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="p-6">
|
||||
<h3 class="text-base font-medium">Rekod Potongan</h3>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
|
||||
<tr>
|
||||
<th class="py-2 pr-4 font-medium">Ahli</th>
|
||||
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||
<th class="py-2 pr-4 font-medium">Jumlah</th>
|
||||
<th class="py-2 pr-4 font-medium">Status</th>
|
||||
<th class="py-2 pr-4 font-medium">Pelaburan</th>
|
||||
<th class="py-2 font-medium">Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="detail in batch.details" :key="detail.id" class="border-b border-foreground/5">
|
||||
<td class="py-3 pr-4">
|
||||
<div class="font-medium">{{ detail.user?.name ?? '-' }}</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ detail.user?.ic_number || detail.user?.email || '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
<div>{{ detail.investment_type?.name ?? '-' }}</div>
|
||||
<div class="text-xs uppercase opacity-60">
|
||||
{{ detail.investment_type?.code ?? '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4">{{ formatInvestmentAmount(detail.amount) }}</td>
|
||||
<td class="py-3 pr-4">
|
||||
<Badge look="outline" :variant="salaryDetailStatusVariant(detail.status)">
|
||||
{{ salaryDetailStatusLabel(detail.status) }}
|
||||
</Badge>
|
||||
<div v-if="detail.remarks" class="mt-1 text-xs opacity-60">
|
||||
{{ detail.remarks }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
<button v-if="detail.investment_id" type="button"
|
||||
class="text-left text-sm underline opacity-80 hover:opacity-100"
|
||||
@click="goToInvestment(detail.investment_id)">
|
||||
{{ detail.investment?.reference_no ?? 'Lihat' }}
|
||||
</button>
|
||||
<span v-else class="opacity-50">-</span>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div v-if="canVerify && canProcessBatch && detail.status === 'PENDING'"
|
||||
class="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" class="bg-green-600 text-white" title="Sahkan"
|
||||
:disabled="acting" @click="openVerifyDetail(detail)">
|
||||
<Check class="size-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" class="bg-danger text-white" title="Tolak"
|
||||
:disabled="acting" @click="openRejectDetail(detail)">
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span v-else class="opacity-40">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!batch.details?.length">
|
||||
<td colspan="6" class="py-6 text-center opacity-60">Tiada rekod dalam batch.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<DialogRoot :open="actionDialogOpen" @openChange="(details) => (actionDialogOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="space-y-4 p-5">
|
||||
<div class="text-xl font-medium">{{ actionTitle }}</div>
|
||||
<p v-if="actionDetail" class="text-sm opacity-70">
|
||||
{{ actionDetail.user?.name }} ·
|
||||
{{ formatInvestmentAmount(actionDetail.amount) }}
|
||||
</p>
|
||||
<p v-else-if="actionType === 'reject-batch'" class="text-sm opacity-70">
|
||||
Batch yang sudah ada rekod disahkan tidak boleh ditolak sepenuhnya.
|
||||
</p>
|
||||
|
||||
<AlertRoot v-if="actionError" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ actionError }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Catatan</FieldLabel>
|
||||
<Textarea v-model="actionRemarks" rows="3" :disabled="acting" placeholder="Catatan pilihan..." />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 px-5 pb-5">
|
||||
<DialogCloseTrigger :disabled="acting">Batal</DialogCloseTrigger>
|
||||
<Button type="button" :variant="actionType === 'verify-detail' ? 'primary' : 'danger'" :disabled="acting"
|
||||
@click="confirmAction">
|
||||
{{ acting ? 'Memproses...' : 'Sahkan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useSalaryDeductionBatchList } from '../composables/useSalaryDeductionBatchList'
|
||||
import type {
|
||||
SalaryDeductionBatch,
|
||||
SalaryDeductionBatchStatus,
|
||||
} from '../types/salary.types'
|
||||
import {
|
||||
SALARY_BATCH_STATUS_OPTIONS,
|
||||
formatInvestmentAmount,
|
||||
monthLabel,
|
||||
salaryBatchStatusLabel,
|
||||
salaryBatchStatusVariant,
|
||||
} from '../utils/salary.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Status', value: '' },
|
||||
...SALARY_BATCH_STATUS_OPTIONS,
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [options[0]?.label ?? '']
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreate = computed(() => hasPermission('tambah potongan gaji'))
|
||||
const canView = computed(() => hasPermission('lihat potongan gaji'))
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
statusFilter,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
} = useSalaryDeductionBatchList()
|
||||
|
||||
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'No. Batch', key: 'batch_no', sortable: false },
|
||||
{ title: 'Tempoh', key: 'period', sortable: false },
|
||||
{ title: 'Rekod', key: 'total_records', sortable: false },
|
||||
{ title: 'Jumlah', key: 'total_amount', sortable: false },
|
||||
{ title: 'Status', key: 'status', sortable: false },
|
||||
{ title: 'Dimuat Naik', key: 'uploaded_at', sortable: false },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
function setStatusFilter(details: { value: string[] }) {
|
||||
statusFilter.value = labelToValue(
|
||||
STATUS_FILTER_OPTIONS,
|
||||
details.value[0],
|
||||
) as SalaryDeductionBatchStatus | ''
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-salary-deduction-batch' })
|
||||
}
|
||||
|
||||
function goToDetail(id: string) {
|
||||
router.push({ name: 'view-salary-deduction-batch', params: { id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Batch Potongan Gaji</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Muat naik / jana batch bulanan, kemudian sahkan setiap rekod ke ledger.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canCreate"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="loading"
|
||||
@click="goToCreate"
|
||||
>
|
||||
Jana / Muat Naik Batch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="batch-potongan-gaji"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
>
|
||||
<template #toolbar>
|
||||
<SelectRoot
|
||||
class="w-full sm:w-56"
|
||||
:collection="statusCollection"
|
||||
:default-value="statusInitial"
|
||||
@value-change="setStatusFilter"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis status">
|
||||
<SelectValueText placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in statusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
|
||||
<template #item.batch_no="{ item }">
|
||||
<div class="font-medium">{{ (item as SalaryDeductionBatch).batch_no }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.period="{ item }">
|
||||
{{ monthLabel((item as SalaryDeductionBatch).month) }}
|
||||
{{ (item as SalaryDeductionBatch).year }}
|
||||
</template>
|
||||
|
||||
<template #item.total_amount="{ item }">
|
||||
{{ formatInvestmentAmount((item as SalaryDeductionBatch).total_amount) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
look="outline"
|
||||
:variant="salaryBatchStatusVariant((item as SalaryDeductionBatch).status)"
|
||||
>
|
||||
{{ salaryBatchStatusLabel((item as SalaryDeductionBatch).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.uploaded_at="{ item }">
|
||||
{{ formatDate((item as SalaryDeductionBatch).uploaded_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<Button
|
||||
v-if="canView"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-green-600 text-white"
|
||||
title="Lihat & sahkan"
|
||||
@click="goToDetail((item as SalaryDeductionBatch).id)"
|
||||
>
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import UserInvestmentLimitForm from '../components/UserInvestmentLimitForm.vue'
|
||||
import { createUserInvestmentLimit } from '../services/investment-limit.service'
|
||||
import type { UserInvestmentLimitPayload } from '../types/investment-limit.types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit(payload: UserInvestmentLimitPayload) {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await createUserInvestmentLimit(payload)
|
||||
router.push({ name: 'list-user-investment-limits' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menambah had pelaburan ahli.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-user-investment-limits' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Tambah Had Pelaburan Ahli</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Override had profil untuk ahli dan jenis pelaburan ini.</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box class="p-6">
|
||||
<UserInvestmentLimitForm
|
||||
:saving="saving"
|
||||
submit-label="Tambah Override"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import UserInvestmentLimitForm from '../components/UserInvestmentLimitForm.vue'
|
||||
import {
|
||||
getUserInvestmentLimit,
|
||||
updateUserInvestmentLimit,
|
||||
} from '../services/investment-limit.service'
|
||||
import type {
|
||||
UserInvestmentLimit,
|
||||
UserInvestmentLimitPayload,
|
||||
} from '../types/investment-limit.types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const item = ref<UserInvestmentLimit | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function loadItem() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
item.value = await getUserInvestmentLimit(String(route.params.id))
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan had pelaburan ahli.')
|
||||
item.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: UserInvestmentLimitPayload) {
|
||||
if (!item.value) return
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await updateUserInvestmentLimit(item.value.id, payload)
|
||||
router.push({ name: 'list-user-investment-limits' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini had pelaburan ahli.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-user-investment-limits' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItem()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Kemaskini Had Pelaburan Ahli</h2>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||
|
||||
<Box v-else-if="item" class="p-6">
|
||||
<UserInvestmentLimitForm
|
||||
:initial="item"
|
||||
:saving="saving"
|
||||
submit-label="Kemaskini"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Pencil, Trash2 } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { useUserInvestmentLimitList } from '../composables/useUserInvestmentLimitList'
|
||||
import { deleteUserInvestmentLimit } from '../services/investment-limit.service'
|
||||
import type { UserInvestmentLimit } from '../types/investment-limit.types'
|
||||
import { formatInvestmentAmount } from '../utils/investment-type.utils'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreate = computed(() => hasPermission('tambah had pelaburan'))
|
||||
const canUpdate = computed(() => hasPermission('kemaskini had pelaburan'))
|
||||
const canDelete = computed(() => hasPermission('hapus had pelaburan'))
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const itemToDelete = ref<UserInvestmentLimit | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
} = useUserInvestmentLimitList()
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Ahli', key: 'user', sortable: false },
|
||||
{ title: 'Jenis', key: 'investment_type', sortable: false },
|
||||
{ title: 'Had Kumulatif', key: 'max_investment_amount', sortable: true },
|
||||
{ title: 'Had Transaksi', key: 'max_transaction_amount', sortable: true },
|
||||
{ title: 'Mula', key: 'effective_date', sortable: true },
|
||||
{ title: 'Tamat', key: 'expiry_date', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY')
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-user-investment-limit' })
|
||||
}
|
||||
|
||||
function goToEdit(id: string) {
|
||||
router.push({ name: 'edit-user-investment-limit', params: { id } })
|
||||
}
|
||||
|
||||
function openDelete(item: UserInvestmentLimit) {
|
||||
itemToDelete.value = item
|
||||
deleteError.value = null
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!itemToDelete.value || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteUserInvestmentLimit(itemToDelete.value.id)
|
||||
deleteOpen.value = false
|
||||
itemToDelete.value = null
|
||||
await fetchItems(page.value)
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus had ahli.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Had Pelaburan Ahli</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Override had profil untuk ahli tertentu. Override aktif mengatasi had profil.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canCreate"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="loading"
|
||||
@click="goToCreate"
|
||||
>
|
||||
Tambah Override
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:current-sort="sortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="had-pelaburan-ahli"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate"
|
||||
>
|
||||
<template #toolbar>
|
||||
<Input
|
||||
v-model="search"
|
||||
class="w-full max-w-md"
|
||||
type="search"
|
||||
placeholder="Cari ahli atau jenis pelaburan..."
|
||||
aria-label="Cari had ahli"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item.user="{ item }">
|
||||
<div>{{ (item as UserInvestmentLimit).user?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs opacity-60">
|
||||
{{ (item as UserInvestmentLimit).user?.ic_number
|
||||
|| (item as UserInvestmentLimit).user?.email
|
||||
|| '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.investment_type="{ item }">
|
||||
<div>{{ (item as UserInvestmentLimit).investment_type?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs uppercase opacity-60">
|
||||
{{ (item as UserInvestmentLimit).investment_type?.code ?? '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.max_investment_amount="{ item }">
|
||||
{{ formatInvestmentAmount((item as UserInvestmentLimit).max_investment_amount) }}
|
||||
</template>
|
||||
|
||||
<template #item.max_transaction_amount="{ item }">
|
||||
{{ formatInvestmentAmount((item as UserInvestmentLimit).max_transaction_amount) }}
|
||||
</template>
|
||||
|
||||
<template #item.effective_date="{ item }">
|
||||
{{ formatDate((item as UserInvestmentLimit).effective_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.expiry_date="{ item }">
|
||||
{{ formatDate((item as UserInvestmentLimit).expiry_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canUpdate"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Edit"
|
||||
@click="goToEdit((item as UserInvestmentLimit).id)"
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-danger text-white"
|
||||
title="Hapus"
|
||||
@click="openDelete(item as UserInvestmentLimit)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-5 text-2xl font-medium">Hapus override had?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Override untuk
|
||||
<span v-if="itemToDelete" class="font-medium">
|
||||
{{ itemToDelete.user?.name }} / {{ itemToDelete.investment_type?.name }}
|
||||
</span>
|
||||
akan dipadam.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">{{ deleteError }}</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import MemberSearchField from '../components/MemberSearchField.vue'
|
||||
import {
|
||||
assignUserLimitProfile,
|
||||
listInvestmentLimitProfiles,
|
||||
} from '../services/investment-limit.service'
|
||||
import type { InvestmentLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loadingProfiles = ref(false)
|
||||
const profiles = ref<InvestmentLimitProfile[]>([])
|
||||
|
||||
const form = reactive({
|
||||
user_id: '',
|
||||
profile_id: '',
|
||||
effective_date: dayjs().format('YYYY-MM-DD'),
|
||||
})
|
||||
|
||||
const profileOptions = computed<SelectOption[]>(() =>
|
||||
profiles.value.map((profile) => ({
|
||||
label: profile.name,
|
||||
value: profile.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const profileCollection = computed(() =>
|
||||
select.collection({
|
||||
items: profileOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
function setProfileValue(details: { value: string[] }) {
|
||||
const label = details.value[0]
|
||||
form.profile_id = profileOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
loadingProfiles.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listInvestmentLimitProfiles({
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
profiles.value = data.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai profil had.')
|
||||
profiles.value = []
|
||||
} finally {
|
||||
loadingProfiles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await assignUserLimitProfile({
|
||||
user_id: form.user_id,
|
||||
profile_id: form.profile_id,
|
||||
effective_date: form.effective_date,
|
||||
})
|
||||
router.push({ name: 'list-user-limit-profiles' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menugaskan profil had.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-user-limit-profiles' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfiles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Tugaskan Profil Had</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Ahli akan menggunakan had daripada profil yang dipilih.
|
||||
</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box class="p-6">
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<MemberSearchField v-model="form.user_id" required :disabled="saving" />
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Profil Had</FieldLabel>
|
||||
<p v-if="loadingProfiles" class="text-sm opacity-60">Memuatkan profil...</p>
|
||||
<SelectRoot
|
||||
v-else-if="profileOptions.length"
|
||||
:key="profileOptions.map((item) => item.value).join(',')"
|
||||
class="w-full"
|
||||
:collection="profileCollection"
|
||||
:disabled="saving"
|
||||
@value-change="setProfileValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih profil had">
|
||||
<SelectValueText placeholder="Pilih profil had" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in profileCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<p v-else class="text-sm opacity-60">Tiada profil had. Cipta profil dahulu.</p>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Tarikh Berkuat Kuasa</FieldLabel>
|
||||
<Input v-model="form.effective_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:disabled="saving || !form.user_id || !form.profile_id || !form.effective_date"
|
||||
>
|
||||
{{ saving ? 'Menyimpan...' : 'Tugaskan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useUserLimitProfileList } from '../composables/useUserLimitProfileList'
|
||||
import type { UserLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canAssign = computed(() => hasPermission('tambah had pelaburan'))
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
activeOnly,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
} = useUserLimitProfileList()
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Ahli', key: 'user', sortable: false },
|
||||
{ title: 'Profil', key: 'profile', sortable: false },
|
||||
{ title: 'Mula', key: 'effective_date', sortable: false },
|
||||
{ title: 'Tamat', key: 'expiry_date', sortable: false },
|
||||
{ title: 'Status', key: 'status', sortable: false },
|
||||
]
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY')
|
||||
}
|
||||
|
||||
function isActive(item: UserLimitProfile): boolean {
|
||||
const today = dayjs().startOf('day')
|
||||
if (dayjs(item.effective_date).startOf('day').isAfter(today)) return false
|
||||
if (!item.expiry_date) return true
|
||||
return !dayjs(item.expiry_date).startOf('day').isBefore(today)
|
||||
}
|
||||
|
||||
function goToAssign() {
|
||||
router.push({ name: 'assign-user-limit-profile' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Tugasan Profil Had</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Tugaskan profil had kepada ahli. Tugasan baharu akan tamatkan tugasan aktif sedia ada.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canAssign"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="loading"
|
||||
@click="goToAssign"
|
||||
>
|
||||
Tugaskan Profil
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="tugasan-profil-had"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
>
|
||||
<template #toolbar>
|
||||
<CheckboxRoot
|
||||
class="flex items-center gap-2"
|
||||
:checked="activeOnly"
|
||||
@checked-change="({ checked }) => (activeOnly = checked === true)"
|
||||
>
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Aktif sahaja</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
|
||||
<template #item.user="{ item }">
|
||||
<div>{{ (item as UserLimitProfile).user?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs opacity-60">
|
||||
{{ (item as UserLimitProfile).user?.ic_number
|
||||
|| (item as UserLimitProfile).user?.email
|
||||
|| '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.profile="{ item }">
|
||||
{{ (item as UserLimitProfile).profile?.name ?? '-' }}
|
||||
</template>
|
||||
|
||||
<template #item.effective_date="{ item }">
|
||||
{{ formatDate((item as UserLimitProfile).effective_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.expiry_date="{ item }">
|
||||
{{ formatDate((item as UserLimitProfile).expiry_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
look="outline"
|
||||
:variant="isActive(item as UserLimitProfile) ? 'success' : 'secondary'"
|
||||
>
|
||||
{{ isActive(item as UserLimitProfile) ? 'Aktif' : 'Tamat' }}
|
||||
</Badge>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const investmentLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'list-investments',
|
||||
name: 'list-investments',
|
||||
component: () => import('./pages/InvestmentList.vue'),
|
||||
meta: {
|
||||
title: 'Pelaburan',
|
||||
module: 'investment',
|
||||
permission: 'lihat pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'investments/manual/create',
|
||||
name: 'create-manual-investment',
|
||||
component: () => import('./pages/InvestmentManualCreate.vue'),
|
||||
meta: {
|
||||
title: 'Rekod Pelaburan Manual',
|
||||
module: 'investment',
|
||||
permission: 'tambah pelaburan manual',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'investments/:id',
|
||||
name: 'view-investment',
|
||||
component: () => import('./pages/InvestmentDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Pelaburan',
|
||||
module: 'investment',
|
||||
permission: 'lihat pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-investment-types',
|
||||
name: 'list-investment-types',
|
||||
component: () => import('./pages/InvestmentTypeList.vue'),
|
||||
meta: {
|
||||
title: 'Jenis Pelaburan',
|
||||
module: 'investment',
|
||||
permission: 'lihat jenis pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'investment-types/create',
|
||||
name: 'create-investment-type',
|
||||
component: () => import('./pages/InvestmentTypeCreate.vue'),
|
||||
meta: {
|
||||
title: 'Tambah Jenis Pelaburan',
|
||||
module: 'investment',
|
||||
permission: 'tambah jenis pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'investment-types/:id/edit',
|
||||
name: 'edit-investment-type',
|
||||
component: () => import('./pages/InvestmentTypeEdit.vue'),
|
||||
meta: {
|
||||
title: 'Kemaskini Jenis Pelaburan',
|
||||
module: 'investment',
|
||||
permission: 'kemaskini jenis pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-investment-limit-profiles',
|
||||
name: 'list-investment-limit-profiles',
|
||||
component: () => import('./pages/InvestmentLimitProfileList.vue'),
|
||||
meta: {
|
||||
title: 'Profil Had Pelaburan',
|
||||
module: 'investment',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'investment-limit-profiles/create',
|
||||
name: 'create-investment-limit-profile',
|
||||
component: () => import('./pages/InvestmentLimitProfileCreate.vue'),
|
||||
meta: {
|
||||
title: 'Tambah Profil Had',
|
||||
module: 'investment',
|
||||
permission: 'tambah had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'investment-limit-profiles/:id',
|
||||
name: 'view-investment-limit-profile',
|
||||
component: () => import('./pages/InvestmentLimitProfileDetail.vue'),
|
||||
meta: {
|
||||
title: 'Urus Profil Had',
|
||||
module: 'investment',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-user-limit-profiles',
|
||||
name: 'list-user-limit-profiles',
|
||||
component: () => import('./pages/UserLimitProfileList.vue'),
|
||||
meta: {
|
||||
title: 'Tugasan Profil Had',
|
||||
module: 'investment',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'user-limit-profiles/assign',
|
||||
name: 'assign-user-limit-profile',
|
||||
component: () => import('./pages/UserLimitProfileAssign.vue'),
|
||||
meta: {
|
||||
title: 'Tugaskan Profil Had',
|
||||
module: 'investment',
|
||||
permission: 'tambah had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-user-investment-limits',
|
||||
name: 'list-user-investment-limits',
|
||||
component: () => import('./pages/UserInvestmentLimitList.vue'),
|
||||
meta: {
|
||||
title: 'Had Pelaburan Ahli',
|
||||
module: 'investment',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'user-investment-limits/create',
|
||||
name: 'create-user-investment-limit',
|
||||
component: () => import('./pages/UserInvestmentLimitCreate.vue'),
|
||||
meta: {
|
||||
title: 'Tambah Had Pelaburan Ahli',
|
||||
module: 'investment',
|
||||
permission: 'tambah had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'user-investment-limits/:id/edit',
|
||||
name: 'edit-user-investment-limit',
|
||||
component: () => import('./pages/UserInvestmentLimitEdit.vue'),
|
||||
meta: {
|
||||
title: 'Kemaskini Had Pelaburan Ahli',
|
||||
module: 'investment',
|
||||
permission: 'kemaskini had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-salary-contributions',
|
||||
name: 'list-salary-contributions',
|
||||
component: () => import('./pages/SalaryContributionList.vue'),
|
||||
meta: {
|
||||
title: 'Caruman Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'salary-contributions/create',
|
||||
name: 'create-salary-contribution',
|
||||
component: () => import('./pages/SalaryContributionCreate.vue'),
|
||||
meta: {
|
||||
title: 'Tambah Caruman Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'tambah potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'salary-contributions/:id/edit',
|
||||
name: 'edit-salary-contribution',
|
||||
component: () => import('./pages/SalaryContributionEdit.vue'),
|
||||
meta: {
|
||||
title: 'Kemaskini Caruman Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'kemaskini potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-salary-deduction-batches',
|
||||
name: 'list-salary-deduction-batches',
|
||||
component: () => import('./pages/SalaryDeductionBatchList.vue'),
|
||||
meta: {
|
||||
title: 'Batch Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'salary-deduction-batches/create',
|
||||
name: 'create-salary-deduction-batch',
|
||||
component: () => import('./pages/SalaryDeductionBatchCreate.vue'),
|
||||
meta: {
|
||||
title: 'Jana / Muat Naik Batch',
|
||||
module: 'investment',
|
||||
permission: 'tambah potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'salary-deduction-batches/:id',
|
||||
name: 'view-salary-deduction-batch',
|
||||
component: () => import('./pages/SalaryDeductionBatchDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Batch Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,267 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
AssignUserLimitProfilePayload,
|
||||
InvestmentLimitProfile,
|
||||
InvestmentLimitProfilePayload,
|
||||
ListInvestmentLimitProfilesParams,
|
||||
ListUserInvestmentLimitsParams,
|
||||
ListUserLimitProfilesParams,
|
||||
ProfileInvestmentLimit,
|
||||
ProfileInvestmentLimitPayload,
|
||||
UserInvestmentLimit,
|
||||
UserInvestmentLimitPayload,
|
||||
UserLimitProfile,
|
||||
} from '../types/investment-limit.types'
|
||||
|
||||
type ProfileApiResponse = {
|
||||
success: boolean
|
||||
data: InvestmentLimitProfile
|
||||
message?: string
|
||||
}
|
||||
|
||||
type ProfileLimitApiResponse = {
|
||||
success: boolean
|
||||
data: ProfileInvestmentLimit
|
||||
message?: string
|
||||
}
|
||||
|
||||
type ProfileLimitsApiResponse = {
|
||||
success: boolean
|
||||
data: ProfileInvestmentLimit[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type UserLimitProfileApiResponse = {
|
||||
success: boolean
|
||||
data: UserLimitProfile
|
||||
message?: string
|
||||
}
|
||||
|
||||
type UserInvestmentLimitApiResponse = {
|
||||
success: boolean
|
||||
data: UserInvestmentLimit
|
||||
message?: string
|
||||
}
|
||||
|
||||
type MessageApiResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function listInvestmentLimitProfiles(
|
||||
params?: ListInvestmentLimitProfilesParams,
|
||||
): Promise<PaginatedApiResponse<InvestmentLimitProfile>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<InvestmentLimitProfile>>(
|
||||
'/v1/investment-limit-profiles',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan profil had pelaburan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getInvestmentLimitProfile(id: string): Promise<InvestmentLimitProfile> {
|
||||
const { data } = await api.get<ProfileApiResponse>(`/v1/investment-limit-profiles/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan profil had pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createInvestmentLimitProfile(
|
||||
payload: InvestmentLimitProfilePayload,
|
||||
): Promise<InvestmentLimitProfile> {
|
||||
const { data } = await api.post<ProfileApiResponse>('/v1/investment-limit-profiles', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah profil had pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateInvestmentLimitProfile(
|
||||
id: string,
|
||||
payload: InvestmentLimitProfilePayload,
|
||||
): Promise<InvestmentLimitProfile> {
|
||||
const { data } = await api.put<ProfileApiResponse>(
|
||||
`/v1/investment-limit-profiles/${id}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini profil had pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteInvestmentLimitProfile(id: string): Promise<void> {
|
||||
const { data } = await api.delete<MessageApiResponse>(`/v1/investment-limit-profiles/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghapus profil had pelaburan.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProfileInvestmentLimits(
|
||||
profileId: string,
|
||||
): Promise<ProfileInvestmentLimit[]> {
|
||||
const { data } = await api.get<ProfileLimitsApiResponse>(
|
||||
`/v1/investment-limit-profiles/${profileId}/limits`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan had profil.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createProfileInvestmentLimit(
|
||||
profileId: string,
|
||||
payload: ProfileInvestmentLimitPayload,
|
||||
): Promise<ProfileInvestmentLimit> {
|
||||
const { data } = await api.post<ProfileLimitApiResponse>(
|
||||
`/v1/investment-limit-profiles/${profileId}/limits`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah had profil.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateProfileInvestmentLimit(
|
||||
profileId: string,
|
||||
limitId: string,
|
||||
payload: ProfileInvestmentLimitPayload,
|
||||
): Promise<ProfileInvestmentLimit> {
|
||||
const { data } = await api.put<ProfileLimitApiResponse>(
|
||||
`/v1/investment-limit-profiles/${profileId}/limits/${limitId}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini had profil.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteProfileInvestmentLimit(
|
||||
profileId: string,
|
||||
limitId: string,
|
||||
): Promise<void> {
|
||||
const { data } = await api.delete<MessageApiResponse>(
|
||||
`/v1/investment-limit-profiles/${profileId}/limits/${limitId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghapus had profil.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function listUserLimitProfiles(
|
||||
params?: ListUserLimitProfilesParams,
|
||||
): Promise<PaginatedApiResponse<UserLimitProfile>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<UserLimitProfile>>(
|
||||
'/v1/user-limit-profiles',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan tugasan profil had.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function assignUserLimitProfile(
|
||||
payload: AssignUserLimitProfilePayload,
|
||||
): Promise<UserLimitProfile> {
|
||||
const { data } = await api.post<UserLimitProfileApiResponse>(
|
||||
'/v1/user-limit-profiles',
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menugaskan profil had.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function listUserInvestmentLimits(
|
||||
params?: ListUserInvestmentLimitsParams,
|
||||
): Promise<PaginatedApiResponse<UserInvestmentLimit>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<UserInvestmentLimit>>(
|
||||
'/v1/user-investment-limits',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan had pelaburan ahli.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUserInvestmentLimit(id: string): Promise<UserInvestmentLimit> {
|
||||
const { data } = await api.get<UserInvestmentLimitApiResponse>(
|
||||
`/v1/user-investment-limits/${id}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan had pelaburan ahli.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createUserInvestmentLimit(
|
||||
payload: UserInvestmentLimitPayload,
|
||||
): Promise<UserInvestmentLimit> {
|
||||
const { data } = await api.post<UserInvestmentLimitApiResponse>(
|
||||
'/v1/user-investment-limits',
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah had pelaburan ahli.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateUserInvestmentLimit(
|
||||
id: string,
|
||||
payload: UserInvestmentLimitPayload,
|
||||
): Promise<UserInvestmentLimit> {
|
||||
const { data } = await api.put<UserInvestmentLimitApiResponse>(
|
||||
`/v1/user-investment-limits/${id}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini had pelaburan ahli.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteUserInvestmentLimit(id: string): Promise<void> {
|
||||
const { data } = await api.delete<MessageApiResponse>(`/v1/user-investment-limits/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghapus had pelaburan ahli.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
InvestmentType,
|
||||
InvestmentTypePayload,
|
||||
ListInvestmentTypesParams,
|
||||
} from '../types/investment-type.types'
|
||||
|
||||
type InvestmentTypeApiResponse = {
|
||||
success: boolean
|
||||
data: InvestmentType
|
||||
message?: string
|
||||
}
|
||||
|
||||
type DeleteInvestmentTypeApiResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function listInvestmentTypes(
|
||||
params?: ListInvestmentTypesParams,
|
||||
): Promise<PaginatedApiResponse<InvestmentType>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<InvestmentType>>('/v1/investment-types', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan senarai jenis pelaburan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getInvestmentType(id: string): Promise<InvestmentType> {
|
||||
const { data } = await api.get<InvestmentTypeApiResponse>(`/v1/investment-types/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan jenis pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createInvestmentType(
|
||||
payload: InvestmentTypePayload,
|
||||
): Promise<InvestmentType> {
|
||||
const { data } = await api.post<InvestmentTypeApiResponse>('/v1/investment-types', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah jenis pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateInvestmentType(
|
||||
id: string,
|
||||
payload: InvestmentTypePayload,
|
||||
): Promise<InvestmentType> {
|
||||
const { data } = await api.put<InvestmentTypeApiResponse>(`/v1/investment-types/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini jenis pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteInvestmentType(id: string): Promise<void> {
|
||||
const { data } = await api.delete<DeleteInvestmentTypeApiResponse>(`/v1/investment-types/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghapus jenis pelaburan.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
Investment,
|
||||
ListInvestmentsParams,
|
||||
ManualInvestmentFiles,
|
||||
ManualInvestmentPayload,
|
||||
MemberInvestmentSummary,
|
||||
} from '../types/investment.types'
|
||||
|
||||
type InvestmentApiResponse = {
|
||||
success: boolean
|
||||
data: Investment
|
||||
message?: string
|
||||
}
|
||||
|
||||
type MemberInvestmentSummaryApiResponse = {
|
||||
success: boolean
|
||||
data: MemberInvestmentSummary
|
||||
message?: string
|
||||
}
|
||||
|
||||
function appendIfPresent(formData: FormData, key: string, value: string | null | undefined) {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildManualInvestmentFormData(
|
||||
payload: ManualInvestmentPayload,
|
||||
files: ManualInvestmentFiles = {},
|
||||
): FormData {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('user_id', payload.user_id)
|
||||
formData.append('investment_type_id', payload.investment_type_id)
|
||||
formData.append('amount', String(payload.amount))
|
||||
appendIfPresent(formData, 'remarks', payload.remarks ?? undefined)
|
||||
|
||||
files.documents?.forEach((file, index) => {
|
||||
formData.append(`documents[${index}]`, file)
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
export async function listInvestments(
|
||||
params?: ListInvestmentsParams,
|
||||
): Promise<PaginatedApiResponse<Investment>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<Investment>>('/v1/investments', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan senarai pelaburan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getInvestment(id: string): Promise<Investment> {
|
||||
const { data } = await api.get<InvestmentApiResponse>(`/v1/investments/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function getMemberInvestmentSummary(): Promise<MemberInvestmentSummary> {
|
||||
const { data } = await api.get<MemberInvestmentSummaryApiResponse>('/v1/investments/summary')
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan ringkasan pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createManualInvestment(
|
||||
payload: ManualInvestmentPayload,
|
||||
files: ManualInvestmentFiles = {},
|
||||
): Promise<Investment> {
|
||||
const formData = buildManualInvestmentFormData(payload, files)
|
||||
|
||||
const { data } = await api.post<InvestmentApiResponse>('/v1/investments/manual', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal merekod pelaburan manual.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchInvestmentDocument(
|
||||
investmentId: string,
|
||||
documentId: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<Blob> {
|
||||
const response = await api.get(
|
||||
`/v1/investments/${investmentId}/documents/${documentId}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
|
||||
const contentType =
|
||||
mimeType ||
|
||||
(typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
|
||||
'application/octet-stream'
|
||||
|
||||
return new Blob([response.data], { type: contentType })
|
||||
}
|
||||
|
||||
export async function downloadInvestmentDocument(
|
||||
investmentId: string,
|
||||
documentId: string,
|
||||
fileName: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<void> {
|
||||
const blob = await fetchInvestmentDocument(investmentId, documentId, mimeType)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
GenerateSalaryDeductionBatchPayload,
|
||||
ListSalaryContributionsParams,
|
||||
ListSalaryDeductionBatchesParams,
|
||||
SalaryContribution,
|
||||
SalaryContributionPayload,
|
||||
SalaryDeductionBatch,
|
||||
SalaryDeductionBatchDetail,
|
||||
UploadSalaryDeductionBatchPayload,
|
||||
} from '../types/salary.types'
|
||||
|
||||
type ContributionApiResponse = {
|
||||
success: boolean
|
||||
data: SalaryContribution
|
||||
message?: string
|
||||
}
|
||||
|
||||
type BatchApiResponse = {
|
||||
success: boolean
|
||||
data: SalaryDeductionBatch
|
||||
message?: string
|
||||
}
|
||||
|
||||
type DetailApiResponse = {
|
||||
success: boolean
|
||||
data: SalaryDeductionBatchDetail
|
||||
message?: string
|
||||
}
|
||||
|
||||
type MessageApiResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function listSalaryContributions(
|
||||
params?: ListSalaryContributionsParams,
|
||||
): Promise<PaginatedApiResponse<SalaryContribution>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<SalaryContribution>>(
|
||||
'/v1/salary-contributions',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan caruman potongan gaji.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSalaryContribution(id: string): Promise<SalaryContribution> {
|
||||
const { data } = await api.get<ContributionApiResponse>(`/v1/salary-contributions/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan caruman potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createSalaryContribution(
|
||||
payload: SalaryContributionPayload,
|
||||
): Promise<SalaryContribution> {
|
||||
const { data } = await api.post<ContributionApiResponse>('/v1/salary-contributions', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah caruman potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateSalaryContribution(
|
||||
id: string,
|
||||
payload: SalaryContributionPayload,
|
||||
): Promise<SalaryContribution> {
|
||||
const { data } = await api.put<ContributionApiResponse>(
|
||||
`/v1/salary-contributions/${id}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini caruman potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteSalaryContribution(id: string): Promise<void> {
|
||||
const { data } = await api.delete<MessageApiResponse>(`/v1/salary-contributions/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghapus caruman potongan gaji.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSalaryDeductionBatches(
|
||||
params?: ListSalaryDeductionBatchesParams,
|
||||
): Promise<PaginatedApiResponse<SalaryDeductionBatch>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<SalaryDeductionBatch>>(
|
||||
'/v1/salary-deduction-batches',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan batch potongan gaji.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSalaryDeductionBatch(id: string): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.get<BatchApiResponse>(`/v1/salary-deduction-batches/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan batch potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function uploadSalaryDeductionBatch(
|
||||
payload: UploadSalaryDeductionBatchPayload,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>('/v1/salary-deduction-batches', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuat naik batch potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function generateSalaryDeductionBatch(
|
||||
payload: GenerateSalaryDeductionBatchPayload,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>(
|
||||
'/v1/salary-deduction-batches/generate',
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menjana batch potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function markSalaryDeductionBatchUnderReview(
|
||||
id: string,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>(
|
||||
`/v1/salary-deduction-batches/${id}/under-review`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghantar batch untuk semakan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function verifyAllSalaryDeductionBatch(
|
||||
id: string,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>(
|
||||
`/v1/salary-deduction-batches/${id}/verify-all`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengesahkan semua rekod.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function rejectSalaryDeductionBatch(
|
||||
id: string,
|
||||
remarks?: string | null,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>(
|
||||
`/v1/salary-deduction-batches/${id}/reject`,
|
||||
{ remarks: remarks ?? null },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menolak batch.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function verifySalaryDeductionDetail(
|
||||
batchId: string,
|
||||
detailId: string,
|
||||
remarks?: string | null,
|
||||
): Promise<SalaryDeductionBatchDetail> {
|
||||
const { data } = await api.post<DetailApiResponse>(
|
||||
`/v1/salary-deduction-batches/${batchId}/details/${detailId}/verify`,
|
||||
{ remarks: remarks ?? null },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengesahkan rekod.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function rejectSalaryDeductionDetail(
|
||||
batchId: string,
|
||||
detailId: string,
|
||||
remarks?: string | null,
|
||||
): Promise<SalaryDeductionBatchDetail> {
|
||||
const { data } = await api.post<DetailApiResponse>(
|
||||
`/v1/salary-deduction-batches/${batchId}/details/${detailId}/reject`,
|
||||
{ remarks: remarks ?? null },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menolak rekod.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
export type InvestmentLimitProfile = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
profile_limits_count?: number
|
||||
profile_limits?: ProfileInvestmentLimit[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type InvestmentLimitProfilePayload = {
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type ProfileInvestmentLimit = {
|
||||
id: string
|
||||
profile_id: string
|
||||
investment_type_id: string
|
||||
max_investment_amount: string | number | null
|
||||
max_transaction_amount: string | number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
investment_type?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type ProfileInvestmentLimitPayload = {
|
||||
investment_type_id: string
|
||||
max_investment_amount?: number | null
|
||||
max_transaction_amount?: number | null
|
||||
}
|
||||
|
||||
export type UserLimitProfile = {
|
||||
id: string
|
||||
user_id: string
|
||||
profile_id: string
|
||||
effective_date: string
|
||||
expiry_date: string | null
|
||||
created_at: string
|
||||
user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number?: string | null
|
||||
} | null
|
||||
profile?: {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type AssignUserLimitProfilePayload = {
|
||||
user_id: string
|
||||
profile_id: string
|
||||
effective_date: string
|
||||
}
|
||||
|
||||
export type UserInvestmentLimit = {
|
||||
id: string
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
max_investment_amount: string | number | null
|
||||
max_transaction_amount: string | number | null
|
||||
effective_date: string
|
||||
expiry_date: string | null
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number?: string | null
|
||||
} | null
|
||||
investment_type?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type UserInvestmentLimitPayload = {
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
max_investment_amount?: number | null
|
||||
max_transaction_amount?: number | null
|
||||
effective_date: string
|
||||
expiry_date?: string | null
|
||||
}
|
||||
|
||||
export type ListInvestmentLimitProfilesParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type ListUserLimitProfilesParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
user_id?: string
|
||||
active_only?: boolean | number
|
||||
}
|
||||
|
||||
export type ListUserInvestmentLimitsParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
user_id?: string
|
||||
investment_type_id?: string
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type InvestmentTypeStatus = 'ACTIVE' | 'INACTIVE' | 'CLOSED'
|
||||
|
||||
export type InvestmentType = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
status: InvestmentTypeStatus
|
||||
minimum_amount: string | number
|
||||
maximum_amount: string | number | null
|
||||
total_fund_limit: string | number | null
|
||||
reserved_amount: string | number
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
allow_billplz: boolean
|
||||
allow_salary_deduction: boolean
|
||||
billplz_collection_id: string | null
|
||||
created_by: string | null
|
||||
updated_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type InvestmentTypePayload = {
|
||||
code: string
|
||||
name: string
|
||||
description?: string | null
|
||||
status: InvestmentTypeStatus
|
||||
minimum_amount: number
|
||||
maximum_amount?: number | null
|
||||
total_fund_limit?: number | null
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
allow_billplz: boolean
|
||||
allow_salary_deduction: boolean
|
||||
billplz_collection_id?: string | null
|
||||
}
|
||||
|
||||
export type ListInvestmentTypesParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
export type InvestmentSource =
|
||||
| 'BILLPLZ'
|
||||
| 'SALARY_DEDUCTION'
|
||||
| 'MANUAL'
|
||||
| 'ADJUSTMENT'
|
||||
| 'REFUND'
|
||||
|
||||
export type InvestmentStatus =
|
||||
| 'DRAFT'
|
||||
| 'PENDING_PAYMENT'
|
||||
| 'PENDING_VERIFICATION'
|
||||
| 'COMPLETED'
|
||||
| 'REJECTED'
|
||||
| 'CANCELLED'
|
||||
|
||||
export type InvestmentUserSummary = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number?: string | null
|
||||
member_number?: number | null
|
||||
}
|
||||
|
||||
export type InvestmentTypeSummary = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type InvestmentAccountSummary = {
|
||||
id: string
|
||||
current_balance: string | number
|
||||
pending_balance: string | number
|
||||
status: string
|
||||
}
|
||||
|
||||
export type InvestmentLedgerEntry = {
|
||||
id: string
|
||||
transaction_type: string
|
||||
credit: string | number
|
||||
debit: string | number
|
||||
balance_after: string | number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type InvestmentPaymentSummary = {
|
||||
id: string
|
||||
gateway: string
|
||||
amount: string | number
|
||||
currency: string
|
||||
status: string
|
||||
external_reference: string | null
|
||||
paid_at: string | null
|
||||
billplz?: {
|
||||
bill_id: string | null
|
||||
bill_url: string | null
|
||||
state: string | null
|
||||
transaction_status: string | null
|
||||
signature_verified: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
export type InvestmentDocument = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
description: string | null
|
||||
mime_type: string | null
|
||||
file_size: number | null
|
||||
uploaded_by: string | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export type Investment = {
|
||||
id: string
|
||||
reference_no: string
|
||||
user_id: string
|
||||
investment_account_id: string
|
||||
investment_type_id: string
|
||||
amount: string | number
|
||||
source: InvestmentSource
|
||||
status: InvestmentStatus
|
||||
remarks: string | null
|
||||
created_by: string | null
|
||||
approved_by: string | null
|
||||
approved_at: string | null
|
||||
completed_at: string | null
|
||||
cancelled_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user?: InvestmentUserSummary | null
|
||||
investment_type?: InvestmentTypeSummary | null
|
||||
account?: InvestmentAccountSummary | null
|
||||
created_by_user?: { id: string; name: string; email: string } | null
|
||||
approved_by_user?: { id: string; name: string; email: string } | null
|
||||
ledger_entries?: InvestmentLedgerEntry[]
|
||||
payment?: InvestmentPaymentSummary | null
|
||||
documents?: InvestmentDocument[]
|
||||
}
|
||||
|
||||
export type ManualInvestmentPayload = {
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
amount: number
|
||||
remarks?: string | null
|
||||
}
|
||||
|
||||
export type ManualInvestmentFiles = {
|
||||
documents?: File[]
|
||||
}
|
||||
|
||||
export type ListInvestmentsParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
status?: InvestmentStatus | ''
|
||||
source?: InvestmentSource | ''
|
||||
user_id?: string
|
||||
investment_type_id?: string
|
||||
}
|
||||
|
||||
export type MemberInvestmentAccountSummary = {
|
||||
id: string
|
||||
investment_type_id: string
|
||||
current_balance: string | number
|
||||
pending_balance: string | number
|
||||
status: string
|
||||
investment_type?: InvestmentTypeSummary | null
|
||||
}
|
||||
|
||||
export type MemberRecentInvestment = {
|
||||
id: string
|
||||
reference_no: string
|
||||
amount: string | number
|
||||
source: InvestmentSource | string
|
||||
status: InvestmentStatus | string
|
||||
created_at: string | null
|
||||
completed_at: string | null
|
||||
investment_type?: InvestmentTypeSummary | null
|
||||
}
|
||||
|
||||
export type MemberInvestmentSummary = {
|
||||
total_current_balance: string | number
|
||||
total_pending_balance: string | number
|
||||
account_count: number
|
||||
transaction_count: number
|
||||
completed_count: number
|
||||
pending_count: number
|
||||
status_counts: Record<InvestmentStatus, number>
|
||||
accounts: MemberInvestmentAccountSummary[]
|
||||
recent_investments: MemberRecentInvestment[]
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
export type SalaryContributionStatus = 'ACTIVE' | 'PAUSED' | 'ENDED'
|
||||
|
||||
export type SalaryDeductionBatchStatus =
|
||||
| 'UPLOADED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'VERIFIED'
|
||||
| 'REJECTED'
|
||||
|
||||
export type SalaryDeductionDetailStatus = 'PENDING' | 'VERIFIED' | 'REJECTED'
|
||||
|
||||
export type SalaryContribution = {
|
||||
id: string
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
monthly_amount: string | number
|
||||
start_date: string
|
||||
end_date: string | null
|
||||
status: SalaryContributionStatus
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number?: string | null
|
||||
} | null
|
||||
investment_type?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type SalaryContributionPayload = {
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
monthly_amount: number
|
||||
start_date: string
|
||||
end_date?: string | null
|
||||
status: SalaryContributionStatus
|
||||
}
|
||||
|
||||
export type SalaryDeductionBatchDetail = {
|
||||
id: string
|
||||
batch_id: string
|
||||
user_id: string
|
||||
salary_contribution_id: string | null
|
||||
investment_type_id: string
|
||||
investment_id: string | null
|
||||
amount: string | number
|
||||
status: SalaryDeductionDetailStatus
|
||||
remarks: string | null
|
||||
verified_by: string | null
|
||||
verified_at: string | null
|
||||
created_at: string
|
||||
user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number?: string | null
|
||||
} | null
|
||||
investment_type?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
} | null
|
||||
investment?: {
|
||||
id: string
|
||||
reference_no: string
|
||||
status: string
|
||||
amount: string | number
|
||||
} | null
|
||||
}
|
||||
|
||||
export type SalaryDeductionBatch = {
|
||||
id: string
|
||||
batch_no: string
|
||||
month: number
|
||||
year: number
|
||||
total_records: number
|
||||
total_amount: string | number
|
||||
status: SalaryDeductionBatchStatus
|
||||
uploaded_by: string | null
|
||||
verified_by: string | null
|
||||
uploaded_at: string | null
|
||||
verified_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
uploaded_by_user?: { id: string; name: string; email: string } | null
|
||||
verified_by_user?: { id: string; name: string; email: string } | null
|
||||
details?: SalaryDeductionBatchDetail[]
|
||||
}
|
||||
|
||||
export type UploadSalaryDeductionBatchPayload = {
|
||||
month: number
|
||||
year: number
|
||||
details: Array<{
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
amount: number
|
||||
salary_contribution_id?: string | null
|
||||
remarks?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export type GenerateSalaryDeductionBatchPayload = {
|
||||
month: number
|
||||
year: number
|
||||
}
|
||||
|
||||
export type ListSalaryContributionsParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
status?: SalaryContributionStatus | ''
|
||||
user_id?: string
|
||||
}
|
||||
|
||||
export type ListSalaryDeductionBatchesParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
status?: SalaryDeductionBatchStatus | ''
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
import type { InvestmentTypeStatus } from '../types/investment-type.types'
|
||||
|
||||
export const INVESTMENT_TYPE_STATUS_OPTIONS: { label: string; value: InvestmentTypeStatus }[] = [
|
||||
{ label: 'Aktif', value: 'ACTIVE' },
|
||||
{ label: 'Tidak Aktif', value: 'INACTIVE' },
|
||||
{ label: 'Ditutup', value: 'CLOSED' },
|
||||
]
|
||||
|
||||
export function investmentTypeStatusLabel(status: InvestmentTypeStatus): string {
|
||||
return INVESTMENT_TYPE_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function investmentTypeStatusVariant(
|
||||
status: InvestmentTypeStatus,
|
||||
): BadgeVariants['variant'] {
|
||||
if (status === 'ACTIVE') return 'success'
|
||||
if (status === 'INACTIVE') return 'pending'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
export function formatInvestmentAmount(value: string | number | null | undefined): string {
|
||||
if (value == null || value === '') return '-'
|
||||
|
||||
const amount = typeof value === 'number' ? value : Number(value)
|
||||
if (Number.isNaN(amount)) return '-'
|
||||
|
||||
return new Intl.NumberFormat('ms-MY', {
|
||||
style: 'currency',
|
||||
currency: 'MYR',
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export function toOptionalNumber(value: string | number | null | undefined): number | null {
|
||||
if (value == null || value === '') return null
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return Number.isNaN(value) ? null : value
|
||||
}
|
||||
|
||||
const trimmed = String(value).trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const amount = Number(trimmed)
|
||||
return Number.isNaN(amount) ? null : amount
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
import type { InvestmentSource, InvestmentStatus } from '../types/investment.types'
|
||||
import { formatInvestmentAmount } from './investment-type.utils'
|
||||
|
||||
export { formatInvestmentAmount }
|
||||
|
||||
export const INVESTMENT_STATUS_OPTIONS: { label: string; value: InvestmentStatus }[] = [
|
||||
{ label: 'Draf', value: 'DRAFT' },
|
||||
{ label: 'Menunggu Bayaran', value: 'PENDING_PAYMENT' },
|
||||
{ label: 'Menunggu Pengesahan', value: 'PENDING_VERIFICATION' },
|
||||
{ label: 'Selesai', value: 'COMPLETED' },
|
||||
{ label: 'Ditolak', value: 'REJECTED' },
|
||||
{ label: 'Dibatalkan', value: 'CANCELLED' },
|
||||
]
|
||||
|
||||
export const INVESTMENT_SOURCE_OPTIONS: { label: string; value: InvestmentSource }[] = [
|
||||
{ label: 'Manual', value: 'MANUAL' },
|
||||
{ label: 'Billplz', value: 'BILLPLZ' },
|
||||
{ label: 'Potongan Gaji', value: 'SALARY_DEDUCTION' },
|
||||
{ label: 'Pelarasan', value: 'ADJUSTMENT' },
|
||||
{ label: 'Bayaran Balik', value: 'REFUND' },
|
||||
]
|
||||
|
||||
export function investmentStatusLabel(status: InvestmentStatus | string): string {
|
||||
return INVESTMENT_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function investmentSourceLabel(source: InvestmentSource | string): string {
|
||||
return INVESTMENT_SOURCE_OPTIONS.find((option) => option.value === source)?.label ?? source
|
||||
}
|
||||
|
||||
export function investmentStatusVariant(
|
||||
status: InvestmentStatus | string,
|
||||
): BadgeVariants['variant'] {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'success'
|
||||
case 'PENDING_PAYMENT':
|
||||
case 'PENDING_VERIFICATION':
|
||||
return 'pending'
|
||||
case 'REJECTED':
|
||||
case 'CANCELLED':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
import type {
|
||||
SalaryContributionStatus,
|
||||
SalaryDeductionBatchStatus,
|
||||
SalaryDeductionDetailStatus,
|
||||
} from '../types/salary.types'
|
||||
import { formatInvestmentAmount } from './investment-type.utils'
|
||||
|
||||
export { formatInvestmentAmount }
|
||||
|
||||
export const SALARY_CONTRIBUTION_STATUS_OPTIONS: {
|
||||
label: string
|
||||
value: SalaryContributionStatus
|
||||
}[] = [
|
||||
{ label: 'Aktif', value: 'ACTIVE' },
|
||||
{ label: 'Ditangguh', value: 'PAUSED' },
|
||||
{ label: 'Tamat', value: 'ENDED' },
|
||||
]
|
||||
|
||||
export const SALARY_BATCH_STATUS_OPTIONS: {
|
||||
label: string
|
||||
value: SalaryDeductionBatchStatus
|
||||
}[] = [
|
||||
{ label: 'Dimuat Naik', value: 'UPLOADED' },
|
||||
{ label: 'Dalam Semakan', value: 'UNDER_REVIEW' },
|
||||
{ label: 'Disahkan', value: 'VERIFIED' },
|
||||
{ label: 'Ditolak', value: 'REJECTED' },
|
||||
]
|
||||
|
||||
export const SALARY_DETAIL_STATUS_OPTIONS: {
|
||||
label: string
|
||||
value: SalaryDeductionDetailStatus
|
||||
}[] = [
|
||||
{ label: 'Pending', value: 'PENDING' },
|
||||
{ label: 'Disahkan', value: 'VERIFIED' },
|
||||
{ label: 'Ditolak', value: 'REJECTED' },
|
||||
]
|
||||
|
||||
export const MONTH_OPTIONS = [
|
||||
{ label: 'Januari', value: 1 },
|
||||
{ label: 'Februari', value: 2 },
|
||||
{ label: 'Mac', value: 3 },
|
||||
{ label: 'April', value: 4 },
|
||||
{ label: 'Mei', value: 5 },
|
||||
{ label: 'Jun', value: 6 },
|
||||
{ label: 'Julai', value: 7 },
|
||||
{ label: 'Ogos', value: 8 },
|
||||
{ label: 'September', value: 9 },
|
||||
{ label: 'Oktober', value: 10 },
|
||||
{ label: 'November', value: 11 },
|
||||
{ label: 'Disember', value: 12 },
|
||||
]
|
||||
|
||||
export function salaryContributionStatusLabel(status: string): string {
|
||||
return SALARY_CONTRIBUTION_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function salaryBatchStatusLabel(status: string): string {
|
||||
return SALARY_BATCH_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function salaryDetailStatusLabel(status: string): string {
|
||||
return SALARY_DETAIL_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function salaryContributionStatusVariant(status: string): BadgeVariants['variant'] {
|
||||
if (status === 'ACTIVE') return 'success'
|
||||
if (status === 'PAUSED') return 'pending'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export function salaryBatchStatusVariant(status: string): BadgeVariants['variant'] {
|
||||
if (status === 'VERIFIED') return 'success'
|
||||
if (status === 'UNDER_REVIEW') return 'pending'
|
||||
if (status === 'REJECTED') return 'danger'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export function salaryDetailStatusVariant(status: string): BadgeVariants['variant'] {
|
||||
if (status === 'VERIFIED') return 'success'
|
||||
if (status === 'REJECTED') return 'danger'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
export function monthLabel(month: number): string {
|
||||
return MONTH_OPTIONS.find((item) => item.value === month)?.label ?? String(month)
|
||||
}
|
||||
@@ -136,13 +136,10 @@ async function handleSubmit() {
|
||||
|
||||
if (!status) throw new Error('Status pengguna diperlukan.')
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user