mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-25 15:55:48 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4b61cfe05 | |||
| c59a88bbcb | |||
| a8b1f6f8c2 | |||
| 6e582c4e7c | |||
| f011f5b7f9 | |||
| 1d4a16cd0b | |||
| 2a71734583 | |||
| e9eee6eb3e | |||
| 6a070e39da | |||
| 3b69286a8e | |||
| 0de2e87221 | |||
| 9daa6aaf8c | |||
| a12ae51f3a | |||
| 17c316c4af | |||
| c438a8f6aa | |||
| afa0c6c83f | |||
| 1afa6f7fad | |||
| ae560c2451 | |||
| 176e1e7cb8 | |||
| f6d4ef4697 | |||
| 33c0d5513c | |||
| 15893c18c9 | |||
| 026e60704b | |||
| 21b155e64b | |||
| 1a1bf8754e | |||
| a51c7c44ec | |||
| 4d1241d712 | |||
| 80747bb441 | |||
| 00f64b1897 | |||
| 01bfe1cc3b | |||
| a0cf6c0f56 | |||
| c72befb5b4 |
+62
-135
@@ -1,149 +1,76 @@
|
|||||||
stages:
|
stages: [notify]
|
||||||
- build
|
|
||||||
- deploy
|
|
||||||
|
|
||||||
# ====== TEMPLATE: BUILD STATIC NEXT.JS ======
|
# --- Notify when MR is opened/updated ---
|
||||||
.build_template: &build_template
|
notify_discord_mr:
|
||||||
stage: build
|
stage: notify
|
||||||
image: node:20-alpine
|
image: alpine:3.20
|
||||||
cache:
|
rules:
|
||||||
key: npm-cache
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||||
paths:
|
|
||||||
- node_modules/
|
|
||||||
variables:
|
variables:
|
||||||
NPM_CONFIG_PRODUCTION: "false"
|
WEBHOOK_URL: $DISCORD_WEBHOOK_URL
|
||||||
NODE_ENV: ""
|
before_script:
|
||||||
script:
|
- apk add --no-cache curl jq
|
||||||
- echo "Installing dependencies..."
|
script: |
|
||||||
- npm ci --no-audit --no-fund
|
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
|
||||||
- echo "Building Next.js static export..."
|
|
||||||
- npx next build
|
|
||||||
artifacts:
|
|
||||||
name: "out-$CI_COMMIT_SHORT_SHA"
|
|
||||||
paths:
|
|
||||||
- out/
|
|
||||||
expire_in: 1 week
|
|
||||||
|
|
||||||
.deploy_template: &deploy_template
|
|
||||||
stage: deploy
|
|
||||||
image:
|
|
||||||
name: amazon/aws-cli:latest
|
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
|
||||||
script:
|
|
||||||
- set -e
|
|
||||||
- aws --version
|
|
||||||
- echo "Cleaning up newline characters in AWS credentials..."
|
|
||||||
- export AWS_ACCESS_KEY_ID=$(echo $AWS_ACCESS_KEY_ID | tr -d '\r\n')
|
|
||||||
- export AWS_SECRET_ACCESS_KEY=$(echo $AWS_SECRET_ACCESS_KEY | tr -d '\r\n')
|
|
||||||
- echo "Deploying to s3://$S3_BUCKET in region $AWS_REGION"
|
|
||||||
- aws s3api head-bucket --bucket "$S3_BUCKET" --region "$AWS_REGION" || aws s3api create-bucket --bucket "$S3_BUCKET" --region "$AWS_REGION" --create-bucket-configuration LocationConstraint="$AWS_REGION"
|
|
||||||
- aws s3 sync ./out "s3://$S3_BUCKET" --delete --region "$AWS_REGION" --endpoint-url "https://s3.ap-southeast-3.amazonaws.com"
|
|
||||||
|
|
||||||
# CloudFront invalidation
|
|
||||||
- |
|
|
||||||
STATUS="success"
|
|
||||||
if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then
|
|
||||||
echo "Invalidating CloudFront cache..."
|
|
||||||
if ! aws cloudfront create-invalidation --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" --paths "/*"; then
|
|
||||||
echo "CloudFront invalidation failed."
|
|
||||||
STATUS="failed"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "No CloudFront distribution specified — skipping invalidation"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Notifikasi Discord
|
|
||||||
- |
|
|
||||||
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"
|
|
||||||
|
|
||||||
# Tentukan nama environment
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "devops-s3" ]; then
|
|
||||||
ENVIRONMENT_NAME="WEB-LTI-DEV"
|
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "master" ]; then
|
|
||||||
ENVIRONMENT_NAME="WEB-LTI-PROD"
|
|
||||||
else
|
|
||||||
ENVIRONMENT_NAME="UNKNOWN"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$STATUS" = "success" ]; then
|
|
||||||
COLOR=3066993
|
|
||||||
TITLE="✅ Deployment ${ENVIRONMENT_NAME} Succeeded"
|
|
||||||
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."
|
|
||||||
else
|
|
||||||
COLOR=15158332
|
|
||||||
TITLE="❌ Deployment ${ENVIRONMENT_NAME} Failed"
|
|
||||||
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` encountered issues."
|
|
||||||
fi
|
|
||||||
|
|
||||||
jq -n \
|
jq -n \
|
||||||
--arg title "$TITLE" \
|
|
||||||
--arg desc "$DESC" \
|
|
||||||
--arg color "$COLOR" \
|
|
||||||
--arg repo "$CI_PROJECT_PATH" \
|
--arg repo "$CI_PROJECT_PATH" \
|
||||||
--arg actor "$GITLAB_USER_LOGIN" \
|
--arg mr "#${CI_MERGE_REQUEST_IID}" \
|
||||||
--arg commit "$CI_COMMIT_SHA" \
|
--arg url "$MR_URL" \
|
||||||
--arg run_url "$RUN_URL" \
|
--arg requestor "${GITLAB_USER_LOGIN:-$GITLAB_USER_NAME}" \
|
||||||
|
--arg source "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" \
|
||||||
|
--arg target "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \
|
||||||
|
--arg title "$CI_MERGE_REQUEST_TITLE" \
|
||||||
'{
|
'{
|
||||||
username: "CI Bot - LTI WEB",
|
username: "CI Bot - FE",
|
||||||
embeds: [{
|
embeds: [{
|
||||||
title: $title,
|
title: "📣 [LTI WEB CLIENT] Merge Request Opened/Updated",
|
||||||
description: $desc,
|
description: ($mr + " in " + $repo),
|
||||||
color: ($color|tonumber),
|
url: $url,
|
||||||
|
color: 3447003,
|
||||||
fields: [
|
fields: [
|
||||||
{name: "Repository", value: $repo, inline: true},
|
{name: "Author", value: $requestor, inline: true},
|
||||||
{name: "Actor", value: $actor, inline: true},
|
{name: "Source → Target", value: ($source + " → " + $target), inline: true},
|
||||||
{name: "Commit", value: $commit, inline: false},
|
{name: "Title", value: $title}
|
||||||
{name: "Pipeline", value: ("[Open run](" + $run_url + ")"), inline: false}
|
|
||||||
]
|
]
|
||||||
}]
|
}]
|
||||||
}' > payload.json
|
}' \
|
||||||
|
| curl -sS -H "Content-Type: application/json" -d @- "$WEBHOOK_URL"
|
||||||
|
|
||||||
curl -sS -H "Content-Type: application/json" -d @payload.json "$DISCORD_WEBHOOK_URL"
|
# --- Notify when MR is merged ---
|
||||||
|
notify_discord_merge:
|
||||||
# ====== DEVELOPMENT (Branch devops-s3) ======
|
stage: notify
|
||||||
build:dev:
|
image: alpine:3.20
|
||||||
<<: *build_template
|
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_COMMIT_BRANCH == "devops-s3"'
|
# Only run for merge request pipelines that are in merged state
|
||||||
environment:
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged"'
|
||||||
name: devops-s3
|
|
||||||
variables:
|
variables:
|
||||||
NEXT_PUBLIC_API_BASE_URL: "https://dev-api-lti.mbugroup.id"
|
WEBHOOK_URL: $DISCORD_WEBHOOK_URL
|
||||||
NEXT_PUBLIC_SSO_LOGIN_URL: "https://dev-api-sso.mbugroup.id"
|
before_script:
|
||||||
|
- apk add --no-cache curl jq
|
||||||
deploy:dev:
|
script: |
|
||||||
<<: *deploy_template
|
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
|
||||||
needs: ["build:dev"]
|
|
||||||
rules:
|
|
||||||
- if: '$CI_COMMIT_BRANCH == "devops-s3"'
|
|
||||||
variables:
|
|
||||||
S3_BUCKET: "dev-lti-erp.mbugroup.id"
|
|
||||||
AWS_REGION: "ap-southeast-3"
|
|
||||||
CLOUDFRONT_DISTRIBUTION_ID: "E1Z8XTA8XF1GIV"
|
|
||||||
environment:
|
|
||||||
name: devops-s3
|
|
||||||
url: https://dev-lti-erp.mbugroup.id
|
|
||||||
|
|
||||||
# ====== PRODUCTION ======
|
|
||||||
# build:production:
|
|
||||||
# <<: *build_template
|
|
||||||
# rules:
|
|
||||||
# # pilih salah satu: pakai branch master ATAU pakai tags rilis
|
|
||||||
# - if: '$CI_COMMIT_BRANCH == "master"'
|
|
||||||
# # - if: '$CI_COMMIT_TAG' # kalau mau rilis via tag, uncomment ini dan hapus baris di atas
|
|
||||||
# environment:
|
|
||||||
# name: production
|
|
||||||
|
|
||||||
# deploy:production:
|
|
||||||
# <<: *deploy_template
|
|
||||||
# needs: ["build:production"]
|
|
||||||
# rules:
|
|
||||||
# - if: '$CI_COMMIT_BRANCH == "master"'
|
|
||||||
# # - if: '$CI_COMMIT_TAG' # selaras dengan rule di build:production
|
|
||||||
# variables:
|
|
||||||
# S3_BUCKET: "lti-erp.mbugroup.id"
|
|
||||||
# CLOUDFRONT_DISTRIBUTION_ID: "ddfd"
|
|
||||||
# environment:
|
|
||||||
# name: production
|
|
||||||
# url: https://royalgoldcapital.com
|
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg repo "$CI_PROJECT_PATH" \
|
||||||
|
--arg mr "#${CI_MERGE_REQUEST_IID}" \
|
||||||
|
--arg url "$MR_URL" \
|
||||||
|
--arg requestor "${CI_MERGE_REQUEST_AUTHOR}" \
|
||||||
|
--arg source "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" \
|
||||||
|
--arg target "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \
|
||||||
|
--arg title "$CI_MERGE_REQUEST_TITLE" \
|
||||||
|
'{
|
||||||
|
username: "CI Bot - FE",
|
||||||
|
embeds: [{
|
||||||
|
title: "✅ [LTI WEB CLIENT] Merge Request Merged",
|
||||||
|
description: ($mr + " has been merged into " + $repo),
|
||||||
|
url: $url,
|
||||||
|
color: 3066993,
|
||||||
|
fields: [
|
||||||
|
{name: "Author", value: $requestor, inline: true},
|
||||||
|
{name: "Source → Target", value: ($source + " → " + $target), inline: true},
|
||||||
|
{name: "Title", value: $title}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}' \
|
||||||
|
| curl -sS -H "Content-Type: application/json" -d @- "$WEBHOOK_URL"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
npm run format
|
||||||
npm run lint
|
npm run lint
|
||||||
npm run build
|
npm run build
|
||||||
|
|||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
RUN apk add --no-cache git bash build-base curl
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Buat config agar Next tahu output: export
|
|
||||||
RUN echo "const config = { output: 'export', images: { unoptimized: true } }; export default config;" > next.config.mjs
|
|
||||||
|
|
||||||
# Build project (Next.js 15 otomatis static export)
|
|
||||||
RUN NEXT_DISABLE_TURBOPACK=1 npx next build
|
|
||||||
|
|
||||||
# Copy static assets dan hasil build agar bisa diakses
|
|
||||||
RUN mkdir -p .next/server/app/_next && \
|
|
||||||
cp -r .next/static .next/server/app/_next/static && \
|
|
||||||
cp -r public/* .next/server/app/
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
|
||||||
dev-web-lti:
|
|
||||||
container_name: dev-web-lti
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
ports:
|
|
||||||
- "3002:3000"
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
APP_ENV: production
|
|
||||||
networks:
|
|
||||||
- dev-lti-network
|
|
||||||
restart: always
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: "3.0"
|
|
||||||
memory: 3G
|
|
||||||
reservations:
|
|
||||||
cpus: "1.0"
|
|
||||||
memory: 512M
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
# Optional: aktifkan healthcheck jika punya endpoint
|
|
||||||
# healthcheck:
|
|
||||||
# test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/healthz || exit 1"]
|
|
||||||
# interval: 10s
|
|
||||||
# timeout: 3s
|
|
||||||
# retries: 10
|
|
||||||
# start_period: 15s
|
|
||||||
|
|
||||||
networks:
|
|
||||||
dev-lti-network:
|
|
||||||
external: true
|
|
||||||
Generated
+83
@@ -17,7 +17,9 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-number-format": "^5.4.4",
|
"react-number-format": "^5.4.4",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
@@ -196,6 +198,12 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@date-fns/tz": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz",
|
||||||
@@ -2517,6 +2525,15 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/attr-accept": {
|
||||||
|
"version": "2.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||||
|
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -2873,6 +2890,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/date-fns-jalali": {
|
||||||
|
"version": "4.1.0-0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
|
||||||
|
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -3721,6 +3754,18 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-selector": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -5749,6 +5794,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-day-picker": {
|
||||||
|
"version": "9.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
|
||||||
|
"integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@date-fns/tz": "^1.4.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"date-fns-jalali": "^4.1.0-0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/gpbl"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
@@ -5761,6 +5827,23 @@
|
|||||||
"react": "^19.1.0"
|
"react": "^19.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dropzone": {
|
||||||
|
"version": "14.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||||
|
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"attr-accept": "^2.2.4",
|
||||||
|
"file-selector": "^2.1.0",
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.13"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.8 || 18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-fast-compare": {
|
"node_modules/react-fast-compare": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-number-format": "^5.4.4",
|
"react-number-format": "^5.4.4",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm';
|
||||||
|
|
||||||
|
const AddExpense = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<ExpenseRequestForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddExpense;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ExpensesTable from '@/components/pages/expense/ExpensesTable';
|
||||||
|
|
||||||
|
const Expense = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<ExpensesTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Expense;
|
||||||
@@ -3,7 +3,7 @@ import Link from 'next/link';
|
|||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import { Color } from '@/types/theme';
|
import { Color } from '@/types/theme';
|
||||||
|
|
||||||
interface ButtonProps extends react.ComponentProps<'button'> {
|
export interface ButtonProps extends react.ComponentProps<'button'> {
|
||||||
variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active';
|
variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active';
|
||||||
color?: Color;
|
color?: Color;
|
||||||
href?: string;
|
href?: string;
|
||||||
|
|||||||
+38
-23
@@ -1,6 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
|
import {
|
||||||
|
ReactNode,
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
export const useModal = () => {
|
export const useModal = () => {
|
||||||
@@ -8,31 +15,35 @@ export const useModal = () => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const openModal = useCallback(() => {
|
const openModal = useCallback(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
ref.current.showModal();
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
|
||||||
ref.current?.showModal();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
ref.current.close();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
ref.current?.close();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (open) {
|
open ? closeModal() : openModal();
|
||||||
closeModal();
|
|
||||||
} else {
|
|
||||||
openModal();
|
|
||||||
}
|
|
||||||
}, [open, closeModal, openModal]);
|
}, [open, closeModal, openModal]);
|
||||||
|
|
||||||
if (ref.current) {
|
// Gunakan useEffect agar event listener tidak didaftarkan berulang kali
|
||||||
ref.current.addEventListener('close', () => {
|
useEffect(() => {
|
||||||
closeModal();
|
const dialog = ref.current;
|
||||||
});
|
if (!dialog) return;
|
||||||
}
|
|
||||||
|
|
||||||
return { ref, open, setOpen, openModal, closeModal, toggle } as const;
|
const handleClose = () => setOpen(false);
|
||||||
|
dialog.addEventListener('close', handleClose);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener('close', handleClose);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ref, open, openModal, closeModal, toggle } as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
@@ -46,15 +57,19 @@ interface ModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
|
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
|
||||||
return (
|
const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
||||||
<dialog ref={ref} className={cn('modal', className?.modal)}>
|
if (closeOnBackdrop && e.target === ref.current) {
|
||||||
<div className={cn('modal-box', className?.modalBox)}>{children}</div>
|
ref.current?.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
{closeOnBackdrop && (
|
return (
|
||||||
<form method='dialog' className='modal-backdrop'>
|
<dialog
|
||||||
<button>close</button>
|
ref={ref}
|
||||||
</form>
|
className={cn('modal', className?.modal)}
|
||||||
)}
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div className={cn('modal-box', className?.modalBox)}>{children}</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react';
|
import {
|
||||||
|
ChangeEventHandler,
|
||||||
import { cn } from '@/lib/helper';
|
FocusEventHandler,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { cn, formatDate } from '@/lib/helper';
|
||||||
|
import Modal, { useModal } from '../Modal';
|
||||||
|
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
|
||||||
|
import 'react-day-picker/dist/style.css';
|
||||||
|
import Button from '../Button';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
export interface DateInputProps {
|
export interface DateInputProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
bottomLabel?: string;
|
bottomLabel?: string;
|
||||||
name: string;
|
name: string;
|
||||||
value?: string;
|
value?: string | { from?: string; to?: string };
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
min?: string;
|
min?: string;
|
||||||
max?: string;
|
max?: string;
|
||||||
@@ -24,9 +33,8 @@ export interface DateInputProps {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
isRange?: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
startAdornment?: ReactNode;
|
|
||||||
endAdornment?: ReactNode;
|
|
||||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
@@ -36,22 +44,144 @@ const DateInput = ({
|
|||||||
bottomLabel,
|
bottomLabel,
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder = 'dd/mm/yyyy',
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
className,
|
className,
|
||||||
isError,
|
isError: externalError,
|
||||||
isValid,
|
isValid: externalValid,
|
||||||
errorMessage,
|
errorMessage: externalErrorMessage,
|
||||||
startAdornment,
|
|
||||||
endAdornment,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
isRange = false,
|
||||||
}: DateInputProps) => {
|
}: DateInputProps) => {
|
||||||
|
const [internalError, setInternalError] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<Date | undefined>();
|
||||||
|
const [selectedRange, setSelectedRange] = useState<{
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
}>({});
|
||||||
|
const [displayValue, setDisplayValue] = useState<string>('');
|
||||||
|
|
||||||
|
const minDate = min
|
||||||
|
? new Date(min.split('/').reverse().join('-'))
|
||||||
|
: undefined;
|
||||||
|
const maxDate = max
|
||||||
|
? new Date(max.split('/').reverse().join('-'))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const calendarModal = useModal();
|
||||||
|
|
||||||
|
// --- Sync value props ---
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) return;
|
||||||
|
if (isRange && typeof value === 'object') {
|
||||||
|
const from = value.from ? new Date(value.from) : undefined;
|
||||||
|
const to = value.to ? new Date(value.to) : undefined;
|
||||||
|
setSelectedRange({ from, to });
|
||||||
|
setDisplayValue(
|
||||||
|
`${from ? formatDate(from, 'DD/MM/YYYY') : ''} ${
|
||||||
|
to ? '- ' + formatDate(to, 'DD/MM/YYYY') : ''
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
const iso = value.includes('/')
|
||||||
|
? value.split('/').reverse().join('-')
|
||||||
|
: value;
|
||||||
|
const date = new Date(iso);
|
||||||
|
setSelected(date);
|
||||||
|
setDisplayValue(formatDate(iso, 'DD/MM/YYYY'));
|
||||||
|
}
|
||||||
|
}, [value, isRange]);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!disabled && !readOnly) calendarModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
onBlur?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSingle = (selectedDate?: Date) => {
|
||||||
|
if (!selectedDate) return;
|
||||||
|
if (minDate && selectedDate < minDate) {
|
||||||
|
setInternalError(`Tanggal tidak boleh sebelum ${min}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (maxDate && selectedDate > maxDate) {
|
||||||
|
setInternalError(`Tanggal tidak boleh setelah ${max}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInternalError(null);
|
||||||
|
setSelected(selectedDate);
|
||||||
|
const formattedDisplay = formatDate(selectedDate, 'DD/MM/YYYY');
|
||||||
|
const formattedISO = formatDate(selectedDate, 'YYYY-MM-DD');
|
||||||
|
setDisplayValue(formattedDisplay);
|
||||||
|
|
||||||
|
const syntheticEvent = {
|
||||||
|
target: { name, value: formattedISO },
|
||||||
|
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||||
|
onChange?.(syntheticEvent);
|
||||||
|
calendarModal.closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRange = (range?: { from?: Date; to?: Date }) => {
|
||||||
|
if (!range) return;
|
||||||
|
setSelectedRange(range);
|
||||||
|
|
||||||
|
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
|
||||||
|
const toStr = range.to ? formatDate(range.to, 'DD/MM/YYYY') : '';
|
||||||
|
setDisplayValue(`${fromStr}${toStr ? ' - ' + toStr : ''}`);
|
||||||
|
|
||||||
|
// Jika kedua tanggal sudah terpilih
|
||||||
|
if (range.from && range.to) {
|
||||||
|
if (minDate && range.from < minDate) {
|
||||||
|
setInternalError(`Tanggal mulai tidak boleh sebelum ${min}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (maxDate && range.to > maxDate) {
|
||||||
|
setInternalError(`Tanggal akhir tidak boleh setelah ${max}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInternalError(null);
|
||||||
|
const syntheticEvent = {
|
||||||
|
target: {
|
||||||
|
name,
|
||||||
|
value: {
|
||||||
|
from: formatDate(range.from, 'YYYY-MM-DD'),
|
||||||
|
to: formatDate(range.to, 'YYYY-MM-DD'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||||
|
onChange?.(syntheticEvent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetDate = () => {
|
||||||
|
setSelected(undefined);
|
||||||
|
setSelectedRange({});
|
||||||
|
setDisplayValue('');
|
||||||
|
const syntheticEvent = {
|
||||||
|
target: { name, value: isRange ? { from: '', to: '' } : '' },
|
||||||
|
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||||
|
onChange?.(syntheticEvent);
|
||||||
|
calendarModal.closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDate = () => {
|
||||||
|
if (internalError) return;
|
||||||
|
calendarModal.closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalIsError = externalError || !!internalError;
|
||||||
|
const finalErrorMessage = internalError || externalErrorMessage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -64,65 +194,136 @@ const DateInput = ({
|
|||||||
htmlFor={name}
|
htmlFor={name}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-sm font-normal leading-5',
|
'w-full text-sm font-normal leading-5',
|
||||||
{
|
{ 'text-error': finalIsError },
|
||||||
'text-error': isError,
|
|
||||||
},
|
|
||||||
className?.label
|
className?.label
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && (
|
{required && (
|
||||||
<>
|
<span className='text-error' title='required'>
|
||||||
{' '}
|
{' '}
|
||||||
<span className='tooltip tooltip-error' data-tip='required'>
|
*
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</span>
|
</span>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200 flex items-center',
|
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border',
|
||||||
{
|
{
|
||||||
'border-error': isError,
|
'border-error': finalIsError,
|
||||||
'border-success!': isValid,
|
'border-success': externalValid && !finalIsError,
|
||||||
},
|
},
|
||||||
className?.inputWrapper
|
className?.inputWrapper
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{startAdornment && startAdornment}
|
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type='date'
|
type='text'
|
||||||
id={name}
|
id={name}
|
||||||
name={name}
|
name={name}
|
||||||
placeholder={placeholder}
|
placeholder={isRange ? 'dd/mm/yyyy - dd/mm/yyyy' : placeholder}
|
||||||
value={value}
|
value={displayValue}
|
||||||
onChange={onChange}
|
onBlur={handleBlur}
|
||||||
onBlur={onBlur}
|
onClick={handleClick}
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn('grow bg-transparent cursor-pointer', className?.input)}
|
readOnly // ✅ tidak bisa diketik manual
|
||||||
readOnly={readOnly}
|
className={cn(
|
||||||
|
'grow bg-transparent cursor-pointer focus:outline-none',
|
||||||
|
className?.input
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(isLoading || endAdornment) && (
|
{isLoading && (
|
||||||
<div className='flex flex-row gap-2'>
|
<div className='flex flex-row gap-2'>
|
||||||
{isLoading && <span className='loading loading-spinner' />}
|
<span className='loading loading-spinner' />
|
||||||
{endAdornment && endAdornment}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Icon
|
||||||
|
icon='uil:calendar'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='cursor-pointer text-dark'
|
||||||
|
onClick={(e) =>
|
||||||
|
handleClick(e as unknown as React.MouseEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isError && bottomLabel && (
|
{!finalIsError && bottomLabel && (
|
||||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||||
)}
|
)}
|
||||||
{isError && errorMessage && (
|
{finalIsError && finalErrorMessage && (
|
||||||
<p className='w-full text-sm text-error'>{errorMessage}</p>
|
<p className='w-full text-sm text-error'>{finalErrorMessage}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
ref={calendarModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'rounded',
|
||||||
|
modalBox: `w-fit min-h-${isRange ? '124' : '110'} flex flex-col`,
|
||||||
|
}}
|
||||||
|
closeOnBackdrop
|
||||||
|
>
|
||||||
|
{isRange ? (
|
||||||
|
<DayPicker
|
||||||
|
required={required}
|
||||||
|
mode='range'
|
||||||
|
captionLayout='dropdown-years'
|
||||||
|
navLayout='around'
|
||||||
|
reverseYears
|
||||||
|
defaultMonth={selectedRange.from ?? new Date()}
|
||||||
|
startMonth={minDate ?? new Date(1999, 1)}
|
||||||
|
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
|
||||||
|
selected={selectedRange as DateRange}
|
||||||
|
onSelect={handleSelectRange}
|
||||||
|
footer={<div className='text-center mt-3'>{displayValue}</div>}
|
||||||
|
disabled={
|
||||||
|
[
|
||||||
|
minDate ? { before: minDate } : undefined,
|
||||||
|
maxDate ? { after: maxDate } : undefined,
|
||||||
|
].filter(Boolean) as Matcher[]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DayPicker
|
||||||
|
required={required}
|
||||||
|
mode='single'
|
||||||
|
captionLayout='dropdown-years'
|
||||||
|
navLayout='around'
|
||||||
|
reverseYears
|
||||||
|
defaultMonth={selected ?? new Date()}
|
||||||
|
startMonth={minDate ?? new Date(1999, 1)}
|
||||||
|
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={handleSelectSingle}
|
||||||
|
disabled={
|
||||||
|
[
|
||||||
|
minDate ? { before: minDate } : undefined,
|
||||||
|
maxDate ? { after: maxDate } : undefined,
|
||||||
|
].filter(Boolean) as Matcher[]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className='mt-auto flex flex-col gap-2'>
|
||||||
|
{isRange && (
|
||||||
|
<small className='text-secondary'>
|
||||||
|
Tekan dua kali untuk memilih tanggal awal
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex h-full justify-end items-end gap-2'>
|
||||||
|
<Button type='button' color='warning' onClick={handleResetDate}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
{isRange && (
|
||||||
|
<Button type='button' onClick={handleSaveDate}>
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useDropzone, type Accept } from 'react-dropzone';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
interface DropFileInputProps {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
bottomLabel?: string;
|
||||||
|
caption?: string;
|
||||||
|
values?: File[];
|
||||||
|
accept?: Accept;
|
||||||
|
required?: boolean;
|
||||||
|
maxFiles?: number; // defaults to 1
|
||||||
|
maxSize?: number; // defaults to 2097152 (2 MB)
|
||||||
|
isError?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange?: (files: File[]) => void;
|
||||||
|
onDelete?: (index: number) => void;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
inputContainer?: string;
|
||||||
|
label?: string;
|
||||||
|
inputWrapper?: string;
|
||||||
|
caption?: string;
|
||||||
|
bottomLabel?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
fileItemContainer?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropFileInput: React.FC<DropFileInputProps> = ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
bottomLabel,
|
||||||
|
caption = 'Seret atau Pilih Dokumen',
|
||||||
|
values,
|
||||||
|
accept,
|
||||||
|
required,
|
||||||
|
maxFiles = Infinity,
|
||||||
|
maxSize,
|
||||||
|
isError,
|
||||||
|
errorMessage,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
onDelete,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const isDisabled =
|
||||||
|
Boolean(values && maxFiles && values.length >= maxFiles) || disabled;
|
||||||
|
|
||||||
|
const {
|
||||||
|
acceptedFiles,
|
||||||
|
getRootProps,
|
||||||
|
getInputProps,
|
||||||
|
isFocused,
|
||||||
|
isDragAccept,
|
||||||
|
isDragReject,
|
||||||
|
} = useDropzone({
|
||||||
|
maxSize,
|
||||||
|
maxFiles,
|
||||||
|
accept: accept,
|
||||||
|
disabled: isDisabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (values && maxFiles && values.length <= maxFiles) {
|
||||||
|
onChange?.([...values, ...acceptedFiles]);
|
||||||
|
}
|
||||||
|
}, [acceptedFiles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className?.wrapper)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full flex flex-col gap-2 text-start',
|
||||||
|
className?.inputContainer
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={name}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-sm font-normal leading-5',
|
||||||
|
className?.label
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<span className='tooltip tooltip-error' data-tip='required'>
|
||||||
|
<span className='text-error'>*</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...getRootProps({
|
||||||
|
'aria-disabled': isDisabled,
|
||||||
|
className: cn(
|
||||||
|
'dropzone w-full px-4 py-2 border border-dashed border-gray-300 rounded cursor-pointer transition-all',
|
||||||
|
'hover:border-primary hover:bg-primary/10',
|
||||||
|
{
|
||||||
|
'border-success bg-success/10': isDragAccept,
|
||||||
|
'border-error bg-error/10': isDragReject || isError,
|
||||||
|
'border-primary bg-primary/10': isFocused,
|
||||||
|
'bg-gray-200/20 cursor-not-allowed': isDisabled,
|
||||||
|
},
|
||||||
|
className?.inputWrapper
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...getInputProps({
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
disabled: isDisabled,
|
||||||
|
'aria-disabled': isDisabled,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{caption && (
|
||||||
|
<p className={cn('text-gray-500 text-sm', className?.caption)}>
|
||||||
|
{caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isError && bottomLabel && (
|
||||||
|
<p
|
||||||
|
className={cn('w-full text-sm opacity-60', className?.bottomLabel)}
|
||||||
|
>
|
||||||
|
{bottomLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<p
|
||||||
|
className={cn('w-full text-sm text-error', className?.errorMessage)}
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{values && values.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full mt-1.5 flex flex-col gap-1.5',
|
||||||
|
className?.fileItemContainer
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{values.map((file, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn('w-full flex flex-row items-center gap-2')}
|
||||||
|
>
|
||||||
|
<div className='p-2 rounded-full bg-primary/10'>
|
||||||
|
<Icon
|
||||||
|
icon='basil:file-solid'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='text-blue-500'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='w-full text-sm'>
|
||||||
|
<p>{file.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
onClick={() => {
|
||||||
|
onDelete?.(idx);
|
||||||
|
}}
|
||||||
|
className='rounded-full text-error focus-visible:text-error-content hover:text-error-content'
|
||||||
|
>
|
||||||
|
<Icon icon='fluent:delete-12-regular' width={24} height={24} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropFileInput;
|
||||||
@@ -139,9 +139,12 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && (
|
{required && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
<span className='tooltip tooltip-error' data-tip='required'>
|
<span className='tooltip tooltip-error' data-tip='required'>
|
||||||
<span className='text-error'>*</span>
|
<span className='text-error'>*</span>
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -229,8 +232,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
|||||||
|
|
||||||
const useSelect = <T,>(
|
const useSelect = <T,>(
|
||||||
basePath: string,
|
basePath: string,
|
||||||
valueKey: keyof T,
|
valueKey: keyof T | string,
|
||||||
labelKey: keyof T,
|
labelKey: keyof T | string,
|
||||||
searchKey: string = 'search',
|
searchKey: string = 'search',
|
||||||
params?: { [key: string]: string }
|
params?: { [key: string]: string }
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { RefObject } from 'react';
|
import { MouseEventHandler, RefObject, useState } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import Button from '@/components/Button';
|
import Button, { ButtonProps } from '@/components/Button';
|
||||||
|
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import { Color } from '@/types/theme';
|
|
||||||
|
|
||||||
interface ConfirmationModalProps {
|
interface ConfirmationModalProps {
|
||||||
ref: RefObject<HTMLDialogElement | null>;
|
ref: RefObject<HTMLDialogElement | null>;
|
||||||
type?: 'info' | 'success' | 'error';
|
type?: 'info' | 'success' | 'error';
|
||||||
text?: string;
|
text?: string;
|
||||||
closeOnBackdrop?: boolean;
|
closeOnBackdrop?: boolean;
|
||||||
primaryButton?: {
|
primaryButton?: ButtonProps & {
|
||||||
text?: string;
|
text?: string;
|
||||||
color?: Color;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
};
|
||||||
secondaryButton?: {
|
secondaryButton?: ButtonProps & {
|
||||||
text?: string;
|
text?: string;
|
||||||
color?: Color;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
};
|
||||||
className?: {
|
className?: {
|
||||||
modal?: string;
|
modal?: string;
|
||||||
@@ -41,10 +34,22 @@ const ConfirmationModal = ({
|
|||||||
secondaryButton,
|
secondaryButton,
|
||||||
className,
|
className,
|
||||||
}: ConfirmationModalProps) => {
|
}: ConfirmationModalProps) => {
|
||||||
|
const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
|
||||||
|
|
||||||
const closeModalHandler = () => {
|
const closeModalHandler = () => {
|
||||||
ref.current?.close();
|
ref.current?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const primaryButtonClickHandler: MouseEventHandler<
|
||||||
|
HTMLButtonElement
|
||||||
|
> = async (event) => {
|
||||||
|
setIsPrimaryButtonLoading(true);
|
||||||
|
|
||||||
|
await primaryButton?.onClick?.(event);
|
||||||
|
|
||||||
|
setIsPrimaryButtonLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
|
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
|
||||||
<div className='w-full flex flex-col gap-4'>
|
<div className='w-full flex flex-col gap-4'>
|
||||||
@@ -93,10 +98,15 @@ const ConfirmationModal = ({
|
|||||||
<div className='w-full flex flex-row gap-2'>
|
<div className='w-full flex flex-row gap-2'>
|
||||||
{secondaryButton && secondaryButton.text && (
|
{secondaryButton && secondaryButton.text && (
|
||||||
<Button
|
<Button
|
||||||
|
{...secondaryButton}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color={secondaryButton?.color ?? 'none'}
|
color={secondaryButton?.color}
|
||||||
isLoading={secondaryButton?.isLoading}
|
isLoading={secondaryButton?.isLoading}
|
||||||
disabled={secondaryButton?.isLoading}
|
disabled={
|
||||||
|
secondaryButton?.isLoading !== undefined
|
||||||
|
? secondaryButton?.isLoading
|
||||||
|
: isPrimaryButtonLoading
|
||||||
|
}
|
||||||
onClick={closeModalHandler}
|
onClick={closeModalHandler}
|
||||||
className='grow'
|
className='grow'
|
||||||
>
|
>
|
||||||
@@ -106,10 +116,19 @@ const ConfirmationModal = ({
|
|||||||
|
|
||||||
{primaryButton && primaryButton.text && (
|
{primaryButton && primaryButton.text && (
|
||||||
<Button
|
<Button
|
||||||
|
{...primaryButton}
|
||||||
color={primaryButton?.color ?? 'info'}
|
color={primaryButton?.color ?? 'info'}
|
||||||
onClick={primaryButton?.onClick}
|
onClick={primaryButtonClickHandler}
|
||||||
isLoading={primaryButton?.isLoading}
|
isLoading={
|
||||||
disabled={primaryButton?.isLoading}
|
primaryButton?.isLoading !== undefined
|
||||||
|
? primaryButton?.isLoading
|
||||||
|
: isPrimaryButtonLoading
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
primaryButton?.isLoading !== undefined
|
||||||
|
? primaryButton?.isLoading
|
||||||
|
: isPrimaryButtonLoading
|
||||||
|
}
|
||||||
className='grow'
|
className='grow'
|
||||||
>
|
>
|
||||||
{primaryButton?.text ?? 'Ya'}
|
{primaryButton?.text ?? 'Ya'}
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||||
|
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||||
|
|
||||||
|
import { Expense } from '@/types/api/expense';
|
||||||
|
import { ExpenseApi } from '@/services/api/expense';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import { ROWS_OPTIONS } from '@/config/constant';
|
||||||
|
|
||||||
|
const RowOptionsMenu = ({
|
||||||
|
type = 'dropdown',
|
||||||
|
props,
|
||||||
|
deleteClickHandler,
|
||||||
|
}: {
|
||||||
|
type: 'dropdown' | 'collapse';
|
||||||
|
props: CellContext<Expense, unknown>;
|
||||||
|
deleteClickHandler: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
tabIndex={type === 'dropdown' ? 0 : undefined}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
'dropdown-content': type === 'dropdown',
|
||||||
|
'mt-2': type === 'collapse',
|
||||||
|
},
|
||||||
|
'p-2.5 mr-2 bg-base-100 rounded-box z-10 border border-black/10 shadow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className='flex flex-col gap-1'>
|
||||||
|
<Button
|
||||||
|
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
||||||
|
variant='ghost'
|
||||||
|
color='primary'
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
||||||
|
variant='ghost'
|
||||||
|
color='warning'
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={deleteClickHandler}
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
className='text-error hover:text-inherit'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExpensesTable = () => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: { search: '', nameSort: '' },
|
||||||
|
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: expenses,
|
||||||
|
isLoading,
|
||||||
|
mutate: refreshExpenses,
|
||||||
|
} = useSWR(
|
||||||
|
`${ExpenseApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
ExpenseApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
|
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
|
const expensesColumns: ColumnDef<Expense>[] = [
|
||||||
|
{
|
||||||
|
header: '#',
|
||||||
|
cell: (props) =>
|
||||||
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
|
props.row.index +
|
||||||
|
1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Nama',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Aksi',
|
||||||
|
cell: (props) => {
|
||||||
|
const currentPageSize = props.table.getPaginationRowModel().rows.length;
|
||||||
|
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||||
|
const currentRowRelativeIndex =
|
||||||
|
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||||
|
|
||||||
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
|
const deleteClickHandler = () => {
|
||||||
|
setSelectedExpense(props.row.original);
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentPageSize > 2 && (
|
||||||
|
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
|
props={props}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowDropdownOptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentPageSize <= 2 && (
|
||||||
|
<RowCollapseOptions>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
|
props={props}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowCollapseOptions>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await ExpenseApi.delete(selectedExpense?.id as number);
|
||||||
|
refreshExpenses();
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Berhasil menghapus biaya operasional!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
|
||||||
|
setPageSize(newVal.value as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
// track sorting
|
||||||
|
useEffect(() => {
|
||||||
|
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
|
|
||||||
|
if (!isNameSorted) {
|
||||||
|
updateFilter('nameSort', '');
|
||||||
|
} else {
|
||||||
|
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
}, [sorting, updateFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-4'>
|
||||||
|
<div className='flex flex-col gap-2 mb-4'>
|
||||||
|
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
||||||
|
<div className='flex flex-row'>
|
||||||
|
<Button href='/expense/add' color='primary'>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah Biaya Operasional
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Cari Biaya Operasional'
|
||||||
|
value={tableFilterState.search}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-end'>
|
||||||
|
<SelectInput
|
||||||
|
label='Baris'
|
||||||
|
options={ROWS_OPTIONS}
|
||||||
|
value={{
|
||||||
|
label: String(tableFilterState.pageSize),
|
||||||
|
value: tableFilterState.pageSize,
|
||||||
|
}}
|
||||||
|
onChange={pageSizeChangeHandler}
|
||||||
|
className={{ wrapper: 'max-w-28' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<Expense>
|
||||||
|
data={isResponseSuccess(expenses) ? expenses?.data : []}
|
||||||
|
columns={expensesColumns}
|
||||||
|
pageSize={tableFilterState.pageSize}
|
||||||
|
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
|
||||||
|
totalItems={
|
||||||
|
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
|
||||||
|
}
|
||||||
|
onPageChange={setPage}
|
||||||
|
isLoading={isLoading}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
|
className={{
|
||||||
|
containerClassName: cn({
|
||||||
|
'mb-20':
|
||||||
|
isResponseSuccess(expenses) && expenses?.data?.length === 0,
|
||||||
|
}),
|
||||||
|
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||||
|
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
||||||
|
headerRowClassName: 'border-b border-b-gray-200',
|
||||||
|
headerColumnClassName:
|
||||||
|
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
|
||||||
|
bodyRowClassName: 'border-b border-b-gray-200',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data biaya operasional ini (${selectedExpense?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpensesTable;
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Collapse from '@/components/Collapse';
|
||||||
|
import Card from '@/components/Card';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||||
|
|
||||||
|
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
|
||||||
|
import { cn, convertRowSelectionArrToObj } from '@/lib/helper';
|
||||||
|
import { Kandang } from '@/types/api/master-data/kandang';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import { KandangApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
interface ExpenseKandangsTableProps {
|
||||||
|
locationId?: number;
|
||||||
|
type: 'add' | 'edit' | 'detail';
|
||||||
|
selectedKandangs: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
onChange: (kandangs: { id: number; name: string }[]) => void;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpenseKandangsTable = ({
|
||||||
|
type,
|
||||||
|
locationId,
|
||||||
|
selectedKandangs,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: ExpenseKandangsTableProps) => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: {
|
||||||
|
search: '',
|
||||||
|
nameSort: '',
|
||||||
|
picSort: '',
|
||||||
|
locationId,
|
||||||
|
},
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
nameSort: 'sort_name',
|
||||||
|
picSort: 'sort_pic',
|
||||||
|
locationId: 'location_id',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: kandangs, isLoading } = useSWR(
|
||||||
|
locationId ? `${KandangApi.basePath}${getTableFilterQueryString()}` : null,
|
||||||
|
KandangApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(
|
||||||
|
isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false
|
||||||
|
);
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
|
||||||
|
convertRowSelectionArrToObj(selectedKandangs.map((item) => item.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const kandangsColumns: ColumnDef<Kandang>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<div className='w-full flex flex-row justify-center'>
|
||||||
|
<CheckboxInput
|
||||||
|
name='allRow'
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
indeterminate={table.getIsSomePageRowsSelected()}
|
||||||
|
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
||||||
|
disabled={type === 'detail'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<CheckboxInput
|
||||||
|
name='row'
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
disabled={!row.getCanSelect() || type === 'detail'}
|
||||||
|
indeterminate={row.getIsSomeSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Nama',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'pic',
|
||||||
|
header: 'PIC',
|
||||||
|
cell: (props) => props.row.original.pic.name,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const updateSortingFilter = useCallback(
|
||||||
|
(
|
||||||
|
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
|
||||||
|
sortFilter: ColumnSort | undefined
|
||||||
|
) => {
|
||||||
|
if (!sortFilter) {
|
||||||
|
updateFilter(sortName, '');
|
||||||
|
} else {
|
||||||
|
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (locationId) updateFilter('locationId', locationId);
|
||||||
|
}, [locationId, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false);
|
||||||
|
}, [kandangs, isResponseSuccess]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Object.keys(rowSelection).length !== 0 && isResponseSuccess(kandangs)) {
|
||||||
|
const formattedSelectedKandangs = Object.keys(rowSelection).map(
|
||||||
|
(item) => {
|
||||||
|
const selectedKandang = kandangs.data.find(
|
||||||
|
(kandang) => kandang.id === parseInt(item)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: parseInt(item),
|
||||||
|
name: selectedKandang?.name ?? 'Kandang tidak ditemukan!',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onChange(formattedSelectedKandangs);
|
||||||
|
}
|
||||||
|
}, [rowSelection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRowSelection({});
|
||||||
|
}, [locationId]);
|
||||||
|
|
||||||
|
// track sorting
|
||||||
|
useEffect(() => {
|
||||||
|
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
|
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
|
||||||
|
|
||||||
|
updateSortingFilter('nameSort', nameSortFilter);
|
||||||
|
updateSortingFilter('picSort', picSortFilter);
|
||||||
|
}, [sorting, updateSortingFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={{
|
||||||
|
wrapper: className?.wrapper,
|
||||||
|
body: 'p-4 shadow',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Collapse
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title={
|
||||||
|
<div className='card-actions p-4 justify-between items-center w-full'>
|
||||||
|
<div className='card-title'>Pilih Kandang</div>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:keyboard-arrow-down'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className={cn('text-primary transition-transform', {
|
||||||
|
'-rotate-180': open,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className='w-full!'
|
||||||
|
titleClassName='w-full p-0!'
|
||||||
|
>
|
||||||
|
<Table<Kandang>
|
||||||
|
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
|
||||||
|
columns={kandangsColumns}
|
||||||
|
pageSize={tableFilterState.pageSize}
|
||||||
|
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
|
||||||
|
totalItems={
|
||||||
|
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
|
||||||
|
}
|
||||||
|
onPageChange={setPage}
|
||||||
|
isLoading={isLoading}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
setRowSelection={setRowSelection}
|
||||||
|
className={{
|
||||||
|
containerClassName: cn({
|
||||||
|
'mb-20':
|
||||||
|
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
|
||||||
|
}),
|
||||||
|
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||||
|
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
||||||
|
headerRowClassName: 'border-b border-b-gray-200',
|
||||||
|
headerColumnClassName:
|
||||||
|
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
|
||||||
|
bodyRowClassName: 'border-b border-b-gray-200',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-6 py-3 first:flex first:flex-row first:justify-start',
|
||||||
|
paginationClassName: cn({
|
||||||
|
hidden:
|
||||||
|
isResponseSuccess(kandangs) &&
|
||||||
|
kandangs?.meta?.total_pages === 1,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseKandangsTable;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import * as Yup from 'yup';
|
||||||
|
import { Expense } from '@/types/api/expense';
|
||||||
|
import { formatDate } from '@/lib/helper';
|
||||||
|
|
||||||
|
type ExpenseFormSchemaType = {
|
||||||
|
location?: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
transaction_date?: string;
|
||||||
|
kandangs?: { id: number; name: string }[];
|
||||||
|
vendor?: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
existing_documents?: { name: string; url: string }[];
|
||||||
|
request_documents?: File[];
|
||||||
|
kandangExpenses: {
|
||||||
|
kandangId: number;
|
||||||
|
expenses: {
|
||||||
|
nonstock?: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
totalQuantity?: number;
|
||||||
|
totalExpense?: number;
|
||||||
|
notes?: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
||||||
|
Yup.object({
|
||||||
|
location: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).required('Lokasi wajib diisi!'),
|
||||||
|
|
||||||
|
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
|
||||||
|
kandangs: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
id: Yup.number().required('Kandang wajib dipilih!'),
|
||||||
|
name: Yup.string().required('Kandang wajib dipilih!'),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, 'Kandang wajib dipilih!')
|
||||||
|
.required('Kandang wajib dipilih!'),
|
||||||
|
|
||||||
|
vendor: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).required('Vendor wajib diisi!'),
|
||||||
|
|
||||||
|
existing_documents: Yup.array().of(
|
||||||
|
Yup.object({
|
||||||
|
name: Yup.string().required(),
|
||||||
|
url: Yup.string().required(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
request_documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
|
||||||
|
|
||||||
|
kandangExpenses: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
kandangId: Yup.number().min(1, 'Wajib memilih kandang!').required(),
|
||||||
|
expenses: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
nonstock: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).required('Nonstock wajib diisi!'),
|
||||||
|
totalQuantity: Yup.number().required(
|
||||||
|
'Total kuantitas wajib diisi!'
|
||||||
|
),
|
||||||
|
totalExpense: Yup.number().required('Total biaya wajib diisi!'),
|
||||||
|
notes: Yup.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, 'Kandang harus memiliki setidaknya 1 biaya!')
|
||||||
|
.required('Biaya kandang wajib diisi!'),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, 'Biaya kandang wajib diisi!')
|
||||||
|
.required('Biaya kandang wajib diisi!'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema;
|
||||||
|
|
||||||
|
export type ExpenseRequestFormValues = Yup.InferType<
|
||||||
|
typeof ExpenseRequestFormSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const getExpenseFormInitialValues = (
|
||||||
|
initialValues?: Expense
|
||||||
|
): ExpenseRequestFormValues => {
|
||||||
|
return {
|
||||||
|
location: initialValues?.location
|
||||||
|
? {
|
||||||
|
value: initialValues.location.id,
|
||||||
|
label: initialValues.location.name,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
transaction_date: initialValues?.transaction_date
|
||||||
|
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
kandangs: initialValues?.kandangs.map((kandang) => ({
|
||||||
|
id: kandang.id,
|
||||||
|
name: kandang.name,
|
||||||
|
})),
|
||||||
|
vendor: initialValues?.vendor
|
||||||
|
? {
|
||||||
|
value: initialValues.vendor.id,
|
||||||
|
label: initialValues.vendor.name,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
existing_documents: initialValues?.request_documents,
|
||||||
|
request_documents: [],
|
||||||
|
kandangExpenses: initialValues?.kandang_expenses
|
||||||
|
? initialValues.kandang_expenses.map((kandangExpense) => ({
|
||||||
|
kandangId: kandangExpense.kandang.id,
|
||||||
|
expenses: kandangExpense.expenses.map((expenseItem) => ({
|
||||||
|
nonstock: {
|
||||||
|
value: expenseItem.nonstock.id,
|
||||||
|
label: expenseItem.nonstock.name,
|
||||||
|
},
|
||||||
|
totalQuantity: expenseItem.total_quantity,
|
||||||
|
totalExpense: expenseItem.total_expense,
|
||||||
|
notes: expenseItem.notes,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import SelectInput, {
|
||||||
|
OptionType,
|
||||||
|
useSelect,
|
||||||
|
} from '@/components/input/SelectInput';
|
||||||
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
|
||||||
|
import DropFileInput from '@/components/input/DropFileInput';
|
||||||
|
import ExpenseRequestKandangDetailExpense from '@/components/pages/expense/form/ExpenseRequestKandangDetailExpense';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ExpenseRequestFormSchema,
|
||||||
|
ExpenseRequestFormValues,
|
||||||
|
getExpenseFormInitialValues,
|
||||||
|
UpdateExpenseRequestFormSchema,
|
||||||
|
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||||
|
import { isResponseError } from '@/lib/api-helper';
|
||||||
|
import {
|
||||||
|
Expense,
|
||||||
|
CreateExpensePayload,
|
||||||
|
UpdateExpensePayload,
|
||||||
|
} from '@/types/api/expense';
|
||||||
|
import { ExpenseApi } from '@/services/api/expense';
|
||||||
|
import { cn, sleep } from '@/lib/helper';
|
||||||
|
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||||
|
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
|
||||||
|
interface ExpenseFormProps {
|
||||||
|
type?: 'add' | 'edit' | 'detail';
|
||||||
|
initialValues?: Expense;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: integrate this with real API
|
||||||
|
const ExpenseRequestForm = ({
|
||||||
|
type = 'add',
|
||||||
|
initialValues,
|
||||||
|
}: ExpenseFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Modal hooks
|
||||||
|
const deleteModal = useModal();
|
||||||
|
const approveModal = useModal();
|
||||||
|
const rejectModal = useModal();
|
||||||
|
|
||||||
|
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const createExpenseHandler = useCallback(
|
||||||
|
async (payload: CreateExpensePayload) => {
|
||||||
|
const createExpenseRes = await ExpenseApi.create(
|
||||||
|
ExpenseApi.convertPayloadToFormData(payload)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseError(createExpenseRes)) {
|
||||||
|
setExpenseFormErrorMessage(createExpenseRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(createExpenseRes?.message as string);
|
||||||
|
router.push('/expense');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateExpenseHandler = useCallback(
|
||||||
|
async (expenseId: number, payload: UpdateExpensePayload) => {
|
||||||
|
const updateExpenseRes = await ExpenseApi.update(
|
||||||
|
expenseId,
|
||||||
|
ExpenseApi.convertPayloadToFormData(payload)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateExpenseRes?.status === 'error') {
|
||||||
|
setExpenseFormErrorMessage(updateExpenseRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(updateExpenseRes?.message as string);
|
||||||
|
router.refresh();
|
||||||
|
router.push('/expense');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formik = useFormik<ExpenseRequestFormValues>({
|
||||||
|
initialValues: getExpenseFormInitialValues(initialValues),
|
||||||
|
validationSchema:
|
||||||
|
type === 'edit'
|
||||||
|
? UpdateExpenseRequestFormSchema
|
||||||
|
: ExpenseRequestFormSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
setExpenseFormErrorMessage('');
|
||||||
|
|
||||||
|
const expensePayload: CreateExpensePayload = {
|
||||||
|
locationId: values.location?.value as number,
|
||||||
|
kandangIds: values.kandangs
|
||||||
|
? values.kandangs.map((item) => item.id)
|
||||||
|
: [],
|
||||||
|
transaction_date: values.transaction_date as string,
|
||||||
|
vendorId: values.vendor?.value as number,
|
||||||
|
request_documents: values.request_documents as File[],
|
||||||
|
kandang_expenses: values.kandangExpenses.map((kandangExpense) => ({
|
||||||
|
kandangId: kandangExpense.kandangId,
|
||||||
|
expenses: kandangExpense.expenses.map((expenseItem) => ({
|
||||||
|
nonstockId: expenseItem.nonstock?.value as number,
|
||||||
|
total_quantity: expenseItem.totalQuantity as number,
|
||||||
|
total_expense: expenseItem.totalExpense as number,
|
||||||
|
notes: expenseItem.notes,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'add':
|
||||||
|
await createExpenseHandler(expensePayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'edit':
|
||||||
|
await updateExpenseHandler(
|
||||||
|
initialValues?.id as number,
|
||||||
|
expensePayload
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setValues: formikSetValues } = formik;
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocationOptions,
|
||||||
|
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setVendorInputValue,
|
||||||
|
options: vendorOptions,
|
||||||
|
isLoadingOptions: isLoadingVendorOptions,
|
||||||
|
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
formik.setFieldTouched('location', true);
|
||||||
|
formik.setFieldValue('location', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
|
||||||
|
formik.setFieldTouched('kandangs', true);
|
||||||
|
formik.setFieldValue('kandangs', kandangs);
|
||||||
|
|
||||||
|
kandangs.forEach((kandangItem) => {
|
||||||
|
const isKandangExistInKandangExpense = formik.values.kandangExpenses.find(
|
||||||
|
(kandangExpenseItem) => kandangExpenseItem.kandangId === kandangItem.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isKandangExistInKandangExpense) return;
|
||||||
|
|
||||||
|
formik.values.kandangExpenses.push({
|
||||||
|
kandangId: kandangItem.id,
|
||||||
|
expenses: [
|
||||||
|
{
|
||||||
|
nonstock: undefined,
|
||||||
|
totalExpense: undefined,
|
||||||
|
totalQuantity: undefined,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
formik.setFieldTouched('vendor', true);
|
||||||
|
formik.setFieldValue('vendor', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDocumentsChangeHandler = (val: File[]) => {
|
||||||
|
formik.setFieldTouched('request_documents', true);
|
||||||
|
formik.setFieldValue('request_documents', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteExpenseClickHandler = () => {
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalRejectClickHandler = async () => {
|
||||||
|
await sleep(750);
|
||||||
|
|
||||||
|
rejectModal.closeModal();
|
||||||
|
toast.success('Berhasil melakukan reject biaya operasional!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalApproveClickHandler = async () => {
|
||||||
|
await sleep(750);
|
||||||
|
|
||||||
|
approveModal.closeModal();
|
||||||
|
toast.success('Berhasil melakukan approve biaya operasional!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
await ExpenseApi.delete(initialValues?.id as number);
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete Expense!');
|
||||||
|
router.push('/expense');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formikSetValues(getExpenseFormInitialValues(initialValues));
|
||||||
|
}, [formikSetValues, getExpenseFormInitialValues, initialValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formik.setFieldValue('kandangs', undefined);
|
||||||
|
}, [formik.values.location]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className='w-full max-w-5xl'>
|
||||||
|
<header className='flex flex-col gap-4'>
|
||||||
|
<Button
|
||||||
|
href='/expense'
|
||||||
|
variant='link'
|
||||||
|
className='w-fit p-0 text-primary'
|
||||||
|
>
|
||||||
|
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
|
{type === 'add' && 'Tambah Biaya Operasional'}
|
||||||
|
{type === 'edit' && 'Edit Biaya Operasional'}
|
||||||
|
{type === 'detail' && 'Detail Biaya Operasional'}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onReset={formik.handleReset}
|
||||||
|
className='w-full mt-8 flex flex-col gap-6'
|
||||||
|
>
|
||||||
|
<div className='grid grid-cols-12 gap-4'>
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
required
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
value={formik.values.location}
|
||||||
|
onChange={locationChangeHandler}
|
||||||
|
options={locationOptions}
|
||||||
|
isLoading={isLoadingLocationOptions}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
className={{ wrapper: 'col-span-12 sm:col-span-6' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
name='transaction_date'
|
||||||
|
label='Tanggal Transaksi'
|
||||||
|
required
|
||||||
|
value={formik.values.transaction_date}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12 sm:col-span-6',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExpenseKandangsTable
|
||||||
|
type={type}
|
||||||
|
locationId={formik.values.location?.value}
|
||||||
|
selectedKandangs={formik.values.kandangs ?? []}
|
||||||
|
onChange={kandangsChangeHandler}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full col-span-12',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Vendor'
|
||||||
|
required
|
||||||
|
placeholder='Pilih Vendor'
|
||||||
|
value={formik.values.vendor}
|
||||||
|
onChange={vendorChangeHandler}
|
||||||
|
options={vendorOptions}
|
||||||
|
isLoading={isLoadingVendorOptions}
|
||||||
|
onInputChange={setVendorInputValue}
|
||||||
|
className={{ wrapper: 'col-span-12' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DropFileInput
|
||||||
|
label='Dokumen Pengajuan'
|
||||||
|
name='request_documents'
|
||||||
|
values={formik.values.request_documents}
|
||||||
|
onChange={requestDocumentsChangeHandler}
|
||||||
|
accept={{
|
||||||
|
...ACCEPTED_FILE_TYPE.PDF,
|
||||||
|
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||||
|
}}
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12',
|
||||||
|
inputWrapper: 'h-12 flex items-center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExpenseRequestKandangDetailExpense
|
||||||
|
formik={formik}
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||||
|
{type !== 'add' && (
|
||||||
|
<div className='flex flex-row justify-start gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={deleteExpenseClickHandler}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{type !== 'edit' && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='warning'
|
||||||
|
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:edit-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-row justify-end gap-2', {
|
||||||
|
'w-full': type === 'add',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button type='reset' color='warning' className='px-4'>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='primary'
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expenseFormErrorMessage && (
|
||||||
|
<div role='alert' className='alert alert-error'>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:error-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
<span>{expenseFormErrorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{type !== 'add' && (
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin menghapus data Expense ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'detail' && (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={approveModal.ref}
|
||||||
|
type='success'
|
||||||
|
text='Apakah anda yakin ingin approve data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'success',
|
||||||
|
onClick: confirmationModalApproveClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={rejectModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin reject data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
onClick: confirmationModalRejectClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseRequestForm;
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormikContextType } from 'formik';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Card from '@/components/Card';
|
||||||
|
import SelectInput, {
|
||||||
|
OptionType,
|
||||||
|
useSelect,
|
||||||
|
} from '@/components/input/SelectInput';
|
||||||
|
import NumberInput from '@/components/input/NumberInput';
|
||||||
|
import TextInput from '@/components/input/TextInput';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
|
||||||
|
import { ExpenseRequestFormValues } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
import { NonstockApi } from '@/services/api/master-data';
|
||||||
|
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||||
|
import { removeArrayItemAndSync } from '@/lib/utils/formik';
|
||||||
|
|
||||||
|
interface ExpenseRequestKandangDetailExpenseProps {
|
||||||
|
type?: 'add' | 'edit' | 'detail';
|
||||||
|
formik: FormikContextType<ExpenseRequestFormValues>;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpenseRequestKandangDetailExpense: React.FC<
|
||||||
|
ExpenseRequestKandangDetailExpenseProps
|
||||||
|
> = ({ type, formik, className }) => {
|
||||||
|
const {
|
||||||
|
setInputValue: setNonstockInputValue,
|
||||||
|
options: nonstockOptions,
|
||||||
|
isLoadingOptions: isLoadingNonstockOptions,
|
||||||
|
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const nonstockChangeHandler = (
|
||||||
|
kandangExpenseIdx: number,
|
||||||
|
expenseIdx: number,
|
||||||
|
val: OptionType | OptionType[] | null
|
||||||
|
) => {
|
||||||
|
formik.setFieldTouched(
|
||||||
|
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
formik.setFieldValue(
|
||||||
|
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`,
|
||||||
|
val
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addExpenseItemHandler = (kandangExpenseIdx: number) => {
|
||||||
|
const newExpensesValue = [
|
||||||
|
...formik.values.kandangExpenses[kandangExpenseIdx].expenses,
|
||||||
|
{
|
||||||
|
nonstock: undefined,
|
||||||
|
totalExpense: undefined,
|
||||||
|
totalQuantity: undefined,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
formik.setFieldValue(
|
||||||
|
`kandangExpenses[${kandangExpenseIdx}].expenses`,
|
||||||
|
newExpensesValue
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteExpenseItemHandler = (
|
||||||
|
kandangExpenseIdx: number,
|
||||||
|
expenseIdx: number
|
||||||
|
) => {
|
||||||
|
const path = `kandangExpenses[${kandangExpenseIdx}].expenses`;
|
||||||
|
|
||||||
|
// trims values, errors, and touched at expenseIdx
|
||||||
|
removeArrayItemAndSync(formik, path, expenseIdx);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpenseRepeaterInputError = (
|
||||||
|
column: 'nonstock' | 'totalQuantity' | 'totalExpense' | 'notes',
|
||||||
|
kandangExpenseIdx: number,
|
||||||
|
expenseIdx: number
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
formik.touched.kandangExpenses?.[kandangExpenseIdx]?.expenses?.[
|
||||||
|
expenseIdx
|
||||||
|
]?.[column] &&
|
||||||
|
Boolean(
|
||||||
|
formik.errors.kandangExpenses?.[kandangExpenseIdx] instanceof Object &&
|
||||||
|
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[
|
||||||
|
expenseIdx
|
||||||
|
] instanceof Object &&
|
||||||
|
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[
|
||||||
|
expenseIdx
|
||||||
|
]?.[column]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={{
|
||||||
|
wrapper: cn('w-full', className?.wrapper),
|
||||||
|
body: 'p-4 shadow',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='mb-4 text-center'>
|
||||||
|
<h4 className='font-bold text-xl'>
|
||||||
|
Rincian Pengajuan Biaya Operasional
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='w-full flex flex-col gap-6'>
|
||||||
|
{formik.values.kandangExpenses.length === 0 && (
|
||||||
|
<div>
|
||||||
|
<p className='text-sm text-gray-400 text-center'>
|
||||||
|
Pilih kandang terlebih dahulu!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formik.values.kandangExpenses.map(
|
||||||
|
(kandangExpense, kandangExpenseIdx) => {
|
||||||
|
const kandangName = formik.values.kandangs?.find(
|
||||||
|
(kandang) => kandang.id === kandangExpense.kandangId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
kandangName?.name && (
|
||||||
|
<div
|
||||||
|
key={`kandangExpense-${kandangExpenseIdx}`}
|
||||||
|
className='w-full flex flex-col gap-4'
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h5 className='mb-2 text-lg font-bold text-center'>
|
||||||
|
Biaya {kandangName?.name}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div className='overflow-x-auto'>
|
||||||
|
<table className='table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nonstock</th>
|
||||||
|
<th>Total Kuantitas</th>
|
||||||
|
<th>Total Biaya</th>
|
||||||
|
<th>Catatan</th>
|
||||||
|
{type !== 'detail' && <th>Aksi</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{kandangExpense.expenses.map(
|
||||||
|
(expenseItem, expenseIdx) => (
|
||||||
|
<tr key={`expense-${expenseIdx}`}>
|
||||||
|
<td className='p-2'>
|
||||||
|
<SelectInput
|
||||||
|
placeholder='Pilih Nonstock'
|
||||||
|
value={expenseItem.nonstock}
|
||||||
|
onChange={(val) => {
|
||||||
|
nonstockChangeHandler(
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx,
|
||||||
|
val
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
options={nonstockOptions}
|
||||||
|
isLoading={isLoadingNonstockOptions}
|
||||||
|
onInputChange={setNonstockInputValue}
|
||||||
|
className={{ wrapper: 'min-w-48' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='p-2'>
|
||||||
|
<NumberInput
|
||||||
|
required
|
||||||
|
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalQuantity`}
|
||||||
|
placeholder='Masukkan Total Kuantitas'
|
||||||
|
value={
|
||||||
|
formik.values.kandangExpenses[
|
||||||
|
kandangExpenseIdx
|
||||||
|
].expenses[expenseIdx].totalQuantity ?? ''
|
||||||
|
}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isExpenseRepeaterInputError(
|
||||||
|
'totalQuantity',
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)}
|
||||||
|
className={{ wrapper: 'min-w-24' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='p-2'>
|
||||||
|
<NumberInput
|
||||||
|
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalExpense`}
|
||||||
|
placeholder='Masukkan Total Biaya'
|
||||||
|
value={
|
||||||
|
formik.values.kandangExpenses[
|
||||||
|
kandangExpenseIdx
|
||||||
|
].expenses[expenseIdx].totalExpense ?? ''
|
||||||
|
}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isExpenseRepeaterInputError(
|
||||||
|
'totalExpense',
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)}
|
||||||
|
className={{ wrapper: 'min-w-24' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='p-2'>
|
||||||
|
<TextInput
|
||||||
|
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].notes`}
|
||||||
|
placeholder='Tuliskan catatan'
|
||||||
|
value={
|
||||||
|
formik.values.kandangExpenses[
|
||||||
|
kandangExpenseIdx
|
||||||
|
].expenses[expenseIdx].notes
|
||||||
|
}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isExpenseRepeaterInputError(
|
||||||
|
'notes',
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)}
|
||||||
|
className={{ wrapper: 'min-w-24' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={() =>
|
||||||
|
deleteExpenseItemHandler(
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
color='success'
|
||||||
|
onClick={() => addExpenseItemHandler(kandangExpenseIdx)}
|
||||||
|
className='w-fit mx-auto'
|
||||||
|
>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseRequestKandangDetailExpense;
|
||||||
@@ -40,6 +40,12 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'Biaya Operasional',
|
||||||
|
link: '/expense',
|
||||||
|
icon: 'uil:wallet',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Persediaan',
|
title: 'Persediaan',
|
||||||
link: '/inventory',
|
link: '/inventory',
|
||||||
@@ -225,3 +231,12 @@ export const RECORDING_FLAG_OPTIONS = [
|
|||||||
{ label: 'Ayam Culling', value: 'Ayam Culling' },
|
{ label: 'Ayam Culling', value: 'Ayam Culling' },
|
||||||
{ label: 'Ayam Mati', value: 'Ayam Mati' },
|
{ label: 'Ayam Mati', value: 'Ayam Mati' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const ACCEPTED_FILE_TYPE = {
|
||||||
|
PDF: {
|
||||||
|
'application/pdf': ['.pdf'],
|
||||||
|
},
|
||||||
|
IMAGE: {
|
||||||
|
'image/*': [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -77,3 +77,23 @@ export function getByPath<T, D = undefined>(
|
|||||||
|
|
||||||
return cur as D;
|
return cur as D;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const convertRowSelectionArrToObj = (
|
||||||
|
rowSelectionArr: string[] | number[]
|
||||||
|
) => {
|
||||||
|
const result: Record<string | number, boolean> = {};
|
||||||
|
|
||||||
|
rowSelectionArr.forEach((item) => {
|
||||||
|
result[item] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertRowSelectionObjToArr = (
|
||||||
|
rowSelection: string[] | number[]
|
||||||
|
) => {
|
||||||
|
const result = Object.keys(rowSelection).map(Number);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { FormikContextType, getIn, setIn } from 'formik';
|
||||||
|
|
||||||
|
function spliceArray<T>(arr: T[] | undefined, index: number) {
|
||||||
|
const a = Array.isArray(arr) ? arr.slice() : [];
|
||||||
|
if (index >= 0 && index < a.length) a.splice(index, 1);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove one item from an array field and also trim Formik's errors & touched
|
||||||
|
* at the SAME index to keep everything aligned.
|
||||||
|
*
|
||||||
|
* @param formik - your useFormik instance
|
||||||
|
* @param arrayPath - path to the array field, e.g. "kandangExpenses[0].expenses"
|
||||||
|
* @param index - the index to remove
|
||||||
|
* @param validateAfter - optional: revalidate after removal (default false)
|
||||||
|
*/
|
||||||
|
export async function removeArrayItemAndSync<FormValues>(
|
||||||
|
formik: FormikContextType<FormValues>,
|
||||||
|
arrayPath: string,
|
||||||
|
index: number,
|
||||||
|
validateAfter: boolean = false
|
||||||
|
) {
|
||||||
|
// 1) VALUES: remove at index
|
||||||
|
const currValues = getIn(formik.values, arrayPath);
|
||||||
|
const nextValues = spliceArray(currValues, index);
|
||||||
|
formik.setFieldValue(arrayPath, nextValues, false);
|
||||||
|
|
||||||
|
// 2) ERRORS: remove the same index (if array exists)
|
||||||
|
const currErrors = getIn(formik.errors, arrayPath);
|
||||||
|
if (Array.isArray(currErrors)) {
|
||||||
|
const nextErrors = spliceArray(currErrors, index);
|
||||||
|
formik.setErrors(setIn(formik.errors, arrayPath, nextErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) TOUCHED: remove the same index (if array exists)
|
||||||
|
const currTouched = getIn(formik.touched, arrayPath);
|
||||||
|
if (Array.isArray(currTouched)) {
|
||||||
|
const nextTouched = spliceArray(currTouched, index);
|
||||||
|
formik.setTouched(setIn(formik.touched, arrayPath, nextTouched), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) (optional) revalidate to rebuild a perfectly clean error tree
|
||||||
|
if (validateAfter) {
|
||||||
|
const newErrors = await formik.validateForm();
|
||||||
|
formik.setErrors(newErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { sleep } from '@/lib/helper';
|
||||||
|
import { BaseApiService } from '@/services/api/base';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
import { CreateExpensePayload, Expense } from '@/types/api/expense';
|
||||||
|
|
||||||
|
const DUMMY_EXPENSE: Expense = {
|
||||||
|
created_user: {
|
||||||
|
id: 1,
|
||||||
|
id_user: 101,
|
||||||
|
email: 'admin@example.com',
|
||||||
|
name: 'Admin User',
|
||||||
|
},
|
||||||
|
created_at: '2025-11-05T10:30:00Z',
|
||||||
|
updated_at: '2025-11-05T12:00:00Z',
|
||||||
|
|
||||||
|
id: 1,
|
||||||
|
location: {
|
||||||
|
id: 10,
|
||||||
|
name: 'Farm A',
|
||||||
|
address: 'Jl. Raya Peternakan No. 45, Bandung',
|
||||||
|
area: {
|
||||||
|
id: 100,
|
||||||
|
name: 'Jawa Barat',
|
||||||
|
},
|
||||||
|
created_user: {
|
||||||
|
id: 2,
|
||||||
|
id_user: 102,
|
||||||
|
email: 'manager@example.com',
|
||||||
|
name: 'Farm Manager',
|
||||||
|
},
|
||||||
|
created_at: '2025-10-01T08:00:00Z',
|
||||||
|
updated_at: '2025-10-05T09:30:00Z',
|
||||||
|
},
|
||||||
|
transaction_date: '2025-11-04',
|
||||||
|
|
||||||
|
kandangs: [
|
||||||
|
{
|
||||||
|
id: 201,
|
||||||
|
name: 'Kandang 1',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: {
|
||||||
|
id: 10,
|
||||||
|
name: 'Farm A',
|
||||||
|
address: 'Jl. Raya Peternakan No. 45, Bandung',
|
||||||
|
area: { id: 100, name: 'Jawa Barat' },
|
||||||
|
},
|
||||||
|
pic: {
|
||||||
|
id: 3,
|
||||||
|
id_user: 103,
|
||||||
|
email: 'kandang1@example.com',
|
||||||
|
name: 'PIC Kandang 1',
|
||||||
|
},
|
||||||
|
created_user: {
|
||||||
|
id: 4,
|
||||||
|
id_user: 104,
|
||||||
|
email: 'creator@example.com',
|
||||||
|
name: 'Creator User',
|
||||||
|
},
|
||||||
|
created_at: '2025-10-10T08:00:00Z',
|
||||||
|
updated_at: '2025-10-20T09:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
vendor: {
|
||||||
|
id: 301,
|
||||||
|
name: 'PT. Pakan Makmur',
|
||||||
|
alias: 'PakanMakmur',
|
||||||
|
pic: 'Budi Santoso',
|
||||||
|
type: 'Supplier',
|
||||||
|
category: 'Pakan',
|
||||||
|
hatchery: 'Makmur Hatchery',
|
||||||
|
phone: '08123456789',
|
||||||
|
email: 'contact@pakanmakmur.com',
|
||||||
|
address: 'Jl. Industri No. 5, Bekasi',
|
||||||
|
npwp: '12.345.678.9-012.345',
|
||||||
|
account_number: '1234567890',
|
||||||
|
due_date: 30,
|
||||||
|
balance: 5000000,
|
||||||
|
created_user: {
|
||||||
|
id: 5,
|
||||||
|
id_user: 105,
|
||||||
|
email: 'finance@example.com',
|
||||||
|
name: 'Finance User',
|
||||||
|
},
|
||||||
|
created_at: '2025-09-01T09:00:00Z',
|
||||||
|
updated_at: '2025-09-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
request_documents: [
|
||||||
|
{
|
||||||
|
name: 'invoice_001.pdf',
|
||||||
|
url: 'https://example.com/files/invoice_001.pdf',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'receipt_001.jpg',
|
||||||
|
url: 'https://example.com/files/receipt_001.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
kandang_expenses: [
|
||||||
|
{
|
||||||
|
kandang: {
|
||||||
|
id: 201,
|
||||||
|
name: 'Kandang 1',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: {
|
||||||
|
id: 10,
|
||||||
|
name: 'Farm A',
|
||||||
|
address: 'Jl. Raya Peternakan No. 45, Bandung',
|
||||||
|
area: { id: 100, name: 'Jawa Barat' },
|
||||||
|
},
|
||||||
|
pic: {
|
||||||
|
id: 3,
|
||||||
|
id_user: 103,
|
||||||
|
email: 'kandang1@example.com',
|
||||||
|
name: 'PIC Kandang 1',
|
||||||
|
},
|
||||||
|
created_user: {
|
||||||
|
id: 4,
|
||||||
|
id_user: 104,
|
||||||
|
email: 'creator@example.com',
|
||||||
|
name: 'Creator User',
|
||||||
|
},
|
||||||
|
created_at: '2025-10-10T08:00:00Z',
|
||||||
|
updated_at: '2025-10-20T09:00:00Z',
|
||||||
|
},
|
||||||
|
expenses: [
|
||||||
|
{
|
||||||
|
nonstock: {
|
||||||
|
id: 501,
|
||||||
|
name: 'Pakan Ayam Broiler',
|
||||||
|
uom_id: 1,
|
||||||
|
uom: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Kg',
|
||||||
|
},
|
||||||
|
suppliers: [
|
||||||
|
{
|
||||||
|
id: 301,
|
||||||
|
name: 'PT. Pakan Makmur',
|
||||||
|
alias: 'PakanMakmur',
|
||||||
|
pic: 'Budi Santoso',
|
||||||
|
type: 'Supplier',
|
||||||
|
category: 'Pakan',
|
||||||
|
hatchery: 'Makmur Hatchery',
|
||||||
|
phone: '08123456789',
|
||||||
|
email: 'contact@pakanmakmur.com',
|
||||||
|
address: 'Jl. Industri No. 5, Bekasi',
|
||||||
|
npwp: '12.345.678.9-012.345',
|
||||||
|
account_number: '1234567890',
|
||||||
|
due_date: 30,
|
||||||
|
balance: 5000000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
flags: ['PAKAN', 'IS_ACTIVE'],
|
||||||
|
created_user: {
|
||||||
|
id: 6,
|
||||||
|
id_user: 106,
|
||||||
|
email: 'staff@example.com',
|
||||||
|
name: 'Inventory Staff',
|
||||||
|
},
|
||||||
|
created_at: '2025-09-25T08:00:00Z',
|
||||||
|
updated_at: '2025-09-30T09:00:00Z',
|
||||||
|
},
|
||||||
|
total_quantity: 500,
|
||||||
|
total_expense: 2500000,
|
||||||
|
notes: 'Pakan untuk minggu pertama',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nonstock: {
|
||||||
|
id: 502,
|
||||||
|
name: 'Vitamin C Ayam',
|
||||||
|
uom_id: 2,
|
||||||
|
uom: {
|
||||||
|
id: 2,
|
||||||
|
name: 'Botol',
|
||||||
|
},
|
||||||
|
suppliers: [],
|
||||||
|
flags: ['VITAMIN', 'IS_ACTIVE'],
|
||||||
|
created_user: {
|
||||||
|
id: 6,
|
||||||
|
id_user: 106,
|
||||||
|
email: 'staff@example.com',
|
||||||
|
name: 'Inventory Staff',
|
||||||
|
},
|
||||||
|
created_at: '2025-09-25T08:00:00Z',
|
||||||
|
updated_at: '2025-09-30T09:00:00Z',
|
||||||
|
},
|
||||||
|
total_quantity: 20,
|
||||||
|
total_expense: 400000,
|
||||||
|
notes: 'Untuk menjaga daya tahan ayam',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ExpenseApiService extends BaseApiService<
|
||||||
|
Expense,
|
||||||
|
FormData,
|
||||||
|
FormData
|
||||||
|
> {
|
||||||
|
constructor(basePath: string) {
|
||||||
|
super(basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove this and integrate to real API
|
||||||
|
async create(
|
||||||
|
payload: FormData
|
||||||
|
): Promise<BaseApiResponse<Expense> | undefined> {
|
||||||
|
await sleep(750);
|
||||||
|
|
||||||
|
const sentPayload = new Map();
|
||||||
|
for (const pair of payload.entries()) {
|
||||||
|
sentPayload.set(pair[0], pair[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({ sentPayload });
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
status: 'success',
|
||||||
|
message: 'Berhasil membuat pengajuan biaya operasional!',
|
||||||
|
data: DUMMY_EXPENSE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
convertPayloadToFormData = (payload: CreateExpensePayload) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('locationId', String(payload.locationId));
|
||||||
|
formData.append('transaction_date', payload.transaction_date);
|
||||||
|
formData.append('vendorId', String(payload.vendorId));
|
||||||
|
|
||||||
|
// kandangIds (array)
|
||||||
|
payload.kandangIds.forEach((id, index) => {
|
||||||
|
formData.append(`kandangIds[${index}]`, String(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// request_documents (array of File)
|
||||||
|
payload.request_documents.forEach((file, index) => {
|
||||||
|
formData.append(`request_documents[${index}]`, file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// kandang_expenses (nested array)
|
||||||
|
payload.kandang_expenses.forEach((kandangExpense, kandangIndex) => {
|
||||||
|
formData.append(
|
||||||
|
`kandang_expenses[${kandangIndex}][kandangId]`,
|
||||||
|
String(kandangExpense.kandangId)
|
||||||
|
);
|
||||||
|
|
||||||
|
kandangExpense.expenses.forEach((expenseItem, expenseIndex) => {
|
||||||
|
formData.append(
|
||||||
|
`kandang_expenses[${kandangIndex}][expenses][${expenseIndex}][nonstockId]`,
|
||||||
|
String(expenseItem.nonstockId)
|
||||||
|
);
|
||||||
|
formData.append(
|
||||||
|
`kandang_expenses[${kandangIndex}][expenses][${expenseIndex}][total_quantity]`,
|
||||||
|
String(expenseItem.total_quantity)
|
||||||
|
);
|
||||||
|
formData.append(
|
||||||
|
`kandang_expenses[${kandangIndex}][expenses][${expenseIndex}][total_expense]`,
|
||||||
|
String(expenseItem.total_expense)
|
||||||
|
);
|
||||||
|
formData.append(
|
||||||
|
`kandang_expenses[${kandangIndex}][expenses][${expenseIndex}][notes]`,
|
||||||
|
expenseItem.notes ?? ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExpenseApi = new ExpenseApiService('/expense');
|
||||||
|
|
||||||
|
// TODO: remove this ASAP
|
||||||
|
// export const ExpenseApi = new BaseApiService<
|
||||||
|
// Expense,
|
||||||
|
// CreateExpensePayload,
|
||||||
|
// UpdateExpensePayload
|
||||||
|
// >('/master-data/uoms');
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
export type BaseExpense = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Expense = BaseMetadata & BaseExpense;
|
||||||
|
|
||||||
|
export type CreateExpensePayload = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateExpensePayload = CreateExpensePayload;
|
||||||
Reference in New Issue
Block a user