mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Compare commits
375 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71edc9c68a | |||
| 2a33fdbbbe | |||
| 178c659b58 | |||
| c1d6436583 | |||
| 8dc62453bd | |||
| 244d800874 | |||
| 52dd1613bb | |||
| 57ea81fdf2 | |||
| 90742604cb | |||
| 4b8853b766 | |||
| 7168270527 | |||
| 47b186e195 | |||
| ff39514b78 | |||
| f97b6fc218 | |||
| 62dc8235d4 | |||
| 73d05d6b4b | |||
| 3c44906a20 | |||
| e4b1deecdc | |||
| 12afa88f2c | |||
| 0527155bb9 | |||
| 34a45d084b | |||
| 9de897dfbd | |||
| 907f6664e1 | |||
| 3ad04e5bac | |||
| 4649dfde89 | |||
| b580a01bdc | |||
| 15ec6c3b9c | |||
| 8b970aeb64 | |||
| de6fd2367e | |||
| 3fedbc7ffb | |||
| 9e297cc0a4 | |||
| 6ff3a715e0 | |||
| cd4cef883e | |||
| d853781c17 | |||
| 8faed2e561 | |||
| 8aeef46ee3 | |||
| c9000c1e2c | |||
| bb3541090a | |||
| ad0e617ed0 | |||
| 73ef1c2ece | |||
| 9cf0d15c33 | |||
| c66f7b1cbf | |||
| 17ebc31f00 | |||
| ce0b4d744c | |||
| f6f3290743 | |||
| 31a4dec8a3 | |||
| fbb6f87368 | |||
| d88d71fb16 | |||
| 570024c2e6 | |||
| 70556d04ba | |||
| 00434002a7 | |||
| bcb1e0b5b6 | |||
| 03a6aabf1f | |||
| 47adaa4f92 | |||
| f2cdbd497a | |||
| 4ffea739a9 | |||
| bf5591d61d | |||
| a725ae4891 | |||
| f5d3fb3b9d | |||
| 4a6c443003 | |||
| 09f4af3ece | |||
| 62d250109b | |||
| e50f4dbddb | |||
| c898154b48 | |||
| acb02c9bdc | |||
| 986f429ea9 | |||
| 1dafb0d365 | |||
| 095b1c5850 | |||
| 4297502c55 | |||
| 726065da51 | |||
| 6c03e42006 | |||
| 5c39e900f3 | |||
| 68c1655824 | |||
| 0ef8c06e41 | |||
| 68b25332b1 | |||
| b402a06706 | |||
| 7df2fad959 | |||
| 0b52fff5f5 | |||
| e251ab9eb4 | |||
| 9f0fbcf041 | |||
| 05fbae680f | |||
| 444c475cb4 | |||
| ef1ce2c78c | |||
| 429ff58bfd | |||
| 8961004000 | |||
| 2dc3bcf9f0 | |||
| 9c31705865 | |||
| b89730ab68 | |||
| 6e34eede4b | |||
| ffd5e70947 | |||
| 2f89c6f216 | |||
| ebf966228b | |||
| ebe1d77c72 | |||
| 922a93414f | |||
| 854a1e7c4c | |||
| 129a3fda44 | |||
| 981b48acc0 | |||
| dd5bbf0ac6 | |||
| 8872b283ac | |||
| 860c9dec22 | |||
| 0cd6c9bd2f | |||
| 107d412c10 | |||
| c6d8533190 | |||
| ae90d55f81 | |||
| c155717459 | |||
| d74de4b2d9 | |||
| 529ba21f47 | |||
| 449c2030fe | |||
| 679740972f | |||
| 57b8326fdf | |||
| e26b5127c5 | |||
| 1a4a1e8e56 | |||
| 10d1f05aa5 | |||
| e4b6238771 | |||
| fc89922ed1 | |||
| 25b5165249 | |||
| cae19d905b | |||
| c040c0e9bb | |||
| b3bd7563fa | |||
| edf21fbfc4 | |||
| 65f31f8340 | |||
| 772087bacd | |||
| f302bcdb4b | |||
| b68bcc77f5 | |||
| 3d7a2073b0 | |||
| 8b1546a305 | |||
| fa3d0a1179 | |||
| ffcf422cb5 | |||
| 8d92da75cf | |||
| 6b29406307 | |||
| 4f3e304b2b | |||
| 18aff48dc2 | |||
| 7bee13124d | |||
| f5d9dcbdf6 | |||
| 04d22e55db | |||
| 8bf5a88edb | |||
| ee0c47b0a1 | |||
| 93d4ff9339 | |||
| aa52211b0a | |||
| 39cb38a23f | |||
| c1087b37fb | |||
| c4e27edd56 | |||
| b020f2b187 | |||
| 375de4c86c | |||
| 8f361c4327 | |||
| 710be88794 | |||
| 0f7a2bd796 | |||
| 0c8a833e00 | |||
| 0c42bfd70c | |||
| 85dee607e0 | |||
| 6c7e310e67 | |||
| 85f6677c2a | |||
| 811850857d | |||
| 5494cb0ff2 | |||
| 11eeac3289 | |||
| 1621f2ab7d | |||
| 5540787154 | |||
| 1b499bc967 | |||
| 44a5c51023 | |||
| aa13e989c1 | |||
| ebe7c367e7 | |||
| 2f085c287f | |||
| 058f9f403d | |||
| 8b8b7be4b7 | |||
| efcecf4f66 | |||
| a6c63a7dcb | |||
| 0263db9fae | |||
| cc08e3af15 | |||
| 0929461ec5 | |||
| ace6633f79 | |||
| f1a952ca6b | |||
| ed34a99117 | |||
| 4beaba1f15 | |||
| 8ea029efdd | |||
| 02e4dba288 | |||
| c42fdbf33d | |||
| 2cfa8c046b | |||
| 30d5516161 | |||
| f83abc91da | |||
| 918c51e83b | |||
| f1a4d9b648 | |||
| 29e33560f8 | |||
| fb9e863862 | |||
| 1b3e5f94f1 | |||
| e1856926ea | |||
| 2b096099d3 | |||
| ea25417e8d | |||
| 668abeca23 | |||
| deabb1c3ee | |||
| 121c44070c | |||
| 0dbad23cd5 | |||
| b9a17f472b | |||
| c07b245eeb | |||
| d7e32f8f5b | |||
| 698fe2e851 | |||
| cdf0442a2b | |||
| 422c7c9fb0 | |||
| 3042b54577 | |||
| e5a686e5ee | |||
| 37d5a6b675 | |||
| 2ff32094ce | |||
| 7207f1ba75 | |||
| 41d2e8737b | |||
| b2016314f5 | |||
| 7366d6490c | |||
| e5e9b517fd | |||
| b6629b0bbb | |||
| bac6766fa2 | |||
| 9389fa0354 | |||
| 37bc7a85e5 | |||
| 667eb41eb2 | |||
| e64b55e527 | |||
| 9710998dc6 | |||
| ca0b216ba0 | |||
| 9ff6f3a35d | |||
| 333dd01f92 | |||
| 75f765ee69 | |||
| 945bdb8b27 | |||
| 77eae32a3d | |||
| a5ebc6d1ae | |||
| 15c7452d7b | |||
| 5dac900a1a | |||
| 4b49cd18f5 | |||
| a4cb4e202b | |||
| 4a69eef294 | |||
| 2de6636bbf | |||
| a7951b6c28 | |||
| 03baba40a6 | |||
| 96ef6f8496 | |||
| 94ab48d3f6 | |||
| ea88c3ce8e | |||
| 267ef9d812 | |||
| b5fc1d4310 | |||
| c98e7d8cb3 | |||
| 1acbc91cfe | |||
| 807041834b | |||
| 4de21561b3 | |||
| 1938f6cbda | |||
| f4a522fc0c | |||
| 7ae04b3f3e | |||
| b786acf71a | |||
| bb28ae8613 | |||
| 080592ff01 | |||
| 6d41203a5e | |||
| 89d7d3ef91 | |||
| 18ebf75aa7 | |||
| 6e7dfebacc | |||
| 4b93cb4589 | |||
| 58f1ab82c7 | |||
| 1fb9687142 | |||
| 4f6d71f1f4 | |||
| d502ec707c | |||
| fa86f488e1 | |||
| 5e5400f56b | |||
| 741884ac29 | |||
| 4e278c5687 | |||
| 3d2e40518b | |||
| cbab7f52f2 | |||
| ceb316d3da | |||
| 9ea152aef9 | |||
| 910ea85b62 | |||
| 968243c370 | |||
| f1ba577a97 | |||
| c6b906a28e | |||
| eafcfd2f28 | |||
| 0e5d38f75c | |||
| f2b59ded3c | |||
| 1341b1ff53 | |||
| 9c4c750664 | |||
| d3501e5f3d | |||
| d96388e5f4 | |||
| 749b7d6f1a | |||
| aadf10b8b9 | |||
| 7db6ae4077 | |||
| 7eaf6b7a3a | |||
| 8397d76171 | |||
| 56d4b8a5c9 | |||
| ef9d820c0d | |||
| 4e3c6736ab | |||
| aa1ef7a559 | |||
| 04b5a7bd4d | |||
| 3aec412599 | |||
| 80a94c48c3 | |||
| 9bd4a73a90 | |||
| d1c6fe8fb4 | |||
| a4378ebd04 | |||
| 16f2f2bc06 | |||
| 64843a36ab | |||
| 396a5ab5ba | |||
| 9f4c041ec8 | |||
| 5e3d2273d1 | |||
| 0b01eefe20 | |||
| 43045b3f8b | |||
| d3ce60d3ba | |||
| 8c7577dcc5 | |||
| da3cadc37d | |||
| 2e6aa1b83a | |||
| 8811976f53 | |||
| 9fb65bdacd | |||
| 53e018aece | |||
| 6f6f54571f | |||
| 3099588141 | |||
| 47ee911852 | |||
| b4d0ed1537 | |||
| 36f2368e95 | |||
| a4d5cbb117 | |||
| c926a81756 | |||
| 2476b6a4b4 | |||
| 01c1843fd5 | |||
| f86498e350 | |||
| 5cd24f2c46 | |||
| da0be9cb52 | |||
| da1cdfb59e | |||
| a991150262 | |||
| ee49c91fba | |||
| 75463326e3 | |||
| e98c49ac5a | |||
| aeeb0b721c | |||
| b14bc00af4 | |||
| e2a4088e77 | |||
| ebe80358ee | |||
| 9d4e9f6318 | |||
| e5007a285a | |||
| da82c704d5 | |||
| 88b9c890e5 | |||
| 5a67901722 | |||
| 0031a65f97 | |||
| a89e83af29 | |||
| a75d84556a | |||
| 0af7b172a0 | |||
| 8be33b230b | |||
| 4fda2f661a | |||
| 22b1102454 | |||
| e2e64f093f | |||
| 90942b41b9 | |||
| 93a2d99b7f | |||
| dae9a24a7c | |||
| f701ab0d91 | |||
| 47a2439777 | |||
| cce84a3a6f | |||
| f92622cc22 | |||
| f0041ca938 | |||
| 6566b881b2 | |||
| 1a2e38568b | |||
| ced3970aae | |||
| 9de31c991d | |||
| bdca10e0ac | |||
| 9a5e2987d5 | |||
| a5b4deaac4 | |||
| 82953f4c9c | |||
| 509fc5476d | |||
| 6a10849a84 | |||
| 50424a25fc | |||
| 35b09b514f | |||
| 81e4e1fc6a | |||
| 62c9ab014d | |||
| ba28d64562 | |||
| 75ee058818 | |||
| 755bddc74c | |||
| 08aa1900a8 | |||
| 7e1166b5e8 | |||
| 75e9b06a83 | |||
| b2f6c6c485 | |||
| ce1114d724 | |||
| 92c07e7841 | |||
| 847772616e | |||
| aea35d4b9f | |||
| 32f4cf411f | |||
| 84cbbaf238 | |||
| 5c50e4a0c1 | |||
| e2be39af18 | |||
| e9cd84e89e | |||
| ec5962bccc | |||
| 8fd442621a | |||
| 35471fc597 |
@@ -45,3 +45,6 @@ next-env.d.ts
|
||||
|
||||
# claude
|
||||
.claude
|
||||
|
||||
# rtk
|
||||
rtk.exe
|
||||
|
||||
+30
-2
@@ -15,7 +15,7 @@ default:
|
||||
# ==========================================================
|
||||
.build_template: &build_template
|
||||
stage: build
|
||||
image: node:20-alpine
|
||||
image: public.ecr.aws/docker/library/node:20-alpine
|
||||
cache:
|
||||
key: npm-cache
|
||||
paths:
|
||||
@@ -56,7 +56,7 @@ default:
|
||||
.deploy_template: &deploy_template
|
||||
stage: deploy
|
||||
image:
|
||||
name: amazon/aws-cli:latest
|
||||
name: public.ecr.aws/aws-cli/aws-cli:latest
|
||||
entrypoint: ['/bin/sh', '-c']
|
||||
script:
|
||||
- set -e
|
||||
@@ -183,3 +183,31 @@ deploy:staging:
|
||||
environment:
|
||||
name: staging
|
||||
url: https://stg-lti-erp.mbugroup.id
|
||||
|
||||
# ==========================================================
|
||||
# ====== STAGING (Branch production) ======
|
||||
# ==========================================================
|
||||
build:production:
|
||||
<<: *build_template
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
environment:
|
||||
name: staging
|
||||
variables:
|
||||
NEXT_PUBLIC_LTI_URL: 'https://lti-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
|
||||
deploy:production:
|
||||
<<: *deploy_template
|
||||
needs: ['build:production']
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
variables:
|
||||
S3_BUCKET: 'production-lti-erp.mbugroup.id'
|
||||
AWS_REGION: 'ap-southeast-3'
|
||||
CLOUDFRONT_DISTRIBUTION_ID: 'E1SSLXKYYITASJ'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://lti-erp.mbugroup.id
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
npm run format
|
||||
npm run lint
|
||||
npx tsc --noEmit
|
||||
npm run typecheck
|
||||
+15
-28
@@ -1,38 +1,25 @@
|
||||
# =========================
|
||||
# Builder stage
|
||||
# =========================
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM public.ecr.aws/docker/library/node:20-alpine
|
||||
|
||||
RUN apk add --no-cache git bash build-base curl
|
||||
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build API binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api
|
||||
# Buat config agar Next tahu output: export
|
||||
RUN echo "const config = { output: 'export', images: { unoptimized: true } }; export default config;" > next.config.mjs
|
||||
|
||||
# Build SEED binary (pastikan cmd/seed ada)
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed
|
||||
# Build project (Next.js 15 otomatis static export)
|
||||
RUN NEXT_DISABLE_TURBOPACK=1 npx next build
|
||||
|
||||
# =========================
|
||||
# Runtime stage
|
||||
# =========================
|
||||
FROM alpine:3.20
|
||||
# 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/
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \
|
||||
&& adduser -D -H -u 10001 appuser
|
||||
EXPOSE 3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/lti-api /app/lti-api
|
||||
COPY --from=builder /app/lti-seed /app/lti-seed
|
||||
|
||||
USER appuser
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["/app/lti-api"]
|
||||
CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
+3
-1
@@ -7,8 +7,10 @@
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"prepare": "husky",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"pre-commit": "npm run format && npm run lint && npm run typecheck && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MasterKandangContent } from '@/figma-make/components/pages/master-data/kandang/MasterKandangContent';
|
||||
|
||||
const MasterKandangPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<MasterKandangContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasterKandangPage;
|
||||
@@ -3,12 +3,7 @@
|
||||
import FinanceTable from '@/components/pages/finance/FinanceTable';
|
||||
|
||||
const Finance = () => {
|
||||
return (
|
||||
<section className='size-full p-6'>
|
||||
<div className='flex flex-row gap-4'></div>
|
||||
<FinanceTable />
|
||||
</section>
|
||||
);
|
||||
return <FinanceTable />;
|
||||
};
|
||||
|
||||
export default Finance;
|
||||
|
||||
@@ -2,7 +2,7 @@ import InventoryAdjustmentTable from '@/components/pages/inventory/adjustment/In
|
||||
|
||||
const InventoryAdjustment = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<section className='w-full'>
|
||||
<InventoryAdjustmentTable />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import AreasTable from '@/components/pages/master-data/area/AreasTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<AreasTable />
|
||||
</section>
|
||||
);
|
||||
return <AreasTable />;
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import BanksTable from '@/components/pages/master-data/bank/BanksTable';
|
||||
|
||||
const Bank = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<BanksTable />
|
||||
</section>
|
||||
);
|
||||
return <BanksTable />;
|
||||
};
|
||||
|
||||
export default Bank;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import CustomersTable from '@/components/pages/master-data/customer/CustomersTable';
|
||||
|
||||
const Customer = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<CustomersTable />
|
||||
</section>
|
||||
);
|
||||
return <CustomersTable />;
|
||||
};
|
||||
|
||||
export default Customer;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import FlockTable from '@/components/pages/master-data/flock/FlocksTable';
|
||||
|
||||
const Flock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<FlockTable />
|
||||
</section>
|
||||
);
|
||||
return <FlockTable />;
|
||||
};
|
||||
|
||||
export default Flock;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<KandangsTable />
|
||||
</section>
|
||||
);
|
||||
return <KandangsTable />;
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import LocationsTable from '@/components/pages/master-data/location/LocationsTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<LocationsTable />
|
||||
</section>
|
||||
);
|
||||
return <LocationsTable />;
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<NonstocksTable />
|
||||
</section>
|
||||
);
|
||||
return <NonstocksTable />;
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import ProductCategoryTable from '@/components/pages/master-data/product-category/ProductCategoryTable';
|
||||
|
||||
const ProductCategory = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<ProductCategoryTable />
|
||||
</section>
|
||||
);
|
||||
return <ProductCategoryTable />;
|
||||
};
|
||||
|
||||
export default ProductCategory;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import ProductsTable from '@/components/pages/master-data/product/ProductTable';
|
||||
|
||||
const Product = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<ProductsTable />
|
||||
</section>
|
||||
);
|
||||
return <ProductsTable />;
|
||||
};
|
||||
|
||||
export default Product;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import ProductionStandardTable from '@/components/pages/master-data/production-standard/ProductionStandardTable';
|
||||
|
||||
const ProductionStandardPage = () => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<ProductionStandardTable />
|
||||
</div>
|
||||
);
|
||||
return <ProductionStandardTable />;
|
||||
};
|
||||
|
||||
export default ProductionStandardPage;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import SuppliersTable from '@/components/pages/master-data/supplier/SupplierTable';
|
||||
|
||||
const Supplier = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<SuppliersTable />
|
||||
</section>
|
||||
);
|
||||
return <SuppliersTable />;
|
||||
};
|
||||
|
||||
export default Supplier;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import UomsTable from '@/components/pages/master-data/uom/UomsTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<UomsTable />
|
||||
</section>
|
||||
);
|
||||
return <UomsTable />;
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable';
|
||||
|
||||
const Warehouse = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<WarehousesTable />
|
||||
</section>
|
||||
);
|
||||
return <WarehousesTable />;
|
||||
};
|
||||
|
||||
export default Warehouse;
|
||||
|
||||
@@ -11,10 +11,13 @@ const RecordingEdit = () => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const recordingId = searchParams.get('recordingId');
|
||||
const recordingDetailKey = recordingId
|
||||
? ['recording-detail', recordingId]
|
||||
: null;
|
||||
|
||||
const { data: recording, isLoading: isLoadingRecording } = useSWR(
|
||||
recordingId,
|
||||
(id: string) => RecordingApi.getSingle(parseInt(id))
|
||||
recordingDetailKey,
|
||||
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
|
||||
);
|
||||
|
||||
if (!recordingId) {
|
||||
|
||||
@@ -11,10 +11,13 @@ const RecordingDetail = () => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const recordingId = searchParams.get('recordingId');
|
||||
const recordingDetailKey = recordingId
|
||||
? ['recording-detail', recordingId]
|
||||
: null;
|
||||
|
||||
const { data: recording, isLoading: isLoadingRecording } = useSWR(
|
||||
recordingId,
|
||||
(id: string) => RecordingApi.getSingle(parseInt(id))
|
||||
recordingDetailKey,
|
||||
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
|
||||
);
|
||||
|
||||
if (!recordingId) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import RecordingTable from '@/components/pages/production/recording/RecordingTab
|
||||
|
||||
const Recording = () => {
|
||||
return (
|
||||
<section className='w-full p-4 sm:p-0'>
|
||||
<section className='w-full'>
|
||||
<RecordingTable />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ const Button = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!href && (
|
||||
{(!href || (href && disabled)) && (
|
||||
<button
|
||||
{...props}
|
||||
type={type}
|
||||
@@ -68,9 +68,9 @@ const Button = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{href && (
|
||||
{href && !disabled && (
|
||||
<Link
|
||||
href={disabled ? '#' : href}
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
aria-disabled={disabled}
|
||||
|
||||
@@ -108,7 +108,9 @@ const Drawer = ({
|
||||
if (closeOnBackdropClick) {
|
||||
setOpen(false);
|
||||
}
|
||||
onBackdropClick && onBackdropClick();
|
||||
if (onBackdropClick) {
|
||||
onBackdropClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -31,7 +31,11 @@ export const useModal = (isNestingModal = false) => {
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
open ? closeModal() : openModal();
|
||||
if (open) {
|
||||
closeModal();
|
||||
} else {
|
||||
openModal();
|
||||
}
|
||||
}, [open, closeModal, openModal]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -26,13 +26,17 @@ const Navbar = ({ toggleSidebar }: NavbarProps) => {
|
||||
|
||||
const logoutClickHandler = async () => {
|
||||
const logoutRes = await AuthApi.logout();
|
||||
|
||||
if (isResponseError(logoutRes)) {
|
||||
toast.error('Gagal logout! Coba lagi!');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(undefined);
|
||||
const redirect = (logoutRes as { redirect?: string })?.redirect;
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
return;
|
||||
}
|
||||
router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,15 +3,51 @@ import { getFilledFormikValuesCount } from '@/lib/formik-helper';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { FormikValues } from 'formik';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export type ButtonFilterProps = ButtonProps & {
|
||||
values: FormikValues;
|
||||
onClick: () => void;
|
||||
excludeFields?: string[];
|
||||
fieldGroups?: string[][];
|
||||
};
|
||||
|
||||
// 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200
|
||||
|
||||
const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
|
||||
const ButtonFilter = ({
|
||||
values,
|
||||
onClick,
|
||||
excludeFields = [],
|
||||
fieldGroups = [],
|
||||
...props
|
||||
}: ButtonFilterProps) => {
|
||||
const activeCount = useMemo(() => {
|
||||
const filteredValues: FormikValues = {};
|
||||
Object.keys(values).forEach((key) => {
|
||||
if (!excludeFields.includes(key)) {
|
||||
filteredValues[key] = values[key];
|
||||
}
|
||||
});
|
||||
|
||||
let count = getFilledFormikValuesCount(filteredValues);
|
||||
|
||||
fieldGroups.forEach((group) => {
|
||||
const groupFields = group.filter(
|
||||
(field) => !excludeFields.includes(field)
|
||||
);
|
||||
const filledGroupFields = groupFields.filter(
|
||||
(field) => filteredValues[field]
|
||||
);
|
||||
if (
|
||||
filledGroupFields.length === groupFields.length &&
|
||||
groupFields.length > 1
|
||||
) {
|
||||
count -= groupFields.length - 1;
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}, [values, excludeFields, fieldGroups]);
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
@@ -21,7 +57,7 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
|
||||
className={cn(
|
||||
'rounded-lg max-h-10 font-semibold text-sm gap-1.5',
|
||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
|
||||
getFilledFormikValuesCount(values) > 0
|
||||
activeCount > 0
|
||||
? 'border-primary-gradient text-primary rounded-lg!'
|
||||
: 'rounded-lg',
|
||||
props.className
|
||||
@@ -31,14 +67,12 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
|
||||
icon='heroicons:funnel'
|
||||
width={20}
|
||||
height={20}
|
||||
className={
|
||||
getFilledFormikValuesCount(values) > 0 ? 'text-blue-600' : ''
|
||||
}
|
||||
className={activeCount > 0 ? 'text-blue-600' : ''}
|
||||
/>
|
||||
Filter
|
||||
{getFilledFormikValuesCount(values) > 0 && (
|
||||
{activeCount > 0 && (
|
||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||
{getFilledFormikValuesCount(values)}
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -134,14 +134,20 @@ const DropFileInput: React.FC<DropFileInputProps> = ({
|
||||
|
||||
{!isError && bottomLabel && (
|
||||
<p
|
||||
className={cn('w-full text-sm opacity-60', className?.bottomLabel)}
|
||||
className={cn(
|
||||
'w-full mt-1.5 text-xs opacity-60',
|
||||
className?.bottomLabel
|
||||
)}
|
||||
>
|
||||
{bottomLabel}
|
||||
</p>
|
||||
)}
|
||||
{isError && (
|
||||
<p
|
||||
className={cn('w-full text-sm text-error', className?.errorMessage)}
|
||||
className={cn(
|
||||
'w-full mt-1.5 text-xs text-error',
|
||||
className?.errorMessage
|
||||
)}
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
|
||||
@@ -35,7 +35,9 @@ const NumberInput = ({
|
||||
| undefined;
|
||||
|
||||
if (newChangeEvent) {
|
||||
newChangeEvent.target.value = numberFormatValues.value;
|
||||
newChangeEvent.target.value = parseFloat(
|
||||
numberFormatValues.value
|
||||
) as unknown as string;
|
||||
|
||||
onChange?.(newChangeEvent);
|
||||
}
|
||||
|
||||
@@ -144,12 +144,12 @@ export const RadioGroup = ({
|
||||
|
||||
{/* Label bawah */}
|
||||
{!isError && bottomLabel && (
|
||||
<p className='text-sm opacity-60'>{bottomLabel}</p>
|
||||
<p className='mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
|
||||
)}
|
||||
|
||||
{/* Pesan error */}
|
||||
{isError && errorMessage && (
|
||||
<p className='text-sm text-error'>{errorMessage}</p>
|
||||
<p className='mt-1.5 text-xs text-error'>{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroupContext.Provider>
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
} from '@/types/api/api-general';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
export interface OptionType {
|
||||
value: string | number;
|
||||
export interface OptionType<T = string | number> {
|
||||
value: T;
|
||||
label: string;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
@@ -488,9 +488,11 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
||||
{isError && (
|
||||
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
|
||||
)}
|
||||
{!isError && bottomLabel && (
|
||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -564,23 +566,31 @@ const useSelect = <T,>(
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
|
||||
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
|
||||
|
||||
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
|
||||
|
||||
if (isResponseSuccess(pages?.[latestPagesIndex])) {
|
||||
formattedSuccessRawData = {
|
||||
...pages?.[latestPagesIndex],
|
||||
data:
|
||||
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
|
||||
[],
|
||||
};
|
||||
}
|
||||
const { formattedSuccessRawData, formattedErrorRawData } = useMemo(() => {
|
||||
let successData: SuccessApiResponse<T[]> | undefined = undefined;
|
||||
let errorData: ErrorApiResponse | undefined = undefined;
|
||||
|
||||
if (isResponseError(pages?.[latestPagesIndex])) {
|
||||
formattedErrorRawData = pages?.[latestPagesIndex];
|
||||
}
|
||||
if (isResponseSuccess(pages?.[latestPagesIndex])) {
|
||||
successData = {
|
||||
...pages![latestPagesIndex],
|
||||
data:
|
||||
pages?.flatMap((page) =>
|
||||
isResponseSuccess(page) ? page.data : []
|
||||
) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
if (isResponseError(pages?.[latestPagesIndex])) {
|
||||
errorData = pages![latestPagesIndex];
|
||||
}
|
||||
|
||||
return {
|
||||
formattedSuccessRawData: successData,
|
||||
formattedErrorRawData: errorData,
|
||||
};
|
||||
}, [pages, latestPagesIndex]);
|
||||
|
||||
return {
|
||||
inputValue,
|
||||
|
||||
@@ -159,9 +159,11 @@ const TagInput: React.FC<TagInputProps> = ({
|
||||
|
||||
{/* Bottom label or error message */}
|
||||
{!isError && bottomLabel && (
|
||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
|
||||
)}
|
||||
{isError && (
|
||||
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
|
||||
)}
|
||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import HppExpeditionClosingTab from '@/components/pages/closing/tab/HppExpeditio
|
||||
import ClosingKandangList from '@/components/pages/closing/ClosingKandangList';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
||||
import { useClosingTabStore } from '@/stores/closing/closing-tab.store';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
interface ClosingDetailProps {
|
||||
id: number;
|
||||
initialValue?: ClosingGeneralInformation;
|
||||
@@ -33,7 +33,7 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
||||
kandangData,
|
||||
}) => {
|
||||
const [activeTabId, setActiveTabId] = useState<string>('sapronak');
|
||||
const tabActions = useClosingTabStore((state) => state.tabActions);
|
||||
const tabActions = useTabActionsStore((state) => state.tabActions);
|
||||
|
||||
const closingDetailTabs = useMemo(() => {
|
||||
const validTabs = [
|
||||
@@ -112,12 +112,11 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
||||
kandangData={kandangData}
|
||||
/>
|
||||
|
||||
{!kandangData && (
|
||||
<ClosingKandangList
|
||||
initialValue={initialValue}
|
||||
projectData={projectData}
|
||||
/>
|
||||
)}
|
||||
<ClosingKandangList
|
||||
initialValue={initialValue}
|
||||
projectData={projectData}
|
||||
selectedKandangId={kandangData?.id}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
activeTabId={activeTabId}
|
||||
|
||||
@@ -5,9 +5,11 @@ import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
const ClosingKandangList = ({
|
||||
initialValue,
|
||||
projectData,
|
||||
selectedKandangId,
|
||||
}: {
|
||||
initialValue?: ClosingGeneralInformation;
|
||||
projectData?: ProjectFlock;
|
||||
selectedKandangId?: number;
|
||||
}) => {
|
||||
return (
|
||||
<div className='w-full py-3 @container relative before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10'>
|
||||
@@ -22,6 +24,9 @@ const ClosingKandangList = ({
|
||||
variant='outline'
|
||||
className='px-3 py-2.5 w-fit text-sm rounded-lg shadow-sm'
|
||||
href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`}
|
||||
disabled={
|
||||
selectedKandangId === kandang.project_flock_kandang_id
|
||||
}
|
||||
>
|
||||
{kandang.name}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState, useMemo } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
ClosingFilterType,
|
||||
} from '@/components/pages/closing/filter/ClosingFilter';
|
||||
import ClosingTableSkeleton from '@/components/pages/closing/skeleton/ClosingTableSkeleton';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
props,
|
||||
@@ -90,6 +93,9 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ClosingsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
// ===== ROUTER =====
|
||||
const router = useRouter();
|
||||
|
||||
@@ -158,6 +164,7 @@ const ClosingsTable = () => {
|
||||
onReset: () => {
|
||||
updateFilter('location_id', '');
|
||||
updateFilter('project_status', '');
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -287,25 +294,17 @@ const ClosingsTable = () => {
|
||||
);
|
||||
}, [formik.values.project_status, projectStatusOptions]);
|
||||
|
||||
// ===== ACTIVE FILTERS COUNT =====
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
let count = 0;
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
if (tableFilterState.location_id) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if (tableFilterState.project_status) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return count;
|
||||
}, [tableFilterState.location_id, tableFilterState.project_status]);
|
||||
|
||||
const hasFilters = activeFiltersCount > 0;
|
||||
useEffect(() => {
|
||||
setTableState('closing-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
// ===== SEARCH CHANGE HANDLER =====
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
@@ -352,25 +351,12 @@ const ClosingsTable = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize', 'search']}
|
||||
onClick={handleFilterModalOpen}
|
||||
className={cn(
|
||||
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||
{
|
||||
'border-primary-gradient text-primary': hasFilters,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
Filter
|
||||
{hasFilters && (
|
||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -379,19 +365,19 @@ const ClosingsTable = () => {
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<ClosingTableSkeleton
|
||||
columns={closingsColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:chart-bar'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
title='Data Closing Belum Tersedia'
|
||||
subtitle='Tidak ada data closing untuk saat ini.'
|
||||
/>
|
||||
<div className='mt-3'>
|
||||
<ClosingTableSkeleton
|
||||
columns={closingsColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Closing>
|
||||
data={isResponseSuccess(closings) ? closings?.data : []}
|
||||
@@ -410,10 +396,7 @@ const ClosingsTable = () => {
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
className={{
|
||||
containerClassName: cn('mt-3', {
|
||||
'w-full mb-0':
|
||||
isResponseSuccess(closings) && closings?.data?.length === 0,
|
||||
}),
|
||||
containerClassName: cn('mt-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,13 +6,13 @@ import { ColumnDef } from '@tanstack/react-table';
|
||||
const ClosingTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no closing data displayed. Enter closing data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Closing>[];
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
|
||||
@@ -276,7 +276,7 @@ const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
|
||||
{
|
||||
id: 'kandang',
|
||||
accessorKey: 'kandang',
|
||||
header: 'Kandang',
|
||||
header: 'Kandang Atribusi',
|
||||
cell: (props) => {
|
||||
const kandang = props.getValue() as Kandang;
|
||||
return kandang?.name || '-';
|
||||
@@ -308,7 +308,16 @@ const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
|
||||
// },
|
||||
// },
|
||||
],
|
||||
[]
|
||||
[
|
||||
summary,
|
||||
totals.avgActualPrice,
|
||||
totals.avgSalesPrice,
|
||||
totals.avgWeight,
|
||||
totals.totalActualPrice,
|
||||
totals.totalQuantity,
|
||||
totals.totalSalesPrice,
|
||||
totals.totalWeight,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -127,11 +127,11 @@ const ClosingOutgoingSapronaksTable = ({
|
||||
},
|
||||
{
|
||||
accessorKey: 'source_warehouse',
|
||||
header: 'Gudang Asal',
|
||||
header: 'Gudang Asal (Fisik)',
|
||||
},
|
||||
{
|
||||
accessorKey: 'destination_warehouse',
|
||||
header: 'Gudang Tujuan',
|
||||
header: 'Gudang Tujuan (Fisik)',
|
||||
},
|
||||
{
|
||||
accessorKey: 'quantity',
|
||||
|
||||
@@ -9,8 +9,11 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardApi } from '@/services/api/dashboard';
|
||||
import { useFormik } from 'formik';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
import {
|
||||
ProjectFlockApi,
|
||||
ProjectFlockKandangApi,
|
||||
} from '@/services/api/production';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
|
||||
import {
|
||||
DashboardFilterType,
|
||||
@@ -22,10 +25,7 @@ import DashboardExportCharts, {
|
||||
DashboardExportChartsRef,
|
||||
} from '@/components/pages/dashboard/export/DashboardExportCharts';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
|
||||
import {
|
||||
DashboardFilter,
|
||||
DashboardMeta,
|
||||
} from '@/types/api/dashboard/dashboard';
|
||||
import { DashboardMeta } from '@/types/api/dashboard/dashboard';
|
||||
import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
@@ -42,6 +42,8 @@ import { cn } from '@/lib/helper';
|
||||
import DashboardExportStats, {
|
||||
DashboardExportStatsRef,
|
||||
} from '@/components/pages/dashboard/export/DashboardExportStats';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
||||
|
||||
// Helper function to normalize values to array
|
||||
const normalizeToArray = (
|
||||
@@ -68,7 +70,6 @@ const DashboardProduction = () => {
|
||||
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
|
||||
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
|
||||
);
|
||||
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
|
||||
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
|
||||
normalizeToArray(filterValues.location)
|
||||
);
|
||||
@@ -80,9 +81,29 @@ const DashboardProduction = () => {
|
||||
const {
|
||||
data: dashboardProductionResponse,
|
||||
isLoading: isLoadingDashboardProductionData,
|
||||
mutate: refreshDashboardProductionData,
|
||||
} = useSWR(endpointUrl, () =>
|
||||
DashboardApi.getDashboardProductionFetcher(endpointUrl)
|
||||
} = useSWR(
|
||||
[
|
||||
'dashboard-production',
|
||||
filterValues.startDate ?? '',
|
||||
filterValues.endDate ?? '',
|
||||
filterValues.analysisMode ?? 'OVERVIEW',
|
||||
normalizeToArray(filterValues.location).toString(),
|
||||
normalizeToArray(filterValues.flock).toString(),
|
||||
normalizeToArray(filterValues.kandang).toString(),
|
||||
filterValues.comparisonType ?? '',
|
||||
],
|
||||
() =>
|
||||
DashboardApi.getDashboardProductionFetcher({
|
||||
start_date: filterValues.startDate || '',
|
||||
end_date: filterValues.endDate || '',
|
||||
analysis_mode:
|
||||
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') ||
|
||||
'OVERVIEW',
|
||||
location_ids: normalizeToArray(filterValues.location),
|
||||
flock_ids: normalizeToArray(filterValues.flock),
|
||||
kandang_ids: normalizeToArray(filterValues.kandang),
|
||||
comparison_type: filterValues.comparisonType || '',
|
||||
})
|
||||
);
|
||||
|
||||
const dashboardProductionData = isResponseSuccess(dashboardProductionResponse)
|
||||
@@ -95,23 +116,23 @@ const DashboardProduction = () => {
|
||||
options: flockOptions,
|
||||
isLoadingOptions: isLoadingFlockOptions,
|
||||
loadMore: loadMoreFlock,
|
||||
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
});
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
{
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
setInputValue: setInputValueLocation,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocation,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name');
|
||||
const {
|
||||
setInputValue: setInputValueKandang,
|
||||
options: kandangOptions,
|
||||
isLoadingOptions: isLoadingKandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
});
|
||||
|
||||
const comparisonTypeOptions = [
|
||||
{ value: 'FARM', label: 'Farm' },
|
||||
{ value: 'FLOCK', label: 'Flock' },
|
||||
@@ -135,75 +156,56 @@ const DashboardProduction = () => {
|
||||
enableReinitialize: true,
|
||||
validationSchema: getDashboardFilterSchema(analysisMode),
|
||||
onSubmit: (values) => {
|
||||
// Save filter values to store
|
||||
setFilterValues(values);
|
||||
|
||||
handleApplyFilter({
|
||||
start_date: values.startDate || '',
|
||||
end_date: values.endDate || '',
|
||||
analysis_mode: values.analysisMode as 'OVERVIEW' | 'COMPARISON',
|
||||
location_ids: normalizeToArray(values.location),
|
||||
flock_ids: normalizeToArray(values.flock),
|
||||
kandang_ids: normalizeToArray(values.kandang),
|
||||
comparison_type: values.comparisonType,
|
||||
});
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
const { resetForm } = formik;
|
||||
|
||||
const selectedLocationValues = normalizeToArray(formik.values.location);
|
||||
const selectedFlockValues = normalizeToArray(formik.values.flock);
|
||||
|
||||
const {
|
||||
setInputValue: setInputValueKandang,
|
||||
options: kandangOptions,
|
||||
isLoadingOptions: isLoadingKandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect<ProjectFlockKandang>(
|
||||
ProjectFlockKandangApi.basePath,
|
||||
'kandang_id',
|
||||
'kandang.name',
|
||||
'search',
|
||||
{
|
||||
location_id:
|
||||
selectedLocationValues.length > 0
|
||||
? selectedLocationValues.toString()
|
||||
: '',
|
||||
project_flock_id:
|
||||
selectedFlockValues.length > 0 ? selectedFlockValues.toString() : '',
|
||||
}
|
||||
);
|
||||
|
||||
const handleResetFilter = useCallback(() => {
|
||||
formik.resetForm();
|
||||
resetForm();
|
||||
resetFilterValues(); // Clear stored filter values
|
||||
setAnalysisMode('OVERVIEW');
|
||||
setEndpointUrl('/dashboards');
|
||||
setSelectedLocationIds([]);
|
||||
}, [resetFilterValues, filterValues, selectedLocationIds]);
|
||||
|
||||
const handleApplyFilter = (values: DashboardFilter) => {
|
||||
// Build query params object, only include non-empty values
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (values.start_date) params.start_date = values.start_date;
|
||||
if (values.end_date) params.end_date = values.end_date;
|
||||
if (values.analysis_mode) params.analysis_mode = values.analysis_mode;
|
||||
if (values.location_ids.length > 0)
|
||||
params.location_ids = values.location_ids.toString();
|
||||
if (values.flock_ids.length > 0)
|
||||
params.flock_ids = values.flock_ids.toString();
|
||||
if (values.kandang_ids.length > 0)
|
||||
params.kandang_ids = values.kandang_ids.toString();
|
||||
if (values.comparison_type) params.comparison_type = values.comparison_type;
|
||||
|
||||
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
|
||||
filterModal.closeModal();
|
||||
refreshDashboardProductionData();
|
||||
};
|
||||
|
||||
// ===== Load filter from store on mount =====
|
||||
useEffect(() => {
|
||||
if (!filterValues) return;
|
||||
handleApplyFilter({
|
||||
start_date: filterValues.startDate,
|
||||
end_date: filterValues.endDate,
|
||||
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
|
||||
location_ids: normalizeToArray(filterValues.location),
|
||||
flock_ids: normalizeToArray(filterValues.flock),
|
||||
kandang_ids: normalizeToArray(filterValues.kandang),
|
||||
comparison_type: filterValues.comparisonType,
|
||||
});
|
||||
}, [filterValues]);
|
||||
}, [filterModal, resetForm, resetFilterValues]);
|
||||
|
||||
// ===== Formik Error List =====
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||
|
||||
// ===== Export PDF =====
|
||||
const handleExportPDF = async () => {
|
||||
const handleExportPDF = useCallback(async () => {
|
||||
await generateDashboardPDF({
|
||||
filterValues: formik.values,
|
||||
allStatsRef,
|
||||
allChartsRef,
|
||||
setExporting,
|
||||
});
|
||||
};
|
||||
}, [formik.values]);
|
||||
|
||||
// ===== Register Navbar Actions =====
|
||||
const openFilterModalRef = useRef(filterModal.openModal);
|
||||
@@ -253,7 +255,7 @@ const DashboardProduction = () => {
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}, [formik.values, exporting, setNavbarActions]);
|
||||
}, [formik.values, exporting, setNavbarActions, handleExportPDF]);
|
||||
|
||||
// Cleanup only on unmount
|
||||
useEffect(() => {
|
||||
@@ -262,14 +264,6 @@ const DashboardProduction = () => {
|
||||
};
|
||||
}, [clearNavbarActions]);
|
||||
|
||||
if (isLoadingDashboardProductionData) {
|
||||
return (
|
||||
<div className='w-full min-h-screen flex items-center justify-center'>
|
||||
<span className='loading loading-spinner loading-xl'></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full p-3 space-y-3'>
|
||||
@@ -321,9 +315,15 @@ const DashboardProduction = () => {
|
||||
</div>
|
||||
{/* Dashboard Stats */}
|
||||
<div>
|
||||
<DashboardStats
|
||||
data={dashboardProductionData?.statistics_data ?? []}
|
||||
/>
|
||||
{isLoadingDashboardProductionData ? (
|
||||
<div className='w-full min-h-screen flex items-center justify-center'>
|
||||
<span className='loading loading-spinner loading-xl'></span>
|
||||
</div>
|
||||
) : (
|
||||
<DashboardStats
|
||||
data={dashboardProductionData?.statistics_data ?? []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use DashboardLineChart component or skeleton */}
|
||||
@@ -531,6 +531,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -567,6 +568,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
) : (
|
||||
<SelectInputRadio
|
||||
@@ -598,6 +600,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -637,6 +640,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
) : (
|
||||
<SelectInputRadio
|
||||
@@ -663,6 +667,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -701,6 +706,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
) : (
|
||||
<SelectInputRadio
|
||||
@@ -727,6 +733,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -409,14 +409,14 @@ const DashboardLineChart = ({
|
||||
axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
|
||||
domain={(() => {
|
||||
// Calculate dynamic domain based on visible data
|
||||
let seriesData: DashboardChartsSeries[] = [];
|
||||
// let seriesData: DashboardChartsSeries[] = [];
|
||||
let dataset: DashboardChartsDataset[] = [];
|
||||
|
||||
if (
|
||||
analysisMode === 'OVERVIEW' &&
|
||||
isOverviewCharts(data.charts)
|
||||
) {
|
||||
seriesData = data.charts[chartData]?.series || [];
|
||||
// seriesData = data.charts[chartData]?.series || [];
|
||||
dataset = data.charts[chartData]?.dataset || [];
|
||||
} else if (
|
||||
analysisMode === 'COMPARISON' &&
|
||||
@@ -426,7 +426,7 @@ const DashboardLineChart = ({
|
||||
data.charts.farm ||
|
||||
data.charts.flock ||
|
||||
data.charts.kandang;
|
||||
seriesData = comparisonChart?.series || [];
|
||||
// seriesData = comparisonChart?.series || [];
|
||||
dataset = comparisonChart?.dataset || [];
|
||||
}
|
||||
|
||||
|
||||
@@ -279,8 +279,6 @@ const ExpenseRequestContent = ({
|
||||
)}
|
||||
|
||||
<div className='w-full mt-4 flex flex-col gap-4'>
|
||||
{/* TODO: apply RBAC */}
|
||||
|
||||
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
||||
{isCurrentApprovalOnHeadArea && (
|
||||
<RequirePermission permissions='lti.expense.approve.head_area'>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
CellContext,
|
||||
@@ -16,41 +18,40 @@ 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,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
||||
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import ExpensesFilterModal from '@/components/pages/expense/filter/ExpensesFilterModal';
|
||||
import ExpenseTableSkeleton from '@/components/pages/expense/skeleton/ExpenseTableSkeleton';
|
||||
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Expense, unknown>;
|
||||
approveClickHandler: () => void;
|
||||
rejectClickHandler: () => void;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `expense#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
const showEditButton = props.row.original.latest_approval
|
||||
? props.row.original.latest_approval.step_number !== 6 &&
|
||||
(props.row.original.latest_approval.step_number === 1 ||
|
||||
@@ -59,85 +60,102 @@ const RowOptionsMenu = ({
|
||||
props.row.original.latest_approval.step_number === 4)
|
||||
: false;
|
||||
|
||||
// TODO: apply RBAC
|
||||
const showRealizationButton = props.row.original.latest_approval
|
||||
? props.row.original.latest_approval.action !== 'REJECTED' &&
|
||||
props.row.original.latest_approval.step_number === 4
|
||||
: false;
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
|
||||
<RequirePermission permissions='lti.expense.detail'>
|
||||
<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>
|
||||
</RequirePermission>
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
{showEditButton && (
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.expense.detail'>
|
||||
<Button
|
||||
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
||||
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Edit
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{showRealizationButton && (
|
||||
<RequirePermission permissions='lti.expense.create.realization'>
|
||||
{showEditButton && (
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<Button
|
||||
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{showRealizationButton && (
|
||||
<RequirePermission permissions='lti.expense.create.realization'>
|
||||
<Button
|
||||
href={`/expense/realization/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:money-bag-rounded'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-info'
|
||||
/>
|
||||
Realisasi
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<RequirePermission permissions='lti.expense.delete'>
|
||||
<Button
|
||||
href={`/expense/realization/?expenseId=${props.row.original.id}`}
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='info'
|
||||
className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
|
||||
color='error'
|
||||
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:money-bag-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Realisasi
|
||||
<Icon icon='mdi:delete-outline' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<RequirePermission permissions='lti.expense.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</RowOptionsMenuWrapper>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpensesTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -179,6 +197,9 @@ const ExpensesTable = () => {
|
||||
const approveModal = useModal();
|
||||
const rejectModal = useModal();
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
|
||||
undefined
|
||||
);
|
||||
@@ -340,31 +361,7 @@ const ExpensesTable = () => {
|
||||
const currentRowRelativeIndex =
|
||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
|
||||
|
||||
const approveClickHandler = () => {
|
||||
setSelectedExpense(props.row.original);
|
||||
|
||||
// Set row selection
|
||||
setRowSelection({
|
||||
[String(props.row.original.id)]: true,
|
||||
});
|
||||
|
||||
setApprovalNotes('');
|
||||
approveModal.openModal();
|
||||
};
|
||||
|
||||
const rejectClickHandler = () => {
|
||||
setSelectedExpense(props.row.original);
|
||||
|
||||
// Set row selection
|
||||
setRowSelection({
|
||||
[String(props.row.original.id)]: true,
|
||||
});
|
||||
|
||||
setApprovalNotes('');
|
||||
rejectModal.openModal();
|
||||
};
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedExpense(props.row.original);
|
||||
@@ -372,31 +369,11 @@ const ExpensesTable = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 3 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu
|
||||
type='dropdown'
|
||||
props={props}
|
||||
approveClickHandler={approveClickHandler}
|
||||
rejectClickHandler={rejectClickHandler}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 3 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu
|
||||
type='collapse'
|
||||
props={props}
|
||||
approveClickHandler={approveClickHandler}
|
||||
rejectClickHandler={rejectClickHandler}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
<RowOptionsMenu
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -535,51 +512,41 @@ const ExpensesTable = () => {
|
||||
setIsRejectLoading(false);
|
||||
};
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
setSelectedLocation(val as OptionType);
|
||||
updateFilter(
|
||||
'locationId',
|
||||
val ? ((val as OptionType).value as string) : ''
|
||||
);
|
||||
};
|
||||
|
||||
const {
|
||||
setInputValue: setVendorInputValue,
|
||||
options: vendorOptions,
|
||||
isLoadingOptions: isLoadingVendorOptions,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||
|
||||
const [selectedVendor, setSelectedVendor] = useState<OptionType | null>(null);
|
||||
|
||||
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
setSelectedVendor(val as OptionType);
|
||||
updateFilter('vendorId', val ? ((val as OptionType).value as string) : '');
|
||||
};
|
||||
useEffect(() => {
|
||||
setTableState('expense-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const transactionDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
|
||||
e
|
||||
) => {
|
||||
updateFilter('transactionDate', e.target.value);
|
||||
// ===== FILTER MODAL HANDLERS =====
|
||||
const handleFilterModalOpen = () => {
|
||||
filterModal.openModal();
|
||||
};
|
||||
|
||||
const realizationDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
|
||||
e
|
||||
) => {
|
||||
updateFilter('realizationDate', e.target.value);
|
||||
const handleFilterSubmit = (values: {
|
||||
transaction_date?: string | null;
|
||||
realization_date?: string | null;
|
||||
location_id?: string | null;
|
||||
vendor_id?: string | null;
|
||||
}) => {
|
||||
updateFilter('transactionDate', values.transaction_date || '');
|
||||
updateFilter('realizationDate', values.realization_date || '');
|
||||
updateFilter('locationId', values.location_id || '');
|
||||
updateFilter('vendorId', values.vendor_id || '');
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
updateFilter('transactionDate', '');
|
||||
updateFilter('realizationDate', '');
|
||||
updateFilter('locationId', '');
|
||||
updateFilter('vendorId', '');
|
||||
};
|
||||
|
||||
// track sorting
|
||||
@@ -595,188 +562,193 @@ const ExpensesTable = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full p-0 sm:p-4'>
|
||||
<div className='flex flex-col gap-2 mb-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-4'>
|
||||
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
|
||||
<RequirePermission permissions='lti.expense.create'>
|
||||
<div className='w-full'>
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.expense.create'>
|
||||
<Button
|
||||
href='/expense/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Expense
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
{selectedRowIds.length > 0 && (
|
||||
<>
|
||||
<hr className='w-px h-full border-none bg-base-content/10 sm:block hidden' />
|
||||
|
||||
<RequirePermission permissions='lti.expense.approve.head_area'>
|
||||
<Button
|
||||
href='/expense/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
|
||||
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Expense
|
||||
<Icon
|
||||
icon='lucide-lab:farm'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-success'
|
||||
/>
|
||||
Approve Head Area
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
{selectedRowIds.length > 0 && (
|
||||
<>
|
||||
<RequirePermission permissions='lti.expense.approve.head_area'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='lucide-lab:farm' width={24} height={24} />
|
||||
Approve Head Area
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={
|
||||
!isAllSelectedRowLatestApprovalOnUnitVicePresident
|
||||
}
|
||||
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<Icon
|
||||
icon='tdesign:money'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-success'
|
||||
/>
|
||||
Approve Unit Vice President
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={
|
||||
!isAllSelectedRowLatestApprovalOnUnitVicePresident
|
||||
}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='tdesign:money' width={24} height={24} />
|
||||
Approve Unit Vice President
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.expense.approve.finance'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnFinance}
|
||||
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<Icon
|
||||
icon='tdesign:money'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-success'
|
||||
/>
|
||||
Approve Finance
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.expense.approve.finance'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnFinance}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='tdesign:money' width={24} height={24} />
|
||||
Approve Finance
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission
|
||||
permissions={[
|
||||
'lti.expense.approve.head_area',
|
||||
'lti.expense.approve.unit_vice_president',
|
||||
'lti.expense.approve.finance',
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={bulkRejectClickHandler}
|
||||
disabled={
|
||||
!isAllSelectedRowLatestApprovalOnHeadArea &&
|
||||
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
|
||||
!isAllSelectedRowLatestApprovalOnFinance
|
||||
}
|
||||
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:close'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-error'
|
||||
/>
|
||||
Reject
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RequirePermission
|
||||
permissions={[
|
||||
'lti.expense.approve.head_area',
|
||||
'lti.expense.approve.unit_vice_president',
|
||||
'lti.expense.approve.finance',
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='error'
|
||||
onClick={bulkRejectClickHandler}
|
||||
disabled={
|
||||
!isAllSelectedRowLatestApprovalOnHeadArea &&
|
||||
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
|
||||
!isAllSelectedRowLatestApprovalOnFinance
|
||||
}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:close'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Reject
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Search and Filter */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-12 justify-end gap-2'>
|
||||
<DateInput
|
||||
required
|
||||
label='Tanggal Transaksi'
|
||||
name='transaction_date'
|
||||
placeholder='Masukkan tanggal transaksi'
|
||||
value={tableFilterState.transactionDate}
|
||||
onChange={transactionDateChangeHandler}
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-3',
|
||||
}}
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
required
|
||||
label='Tanggal Realisasi'
|
||||
name='realization_date'
|
||||
placeholder='Masukkan tanggal realisasi'
|
||||
value={tableFilterState.realizationDate}
|
||||
onChange={realizationDateChangeHandler}
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-3',
|
||||
}}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
options={locationOptions}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
value={selectedLocation}
|
||||
onChange={locationChangeHandler}
|
||||
onInputChange={setLocationInputValue}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-3',
|
||||
}}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Vendor'
|
||||
options={vendorOptions}
|
||||
isLoading={isLoadingVendorOptions}
|
||||
value={selectedVendor}
|
||||
onChange={vendorChangeHandler}
|
||||
onInputChange={setVendorInputValue}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-3',
|
||||
}}
|
||||
/>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Biaya Operasional'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
|
||||
/>
|
||||
</div>
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={[
|
||||
'page',
|
||||
'pageSize',
|
||||
'search',
|
||||
'nameSort',
|
||||
'userId',
|
||||
]}
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</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}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
enableRowSelection={tableEnableRowSelectionHandler}
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(expenses) || expenses.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<ExpenseTableSkeleton
|
||||
columns={expensesColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</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}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
enableRowSelection={tableEnableRowSelectionHandler}
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
@@ -831,6 +803,12 @@ const ExpensesTable = () => {
|
||||
onClick: confirmationModalRejectClickHandler,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExpensesFilterModal
|
||||
ref={filterModal.ref}
|
||||
onSubmit={handleFilterSubmit}
|
||||
onReset={handleFilterReset}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as yup from 'yup';
|
||||
|
||||
export type ExpensesFilterType = {
|
||||
transaction_date: string | null;
|
||||
realization_date: string | null;
|
||||
location_id: string | null;
|
||||
vendor_id: string | null;
|
||||
};
|
||||
|
||||
export const ExpensesFilterSchema = yup.object({
|
||||
transaction_date: yup.string().nullable(),
|
||||
realization_date: yup
|
||||
.string()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-greater-or-equal-transaction',
|
||||
'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
|
||||
function (value) {
|
||||
const { transaction_date } = this.parent;
|
||||
if (!transaction_date || !value) return true;
|
||||
return new Date(value) >= new Date(transaction_date);
|
||||
}
|
||||
),
|
||||
location_id: yup.string().nullable(),
|
||||
vendor_id: yup.string().nullable(),
|
||||
});
|
||||
|
||||
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Modal from '@/components/Modal';
|
||||
import Button from '@/components/Button';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import SelectInput from '@/components/input/SelectInput';
|
||||
|
||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import {
|
||||
ExpensesFilterSchema,
|
||||
ExpensesFilterValues,
|
||||
} from '@/components/pages/expense/filter/ExpensesFilter';
|
||||
|
||||
interface ExpensesFilterModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
initialValues?: ExpensesFilterValues;
|
||||
onSubmit?: (values: Partial<ExpensesFilterValues>) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
const ExpensesFilterModal = ({
|
||||
ref,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
onReset,
|
||||
}: ExpensesFilterModalProps) => {
|
||||
const closeModalHandler = () => {
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
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 formik = useFormik<ExpensesFilterValues>({
|
||||
initialValues: initialValues || {
|
||||
transaction_date: null,
|
||||
realization_date: null,
|
||||
location_id: null,
|
||||
vendor_id: null,
|
||||
},
|
||||
validationSchema: ExpensesFilterSchema,
|
||||
onSubmit: async (values) => {
|
||||
onSubmit?.(values);
|
||||
closeModalHandler();
|
||||
},
|
||||
onReset: () => {
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
},
|
||||
});
|
||||
|
||||
const locationValue = formik.values.location_id
|
||||
? locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
: null;
|
||||
|
||||
const vendorValue = formik.values.vendor_id
|
||||
? vendorOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.vendor_id
|
||||
) || null
|
||||
: null;
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const locationId =
|
||||
val && !Array.isArray(val) ? (String(val.value) as string) : null;
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
};
|
||||
|
||||
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const vendorId =
|
||||
val && !Array.isArray(val) ? (String(val.value) as string) : null;
|
||||
formik.setFieldValue('vendor_id', vendorId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={ref}
|
||||
className={{
|
||||
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='text-sm font-medium'>Filter Data</h3>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={closeModalHandler}
|
||||
className='p-0 text-base-content/50 hover:text-base-content'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col'>
|
||||
<span className='py-2 text-xs font-semibold'>Tanggal</span>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<DateInput
|
||||
name='transaction_date'
|
||||
placeholder='Tanggal Transaksi'
|
||||
value={formik.values.transaction_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.transaction_date &&
|
||||
!!formik.errors.transaction_date
|
||||
}
|
||||
/>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||
<DateInput
|
||||
name='realization_date'
|
||||
placeholder='Tanggal Realisasi'
|
||||
value={formik.values.realization_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.realization_date &&
|
||||
!!formik.errors.realization_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{formik.touched.realization_date &&
|
||||
formik.errors.realization_date && (
|
||||
<span className='text-xs text-error'>
|
||||
{formik.errors.realization_date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationValue}
|
||||
onChange={locationChangeHandler}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Vendor'
|
||||
placeholder='Pilih Vendor'
|
||||
options={vendorOptions}
|
||||
value={vendorValue}
|
||||
onChange={vendorChangeHandler}
|
||||
onInputChange={setVendorInputValue}
|
||||
isLoading={isLoadingVendorOptions}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpensesFilterModal;
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -75,6 +75,12 @@ const ExpenseKandangsTable = ({
|
||||
.filter((id): id is number => id !== undefined)
|
||||
)
|
||||
);
|
||||
const rowSelectionRef = useRef(rowSelection);
|
||||
const prevRowSelectionRef = useRef<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
rowSelectionRef.current = rowSelection;
|
||||
}, [rowSelection]);
|
||||
|
||||
const kandangsColumns: ColumnDef<Kandang>[] = [
|
||||
{
|
||||
@@ -133,33 +139,43 @@ const ExpenseKandangsTable = ({
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false);
|
||||
}, [kandangs, isResponseSuccess]);
|
||||
}, [kandangs]);
|
||||
|
||||
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)
|
||||
);
|
||||
const currentKeys = Object.keys(rowSelection).sort().join(',');
|
||||
const prevKeys = Object.keys(prevRowSelectionRef.current).sort().join(',');
|
||||
|
||||
return {
|
||||
id: parseInt(item),
|
||||
name: selectedKandang?.name ?? 'Kandang tidak ditemukan!',
|
||||
};
|
||||
}
|
||||
);
|
||||
if (currentKeys !== prevKeys) {
|
||||
prevRowSelectionRef.current = { ...rowSelection };
|
||||
|
||||
onChange(formattedSelectedKandangs);
|
||||
} else {
|
||||
onChange([]);
|
||||
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);
|
||||
} else if (Object.keys(rowSelection).length === 0) {
|
||||
onChange([]);
|
||||
}
|
||||
}
|
||||
}, [rowSelection]);
|
||||
}, [rowSelection, kandangs, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedKandangs.length === 0 &&
|
||||
Object.keys(rowSelection).length !== 0
|
||||
Object.keys(rowSelectionRef.current).length !== 0
|
||||
) {
|
||||
setRowSelection({});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -90,6 +90,7 @@ const ExpenseRealizationForm = ({
|
||||
|
||||
const formik = useFormik<ExpenseRealizationFormValues>({
|
||||
initialValues: getExpenseRealizationFormInitialValues(initialValues),
|
||||
enableReinitialize: true,
|
||||
validationSchema:
|
||||
type === 'edit'
|
||||
? UpdateExpenseRealizationFormSchema
|
||||
@@ -143,7 +144,6 @@ const ExpenseRealizationForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { setValues: formikSetValues } = formik;
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||
|
||||
const {
|
||||
@@ -207,7 +207,7 @@ const ExpenseRealizationForm = ({
|
||||
|
||||
// add new realizations for each kandang
|
||||
kandangs.forEach((kandangItem) => {
|
||||
if (!kandangItem.id) return;
|
||||
if (isNaN(Number(kandangItem.id))) return;
|
||||
|
||||
const existingRealization = formik.values.realizations?.find(
|
||||
(realizationItem) => realizationItem.kandang_id === kandangItem.id
|
||||
@@ -254,10 +254,6 @@ const ExpenseRealizationForm = ({
|
||||
formik.setFieldValue('documents', newRequestDocuments);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
formikSetValues(getExpenseRealizationFormInitialValues(initialValues));
|
||||
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
|
||||
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
|
||||
@@ -35,6 +35,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
|
||||
setInputValue: setNonstockInputValue,
|
||||
options: nonstockOptions,
|
||||
isLoadingOptions: isLoadingNonstockOptions,
|
||||
loadMore: loadMoreNonstocks,
|
||||
} = useSelect<Nonstock>(
|
||||
NonstockApi.basePath,
|
||||
'id',
|
||||
@@ -164,6 +165,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
|
||||
options={nonstockOptions}
|
||||
isLoading={isLoadingNonstockOptions}
|
||||
onInputChange={setNonstockInputValue}
|
||||
onMenuScrollToBottom={loadMoreNonstocks}
|
||||
className={{ wrapper: 'min-w-48' }}
|
||||
isDisabled
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useFormik } from 'formik';
|
||||
import { toast } from 'react-hot-toast';
|
||||
@@ -102,6 +102,7 @@ const ExpenseRequestForm = ({
|
||||
|
||||
const formik = useFormik<ExpenseRequestFormValues>({
|
||||
initialValues: getExpenseFormInitialValues(initialValues),
|
||||
enableReinitialize: true,
|
||||
validationSchema:
|
||||
type === 'edit'
|
||||
? UpdateExpenseRequestFormSchema
|
||||
@@ -171,23 +172,25 @@ const ExpenseRequestForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { setValues: formikSetValues } = formik;
|
||||
const { setFieldValue, setFieldTouched } = formik;
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
setInputValue: setVendorInputValue,
|
||||
options: supplierOptions,
|
||||
isLoadingOptions: isLoadingVendorOptions,
|
||||
loadMore: loadMoreSuppliers,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||
|
||||
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('category', true);
|
||||
formik.setFieldValue('category', val);
|
||||
setFieldTouched('category', true);
|
||||
setFieldValue('category', val);
|
||||
};
|
||||
|
||||
const locationChangeHandler = useCallback(
|
||||
@@ -195,12 +198,12 @@ const ExpenseRequestForm = ({
|
||||
const location = val as OptionType | null;
|
||||
const locationId = location ? Number(location.value) : 0;
|
||||
|
||||
formik.setFieldTouched('location', true);
|
||||
formik.setFieldValue('location', location);
|
||||
formik.setFieldTouched('location_id', true);
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
setFieldTouched('location', true);
|
||||
setFieldValue('location', location);
|
||||
setFieldTouched('location_id', true);
|
||||
setFieldValue('location_id', locationId);
|
||||
},
|
||||
[]
|
||||
[setFieldTouched, setFieldValue]
|
||||
);
|
||||
|
||||
const kandangsChangeHandler = (
|
||||
@@ -343,10 +346,6 @@ const ExpenseRequestForm = ({
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
formikSetValues(getExpenseFormInitialValues(initialValues));
|
||||
}, [formikSetValues, getExpenseFormInitialValues, initialValues]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full'>
|
||||
@@ -411,6 +410,7 @@ const ExpenseRequestForm = ({
|
||||
options={locationOptions}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isError={
|
||||
formik.touched.location_id && Boolean(formik.errors.location_id)
|
||||
}
|
||||
@@ -455,6 +455,7 @@ const ExpenseRequestForm = ({
|
||||
options={supplierOptions}
|
||||
onInputChange={setVendorInputValue}
|
||||
isLoading={isLoadingVendorOptions}
|
||||
onMenuScrollToBottom={loadMoreSuppliers}
|
||||
isError={
|
||||
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
|
||||
}
|
||||
|
||||
@@ -287,8 +287,8 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
PT LUMBUNG TELUR INDONESIA
|
||||
</Text>
|
||||
<Text style={ExpensePDFStyle.companyAddress}>
|
||||
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
|
||||
Cipedes, Kec. Sukajadi, Kota Bandung 40162
|
||||
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
|
||||
Bandung Barat, Jawa Barat 40514
|
||||
</Text>
|
||||
|
||||
<View style={ExpensePDFStyle.doubleDivider} />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const ExpenseTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no expense data displayed. Enter expense data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Expense>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpenseTableSkeleton;
|
||||
@@ -2,7 +2,6 @@ import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import { FormHeader } from '@/components/helper/form/FormHeader';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import Table from '@/components/Table';
|
||||
@@ -26,11 +25,13 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
const informasiUmum = [
|
||||
{
|
||||
label: 'ID',
|
||||
value: finance.payment_code,
|
||||
value: finance.payment_code || '-',
|
||||
},
|
||||
{
|
||||
label: 'Jenis Transaksi',
|
||||
value: formatTitleCase(finance.transaction_type.split('_').join(' ')),
|
||||
value: formatTitleCase(
|
||||
(finance.transaction_type || '').split('_').join(' ')
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Pihak',
|
||||
@@ -38,11 +39,13 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
},
|
||||
{
|
||||
label: 'Tanggal',
|
||||
value: formatDate(finance.payment_date, 'DD MMM yyyy'),
|
||||
value: finance.payment_date
|
||||
? formatDate(finance.payment_date, 'DD MMM yyyy')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
label: 'Metode Pembayaran',
|
||||
value: finance.payment_method,
|
||||
value: finance.payment_method || '-',
|
||||
},
|
||||
{
|
||||
label: 'Catatan',
|
||||
@@ -61,22 +64,22 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
label: `Rekening ${formatTitleCase(finance.party?.type)}`,
|
||||
value: finance.party?.account_number,
|
||||
label: `Rekening ${formatTitleCase(finance.party?.type || '')}`,
|
||||
value: finance.party?.account_number || '-',
|
||||
},
|
||||
{
|
||||
label: 'Nominal',
|
||||
value: formatCurrency(
|
||||
finance.transaction_type === 'INJECTION'
|
||||
? finance.nominal
|
||||
: Math.abs(finance.nominal)
|
||||
? finance.nominal || 0
|
||||
: Math.abs(finance.nominal || 0)
|
||||
),
|
||||
},
|
||||
].filter((item) => {
|
||||
// Hide party account number row if transaction type is INJECTION
|
||||
if (
|
||||
FINANCE_INJECTION_STATUS.includes(finance.transaction_type) &&
|
||||
item.label === `Rekening ${formatTitleCase(finance.party?.type)}`
|
||||
FINANCE_INJECTION_STATUS.includes(finance.transaction_type || '') &&
|
||||
item.label === `Rekening ${formatTitleCase(finance.party?.type || '')}`
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -150,7 +153,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
</Card>
|
||||
|
||||
<div className='flex flex-row gap-2 justify-end'>
|
||||
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) &&
|
||||
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type || '') &&
|
||||
finance.party?.type !== 'SUPPLIER' && (
|
||||
<RequirePermission permissions='lti.finance.payments.update'>
|
||||
<Button
|
||||
@@ -163,7 +166,9 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && (
|
||||
{FINANCE_INITIAL_BALANCE_STATUS.includes(
|
||||
finance.transaction_type || ''
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.initial_balances.update'>
|
||||
<Button
|
||||
color='warning'
|
||||
@@ -175,7 +180,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{FINANCE_INJECTION_STATUS.includes(finance.transaction_type) && (
|
||||
{FINANCE_INJECTION_STATUS.includes(finance.transaction_type || '') && (
|
||||
<RequirePermission permissions='lti.finance.injections.update'>
|
||||
<Button
|
||||
color='warning'
|
||||
@@ -201,7 +206,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Finance ini (${finance?.payment_code})?`}
|
||||
text={`Apakah anda yakin ingin menghapus data Finance ini (${finance?.payment_code || ''})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput, {
|
||||
@@ -12,7 +21,6 @@ import SelectInput, {
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import Table from '@/components/Table';
|
||||
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { Finance } from '@/types/api/finance/finance';
|
||||
import {
|
||||
@@ -25,115 +33,145 @@ import { FinanceApi } from '@/services/api/finance';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import toast from 'react-hot-toast';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Icon } from '@iconify/react';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
FinanceTableFilterSchema,
|
||||
FinanceTableFilterValues,
|
||||
} from './FinanceTableFilter.schema';
|
||||
} from '@/components/pages/finance/filter/FinanceFilter';
|
||||
import FinanceTableSkeleton from '@/components/pages/finance/skeleton/FinanceTableSkeleton';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Finance, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `finance#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-finance#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
const popover = document.getElementById(popoverId) as
|
||||
| HTMLDivElement
|
||||
| undefined;
|
||||
popover?.hidePopover?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission
|
||||
permissions={[
|
||||
'lti.finance.transactions.detail',
|
||||
'lti.finance.initial_balances.detail',
|
||||
'lti.finance.injections.detail',
|
||||
'lti.finance.payments.detail',
|
||||
]}
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Button
|
||||
href={`/finance/detail?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
{FINANCE_TRANSACTION_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.payments.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission
|
||||
permissions={[
|
||||
'lti.finance.transactions.detail',
|
||||
'lti.finance.initial_balances.detail',
|
||||
'lti.finance.injections.detail',
|
||||
'lti.finance.payments.detail',
|
||||
]}
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<Button
|
||||
href={`/finance/detail?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
{FINANCE_INITIAL_BALANCE_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.initial_balances.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit/initial-balance?financeId=${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>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{FINANCE_TRANSACTION_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.payments.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{FINANCE_INJECTION_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.injections.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit/injection?financeId=${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>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{FINANCE_INITIAL_BALANCE_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.initial_balances.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit/initial-balance?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<RequirePermission permissions='lti.finance.transactions.delete'>
|
||||
<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>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
{FINANCE_INJECTION_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.injections.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit/injection?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<RequirePermission permissions='lti.finance.transactions.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon icon='mdi:delete-outline' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -171,6 +209,9 @@ const FinanceTable = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== State =====
|
||||
const deleteModal = useModal();
|
||||
const [selectedTransactionType, setSelectedTransactionType] = useState<
|
||||
@@ -189,6 +230,7 @@ const FinanceTable = () => {
|
||||
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||
const [hasDateError, setHasDateError] = useState(false);
|
||||
|
||||
// ===== Formik for Filter =====
|
||||
const filterFormik = useFormik<FinanceTableFilterValues>({
|
||||
@@ -214,6 +256,18 @@ const FinanceTable = () => {
|
||||
updateFilter('sortBy', values.sort_by);
|
||||
updateFilter('startDate', values.start_date);
|
||||
updateFilter('endDate', values.end_date);
|
||||
filterModal.closeModal();
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('search', '');
|
||||
resetSearchValue();
|
||||
updateFilter('transactionTypes', '');
|
||||
updateFilter('bankIds', '');
|
||||
updateFilter('customerIds', '');
|
||||
updateFilter('supplierIds', '');
|
||||
updateFilter('sortBy', '');
|
||||
updateFilter('startDate', '');
|
||||
updateFilter('endDate', '');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -266,10 +320,41 @@ const FinanceTable = () => {
|
||||
});
|
||||
}, [bankOptions, bankRawData]);
|
||||
|
||||
// ===== ACTIVE FILTERS COUNT =====
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
let count = 0;
|
||||
|
||||
if (tableFilterState.transactionTypes) count += 1;
|
||||
if (tableFilterState.bankIds) count += 1;
|
||||
if (tableFilterState.customerIds) count += 1;
|
||||
if (tableFilterState.supplierIds) count += 1;
|
||||
if (tableFilterState.sortBy) count += 1;
|
||||
if (tableFilterState.startDate) count += 1;
|
||||
if (tableFilterState.endDate) count += 1;
|
||||
|
||||
return count;
|
||||
}, [
|
||||
tableFilterState.transactionTypes,
|
||||
tableFilterState.bankIds,
|
||||
tableFilterState.customerIds,
|
||||
tableFilterState.supplierIds,
|
||||
tableFilterState.sortBy,
|
||||
tableFilterState.startDate,
|
||||
tableFilterState.endDate,
|
||||
]);
|
||||
|
||||
const hasFilters = activeFiltersCount > 0;
|
||||
|
||||
// ===== Handler =====
|
||||
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
filterFormik.setFieldValue('search', e.target.value);
|
||||
};
|
||||
const searchChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value);
|
||||
setSearchValue(e.target.value);
|
||||
setPage(1);
|
||||
},
|
||||
[updateFilter, setSearchValue, setPage]
|
||||
);
|
||||
|
||||
const transactionTypeChangeHandler = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
@@ -335,10 +420,7 @@ const FinanceTable = () => {
|
||||
const endDateObj = new Date(endDate);
|
||||
|
||||
if (endDateObj < startDate) {
|
||||
filterFormik.setFieldError(
|
||||
'end_date',
|
||||
'Tanggal akhir tidak boleh masa lampau'
|
||||
);
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
@@ -346,12 +428,14 @@ const FinanceTable = () => {
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
} else {
|
||||
filterFormik.setFieldError('end_date', undefined);
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -366,10 +450,7 @@ const FinanceTable = () => {
|
||||
const endDate = new Date(value);
|
||||
|
||||
if (endDate < startDateObj) {
|
||||
filterFormik.setFieldError(
|
||||
'end_date',
|
||||
'Tanggal akhir tidak boleh masa lampau'
|
||||
);
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
@@ -380,13 +461,18 @@ const FinanceTable = () => {
|
||||
}
|
||||
}
|
||||
|
||||
filterFormik.setFieldError('end_date', undefined);
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterModalOpen = () => {
|
||||
filterModal.openModal();
|
||||
filterFormik.validateForm();
|
||||
};
|
||||
|
||||
const resetFilterHandler = () => {
|
||||
setSelectedTransactionType(null);
|
||||
setSelectedBank(null);
|
||||
@@ -406,6 +492,7 @@ const FinanceTable = () => {
|
||||
updateFilter('startDate', '');
|
||||
updateFilter('endDate', '');
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
@@ -417,8 +504,8 @@ const FinanceTable = () => {
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
const columns: ColumnDef<Finance>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'payment_code',
|
||||
@@ -498,32 +585,17 @@ const FinanceTable = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu
|
||||
type='dropdown'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu
|
||||
type='collapse'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
],
|
||||
[deleteModal]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -555,151 +627,280 @@ const FinanceTable = () => {
|
||||
}, [resetSearchValue, dateErrorShown]);
|
||||
|
||||
return (
|
||||
<section className='size-full flex flex-col gap-6'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<RequirePermission permissions='lti.finance.injections.create'>
|
||||
<Button
|
||||
color='warning'
|
||||
className='min-w-24'
|
||||
href='/finance/add/injection'
|
||||
>
|
||||
Injection Saldo Bank
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.finance.initial_balances.create'>
|
||||
<Button
|
||||
color='info'
|
||||
className='text-white min-w-24'
|
||||
href='/finance/add/initial-balance'
|
||||
>
|
||||
Saldo Awal
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.finance.payments.create'>
|
||||
<Button color='primary' className='min-w-24' href='/finance/add'>
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
footer={
|
||||
<div className='flex justify-end gap-2'>
|
||||
<>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.finance.injections.create'>
|
||||
<Button
|
||||
href='/finance/add/injection'
|
||||
color='warning'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='mdi:bank-transfer-in' width={20} height={20} />
|
||||
Add Injection (Saldo Bank)
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.finance.initial_balances.create'>
|
||||
<Button
|
||||
href='/finance/add/initial-balance'
|
||||
color='info'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='mdi:cash-register' width={20} height={20} />
|
||||
Add Initial Balance
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.finance.payments.create'>
|
||||
<Button
|
||||
href='/finance/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Finance
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
color='warning'
|
||||
className='min-w-24'
|
||||
onClick={resetFilterHandler}
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={handleFilterModalOpen}
|
||||
className={cn(
|
||||
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||
{
|
||||
'border-primary-gradient text-primary': hasFilters,
|
||||
}
|
||||
)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
className='min-w-24'
|
||||
onClick={() => filterFormik.handleSubmit()}
|
||||
>
|
||||
Cari
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
Filter
|
||||
{hasFilters && (
|
||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-4 gap-6'>
|
||||
<SelectInput
|
||||
options={FINANCE_TRANSACTION_TYPE_OPTIONS}
|
||||
label='Jenis Transaksi'
|
||||
value={selectedTransactionType}
|
||||
onChange={transactionTypeChangeHandler}
|
||||
closeMenuOnSelect={false}
|
||||
isClearable
|
||||
isMulti
|
||||
/>
|
||||
<SelectInput
|
||||
options={customerOptions}
|
||||
label={'Customer'}
|
||||
value={selectedCustomerId}
|
||||
onChange={customerIdChangeHandler}
|
||||
onInputChange={customerInputValue}
|
||||
onMenuScrollToBottom={customerLoadMore}
|
||||
isLoading={customerIsLoadingOptions}
|
||||
closeMenuOnSelect={false}
|
||||
isClearable
|
||||
isMulti
|
||||
/>
|
||||
<SelectInput
|
||||
options={supplierOptions}
|
||||
label={'Supplier'}
|
||||
value={selectedSupplierId}
|
||||
onChange={supplierIdChangeHandler}
|
||||
onInputChange={supplierInputValue}
|
||||
onMenuScrollToBottom={supplierLoadMore}
|
||||
isLoading={supplierIsLoadingOptions}
|
||||
closeMenuOnSelect={false}
|
||||
isClearable
|
||||
isMulti
|
||||
/>
|
||||
<SelectInput
|
||||
options={bankSelectOptions}
|
||||
label='Bank'
|
||||
value={selectedBank}
|
||||
onChange={bankChangeHandler}
|
||||
onInputChange={bankInputValue}
|
||||
onMenuScrollToBottom={bankLoadMore}
|
||||
closeMenuOnSelect={false}
|
||||
isClearable
|
||||
isMulti
|
||||
/>
|
||||
<SelectInput
|
||||
options={sortByOptions}
|
||||
label='Urutkan Berdasarkan'
|
||||
value={selectedSortBy}
|
||||
onChange={sortByChangeHandler}
|
||||
isClearable
|
||||
/>
|
||||
<DateInput
|
||||
name='start_date'
|
||||
label='Periode Tanggal (Mulai)'
|
||||
value={filterFormik.values.start_date}
|
||||
onChange={startDateChangeHandler}
|
||||
errorMessage={
|
||||
filterFormik.errors.end_date
|
||||
? filterFormik.errors.end_date
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<DateInput
|
||||
name='end_date'
|
||||
label='Periode Tanggal (Akhir)'
|
||||
value={filterFormik.values.end_date}
|
||||
onChange={endDateChangeHandler}
|
||||
errorMessage={
|
||||
filterFormik.errors.end_date
|
||||
? filterFormik.errors.end_date
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
label='Cari'
|
||||
placeholder='Cari'
|
||||
value={filterFormik.values.search}
|
||||
onChange={searchChangeHandler}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Table<Finance>
|
||||
data={isResponseSuccess(finances) ? finances.data : []}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={tableFilterState.page}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
totalItems={
|
||||
isResponseSuccess(finances) ? finances.meta?.total_results : 0
|
||||
}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(finances) || finances.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<FinanceTableSkeleton
|
||||
columns={columns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Finance>
|
||||
data={isResponseSuccess(finances) ? finances.data : []}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={tableFilterState.page}
|
||||
totalItems={
|
||||
isResponseSuccess(finances) ? finances.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<Modal
|
||||
ref={filterModal.ref}
|
||||
className={{
|
||||
modal: 'p-0',
|
||||
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={filterModal.closeModal}
|
||||
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={filterFormik.handleSubmit}
|
||||
onReset={filterFormik.handleReset}
|
||||
>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col'>
|
||||
<span className='py-2 text-xs font-semibold'>Tanggal</span>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<DateInput
|
||||
name='start_date'
|
||||
placeholder='Periode Tanggal Awal'
|
||||
value={filterFormik.values.start_date}
|
||||
errorMessage={filterFormik.errors.start_date}
|
||||
onChange={startDateChangeHandler}
|
||||
isError={
|
||||
filterFormik.touched.start_date &&
|
||||
Boolean(filterFormik.errors.start_date)
|
||||
}
|
||||
/>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||
<DateInput
|
||||
name='end_date'
|
||||
placeholder='Periode Tanggal Akhir'
|
||||
value={filterFormik.values.end_date}
|
||||
errorMessage={filterFormik.errors.end_date}
|
||||
onChange={endDateChangeHandler}
|
||||
isError={
|
||||
(filterFormik.touched.end_date &&
|
||||
Boolean(filterFormik.errors.end_date)) ||
|
||||
hasDateError
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectInput
|
||||
options={FINANCE_TRANSACTION_TYPE_OPTIONS}
|
||||
label='Jenis Transaksi'
|
||||
placeholder='Pilih Jenis Transaksi'
|
||||
value={selectedTransactionType}
|
||||
onChange={transactionTypeChangeHandler}
|
||||
onInputChange={() => {}}
|
||||
closeMenuOnSelect={false}
|
||||
isClearable
|
||||
isMulti
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<SelectInput
|
||||
options={customerOptions}
|
||||
label='Customer'
|
||||
placeholder='Pilih Customer'
|
||||
value={selectedCustomerId}
|
||||
onChange={customerIdChangeHandler}
|
||||
onInputChange={customerInputValue}
|
||||
onMenuScrollToBottom={customerLoadMore}
|
||||
isLoading={customerIsLoadingOptions}
|
||||
closeMenuOnSelect={false}
|
||||
isClearable
|
||||
isMulti
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<SelectInput
|
||||
options={supplierOptions}
|
||||
label='Supplier'
|
||||
placeholder='Pilih Supplier'
|
||||
value={selectedSupplierId}
|
||||
onChange={supplierIdChangeHandler}
|
||||
onInputChange={supplierInputValue}
|
||||
onMenuScrollToBottom={supplierLoadMore}
|
||||
isLoading={supplierIsLoadingOptions}
|
||||
closeMenuOnSelect={false}
|
||||
isClearable
|
||||
isMulti
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<SelectInput
|
||||
options={bankSelectOptions}
|
||||
label='Bank'
|
||||
placeholder='Pilih Bank'
|
||||
value={selectedBank}
|
||||
onChange={bankChangeHandler}
|
||||
onInputChange={bankInputValue}
|
||||
onMenuScrollToBottom={bankLoadMore}
|
||||
closeMenuOnSelect={false}
|
||||
isClearable
|
||||
isMulti
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<SelectInput
|
||||
options={sortByOptions}
|
||||
label='Urutkan Berdasarkan'
|
||||
placeholder='Pilih Urutan'
|
||||
value={selectedSortBy}
|
||||
onChange={sortByChangeHandler}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
filterFormik.resetForm();
|
||||
setSelectedTransactionType(null);
|
||||
setSelectedBank(null);
|
||||
setSelectedCustomerId(null);
|
||||
setSelectedSupplierId(null);
|
||||
setSelectedSortBy(null);
|
||||
resetFilterHandler();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!filterFormik.isValid || filterFormik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
@@ -714,7 +915,7 @@ const FinanceTable = () => {
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Finance } from '@/types/api/finance/finance';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const FinanceTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no finance data displayed. Enter finance data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Finance>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceTableSkeleton;
|
||||
@@ -1,22 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import Badge from '@/components/Badge';
|
||||
import Button from '@/components/Button';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import Table from '@/components/Table';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { InventoryAdjustmentApi } from '@/services/api/inventory';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { useFormik } from 'formik';
|
||||
import Button from '@/components/Button';
|
||||
import Table from '@/components/Table';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
|
||||
import { InventoryAdjustmentApi } from '@/services/api/inventory';
|
||||
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import toast from 'react-hot-toast';
|
||||
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant';
|
||||
import { Product } from '@/types/api/master-data/product';
|
||||
import StatusBadge from '@/components/helper/StatusBadge';
|
||||
import InventoryAdjustmentTableSkeleton from '@/components/pages/inventory/adjustment/skeleton/InventoryAdjustmentTableSkeleton';
|
||||
|
||||
import {
|
||||
AdjustmentFilterSchema,
|
||||
AdjustmentFilterType,
|
||||
} from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter';
|
||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<InventoryAdjustment, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `adjustment#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-adjustment#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.inventory.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon icon='mdi:delete-outline' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InventoryAdjustmentTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -30,6 +116,9 @@ const InventoryAdjustmentTable = () => {
|
||||
productSort: '',
|
||||
warehouseSort: '',
|
||||
stockSort: '',
|
||||
productFilter: '',
|
||||
warehouseFilter: '',
|
||||
transactionTypeFilter: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -38,84 +127,303 @@ const InventoryAdjustmentTable = () => {
|
||||
productSort: 'sort_product',
|
||||
warehouseSort: 'sort_warehouse',
|
||||
stockSort: 'sort_stock',
|
||||
productFilter: 'product_id',
|
||||
warehouseFilter: 'warehouse_id',
|
||||
transactionTypeFilter: 'transaction_type',
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch Data
|
||||
const { data: inventoryAdjustments, isLoading } = useSWR(
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<AdjustmentFilterType>({
|
||||
initialValues: {
|
||||
product_id: null,
|
||||
warehouse: null,
|
||||
transaction_type: null,
|
||||
},
|
||||
validationSchema: AdjustmentFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('productFilter', values.product_id || '');
|
||||
updateFilter('warehouseFilter', String(values.warehouse?.value) || '');
|
||||
updateFilter('transactionTypeFilter', values.transaction_type || '');
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('productFilter', '');
|
||||
updateFilter('warehouseFilter', '');
|
||||
updateFilter('transactionTypeFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
// ===== PRODUCT OPTIONS =====
|
||||
const {
|
||||
setInputValue: setProductInputValue,
|
||||
options: productOptions,
|
||||
isLoadingOptions: isLoadingProductOptions,
|
||||
loadMore: loadMoreProducts,
|
||||
} = useSelect<Product>(
|
||||
filterModal.open ? ProductApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search'
|
||||
);
|
||||
|
||||
// ===== WAREHOUSE OPTIONS =====
|
||||
const {
|
||||
setInputValue: setWarehouseInputValue,
|
||||
options: warehouseOptions,
|
||||
isLoadingOptions: isLoadingWarehouseOptions,
|
||||
loadMore: loadMoreWarehouses,
|
||||
} = useSelect<Warehouse>(
|
||||
filterModal.open ? WarehouseApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search'
|
||||
);
|
||||
|
||||
// ===== TRANSACTION TYPE OPTIONS =====
|
||||
const transactionTypeOptions = useMemo(() => {
|
||||
return [
|
||||
{ value: 'increase', label: 'Increase' },
|
||||
{ value: 'decrease', label: 'Decrease' },
|
||||
];
|
||||
}, []);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterProductChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const product = val as OptionType | null;
|
||||
const productId = product?.value ? String(product.value) : null;
|
||||
formik.setFieldValue('product_id', productId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterWarehouseChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('warehouse', val);
|
||||
};
|
||||
|
||||
const handleFilterTransactionTypeChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const type = val as OptionType | null;
|
||||
const typeValue = type?.value ? String(type.value) : null;
|
||||
formik.setFieldValue('transaction_type', typeValue);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const productIdValue = useMemo(() => {
|
||||
if (!formik.values.product_id) return null;
|
||||
return (
|
||||
productOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.product_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.product_id, productOptions]);
|
||||
|
||||
const transactionTypeValue = useMemo(() => {
|
||||
if (!formik.values.transaction_type) return null;
|
||||
return (
|
||||
transactionTypeOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.transaction_type
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.transaction_type, transactionTypeOptions]);
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const {
|
||||
data: inventoryAdjustments,
|
||||
isLoading,
|
||||
mutate: refreshAdjustments,
|
||||
} = useSWR(
|
||||
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
|
||||
InventoryAdjustmentApi.getAllFetcher
|
||||
);
|
||||
|
||||
// State
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const singleDeleteHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
// Columns
|
||||
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
id: 'product_name',
|
||||
header: 'Nama Produk',
|
||||
accessorFn: (row) => row.product_warehouse?.product?.name ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'warehouse_name',
|
||||
header: 'Gudang',
|
||||
accessorFn: (row) => row.product_warehouse?.warehouse?.name ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
header: 'Tanggal',
|
||||
accessorFn: (row) =>
|
||||
new Date(row.created_at).toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'quantity',
|
||||
header: 'Kuantitas',
|
||||
accessorFn: (row) => formatNumber(String(row.increase + row.decrease)),
|
||||
},
|
||||
{
|
||||
id: 'transaction_type',
|
||||
header: 'Tipe Transaksi',
|
||||
accessorFn: (row) => {
|
||||
if (row.increase > 0) return 'Peningkatan';
|
||||
if (row.decrease > 0) return 'Penurunan';
|
||||
return '-';
|
||||
},
|
||||
cell: (props) => {
|
||||
const type = props.row.original.increase;
|
||||
const label = type > 0 ? 'Peningkatan' : type <= 0 ? 'Penurunan' : '-';
|
||||
const response = await InventoryAdjustmentApi.delete(
|
||||
selectedAdjustment?.id as number
|
||||
);
|
||||
|
||||
return (
|
||||
<Badge variant='soft' color={type > 0 ? 'success' : 'error'}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'created_by',
|
||||
header: 'Oleh',
|
||||
accessorFn: (row) => row.created_user?.name ?? '-',
|
||||
},
|
||||
];
|
||||
singleDeleteModal.closeModal();
|
||||
setIsDeleteLoading(false);
|
||||
|
||||
// Handler
|
||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
setPageSize(newVal.value as number);
|
||||
if (isResponseSuccess(response)) {
|
||||
toast.success(response?.message || 'Successfully delete Adjustment!');
|
||||
refreshAdjustments();
|
||||
} else {
|
||||
toast.error(response?.message || 'Failed to delete Adjustment');
|
||||
}
|
||||
};
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [selectedAdjustment, setSelectedAdjustment] = useState<
|
||||
InventoryAdjustment | undefined
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const singleDeleteModal = useModal();
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('inventory-adjustment-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'adj_number',
|
||||
header: 'No. Referensi',
|
||||
accessorFn: (row) => row.adj_number ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
header: 'Lokasi',
|
||||
accessorFn: (row) => row.location?.name ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'project_flock',
|
||||
header: 'Flock',
|
||||
accessorFn: (row) => row.project_flock?.flock_name ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'warehouse_name',
|
||||
header: 'Gudang',
|
||||
accessorFn: (row) => row.product_warehouse?.warehouse?.name ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'product_name',
|
||||
header: 'Nama Produk',
|
||||
accessorFn: (row) => row.product_warehouse?.product?.name ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'quantity',
|
||||
header: 'Kuantitas',
|
||||
accessorFn: (row) => row.qty ?? '-',
|
||||
cell: (row) => {
|
||||
const value = row.row.original.increase + row.row.original.decrease;
|
||||
return <div className='text-center'>{formatNumber(value)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
header: 'Harga',
|
||||
accessorFn: (row) => (row.price ? formatCurrency(row.price) : '-'),
|
||||
},
|
||||
{
|
||||
id: 'grand_total',
|
||||
header: 'Grand Total',
|
||||
accessorFn: (row) =>
|
||||
row.grand_total ? formatCurrency(row.grand_total) : '-',
|
||||
},
|
||||
{
|
||||
id: 'transaction_type',
|
||||
header: 'Tipe Transaksi',
|
||||
accessorFn: (row) => row.transaction_subtype ?? '-',
|
||||
cell: (row) => {
|
||||
const subtype = row.row.original.transaction_subtype;
|
||||
const increase = row.row.original.increase;
|
||||
|
||||
const getSubtypeLabel = (subtypeValue: string): string => {
|
||||
if (subtypeValue === TRANSACTION_SUBTYPE_OPTIONS.PEMBELIAN.value) {
|
||||
return TRANSACTION_SUBTYPE_OPTIONS.PEMBELIAN.label;
|
||||
}
|
||||
if (subtypeValue === TRANSACTION_SUBTYPE_OPTIONS.PENJUALAN.value) {
|
||||
return TRANSACTION_SUBTYPE_OPTIONS.PENJUALAN.label;
|
||||
}
|
||||
const recordingOption = TRANSACTION_SUBTYPE_OPTIONS.RECORDING.find(
|
||||
(opt) => opt.value === subtypeValue
|
||||
);
|
||||
if (recordingOption) {
|
||||
return recordingOption.label;
|
||||
}
|
||||
if (subtypeValue === 'RECORDING_DEPLETION_OUT') {
|
||||
return 'Recording Depletion';
|
||||
}
|
||||
return subtypeValue || '-';
|
||||
};
|
||||
|
||||
const label = getSubtypeLabel(subtype);
|
||||
|
||||
return (
|
||||
<StatusBadge
|
||||
color={
|
||||
increase > 0 ? 'success' : increase <= 0 ? 'error' : 'neutral'
|
||||
}
|
||||
text={label}
|
||||
className={{
|
||||
badge: 'whitespace-nowrap',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
header: 'Tanggal',
|
||||
accessorFn: (row) =>
|
||||
row.created_at ? formatDate(row.created_at, 'DD MMM YYYY') : '-',
|
||||
},
|
||||
{
|
||||
id: 'created_by',
|
||||
header: 'Oleh',
|
||||
accessorFn: (row) => row.created_user?.name ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<InventoryAdjustment, unknown>) => {
|
||||
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 = () => {
|
||||
setSelectedAdjustment(props.row.original);
|
||||
singleDeleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
tableFilterState.pageSize,
|
||||
tableFilterState.page,
|
||||
singleDeleteModal,
|
||||
setSelectedAdjustment,
|
||||
]
|
||||
);
|
||||
|
||||
const updateSortingFilter = useCallback(
|
||||
(
|
||||
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
|
||||
@@ -130,7 +438,6 @@ const InventoryAdjustmentTable = () => {
|
||||
[updateFilter]
|
||||
);
|
||||
|
||||
// Effect
|
||||
useEffect(() => {
|
||||
const productCategorySortFilter = sorting.find(
|
||||
(sortItem) => sortItem.id === 'productCategory'
|
||||
@@ -149,88 +456,215 @@ const InventoryAdjustmentTable = () => {
|
||||
updateSortingFilter('stockSort', stockSortFilter);
|
||||
}, [sorting, updateSortingFilter]);
|
||||
|
||||
// Utils Function
|
||||
const formatNumber = (value: string) => {
|
||||
const numericValue = value.replace(/[^0-9.]/g, '');
|
||||
const [integer, decimal] = numericValue.split('.');
|
||||
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return decimal ? `${formattedInteger}.${decimal}` : formattedInteger;
|
||||
};
|
||||
|
||||
// Render
|
||||
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='w-full flex flex-row'>
|
||||
<RequirePermission permissions='lti.inventory.create'>
|
||||
<Button
|
||||
href='/inventory/adjustment/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</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: 'min-w-28' }}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.inventory.create'>
|
||||
<Button
|
||||
href='/inventory/adjustment/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Adjustment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<Table<InventoryAdjustment>
|
||||
data={
|
||||
isResponseSuccess(inventoryAdjustments)
|
||||
? inventoryAdjustments?.data
|
||||
: []
|
||||
}
|
||||
columns={inventoryAdjustmentsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={
|
||||
isResponseSuccess(inventoryAdjustments)
|
||||
? inventoryAdjustments?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(inventoryAdjustments)
|
||||
? inventoryAdjustments?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(inventoryAdjustments) &&
|
||||
inventoryAdjustments?.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',
|
||||
}}
|
||||
/>
|
||||
{/* Search and Filter */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={[
|
||||
'page',
|
||||
'pageSize',
|
||||
'search',
|
||||
'productCategorySort',
|
||||
'productSort',
|
||||
'warehouseSort',
|
||||
'stockSort',
|
||||
]}
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(inventoryAdjustments) ||
|
||||
inventoryAdjustments.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<InventoryAdjustmentTableSkeleton
|
||||
columns={inventoryAdjustmentsColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<InventoryAdjustment>
|
||||
data={
|
||||
isResponseSuccess(inventoryAdjustments)
|
||||
? inventoryAdjustments?.data
|
||||
: []
|
||||
}
|
||||
columns={inventoryAdjustmentsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={
|
||||
isResponseSuccess(inventoryAdjustments)
|
||||
? inventoryAdjustments?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(inventoryAdjustments)
|
||||
? inventoryAdjustments?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<Modal
|
||||
ref={filterModal.ref}
|
||||
className={{
|
||||
modal: 'p-0',
|
||||
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={filterModal.closeModal}
|
||||
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Produk'
|
||||
placeholder='Pilih Produk'
|
||||
options={productOptions}
|
||||
value={productIdValue}
|
||||
onChange={handleFilterProductChange}
|
||||
onInputChange={setProductInputValue}
|
||||
isLoading={isLoadingProductOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreProducts}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Gudang'
|
||||
placeholder='Pilih Gudang'
|
||||
options={warehouseOptions}
|
||||
value={formik.values.warehouse}
|
||||
onChange={handleFilterWarehouseChange}
|
||||
onInputChange={setWarehouseInputValue}
|
||||
isLoading={isLoadingWarehouseOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<SelectInputRadio
|
||||
label='Tipe Transaksi'
|
||||
placeholder='Pilih Tipe Transaksi'
|
||||
options={transactionTypeOptions}
|
||||
value={transactionTypeValue}
|
||||
onChange={handleFilterTransactionTypeChange}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationModal
|
||||
ref={singleDeleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Adjustment ini?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: singleDeleteHandler,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
|
||||
export const AdjustmentFilterSchema = object().shape({
|
||||
product_id: string().nullable(),
|
||||
warehouse_id: string().nullable(),
|
||||
transaction_type: string().nullable(),
|
||||
});
|
||||
|
||||
export type AdjustmentFilterType = {
|
||||
product_id: string | null;
|
||||
transaction_type: string | null;
|
||||
warehouse: OptionType<number> | null;
|
||||
};
|
||||
@@ -1,55 +1,118 @@
|
||||
import * as Yup from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
|
||||
export const InventoryAdjustmentFormSchema = Yup.object({
|
||||
product_category: Yup.mixed<OptionType>()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Kategori Produk wajib diisi!',
|
||||
(value) => value !== null && value !== undefined
|
||||
export type InventoryAdjustmentFormSchemaType = {
|
||||
location: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
location_id: number;
|
||||
project_flock: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
project_flock_id: number;
|
||||
kandang: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
kandang_id: number;
|
||||
project_flock_kandang: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
project_flock_kandang_id: number;
|
||||
product: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
product_id: number;
|
||||
depletion_product: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
depletion_product_id: number;
|
||||
transaction_type: string;
|
||||
transaction_subtype: string;
|
||||
qty: number | string;
|
||||
price: number | string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export const InventoryAdjustmentFormSchema: Yup.ObjectSchema<InventoryAdjustmentFormSchemaType> =
|
||||
Yup.object({
|
||||
location: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
location_id: Yup.number()
|
||||
.min(1, 'Lokasi wajib diisi!')
|
||||
.required('Lokasi wajib diisi!')
|
||||
.typeError('Lokasi wajib diisi!'),
|
||||
project_flock: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
project_flock_id: Yup.number()
|
||||
.min(1, 'Project flock wajib diisi!')
|
||||
.required('Project flock wajib diisi!')
|
||||
.typeError('Project flock wajib diisi!'),
|
||||
kandang: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
kandang_id: Yup.number()
|
||||
.min(1, 'Kandang wajib diisi!')
|
||||
.required('Kandang wajib diisi!')
|
||||
.typeError('Kandang wajib diisi!'),
|
||||
project_flock_kandang: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
project_flock_kandang_id: Yup.number()
|
||||
.default(0)
|
||||
.typeError('Project Flock Kandang wajib diisi!')
|
||||
.test(
|
||||
'is-valid-project-flock-kandang',
|
||||
'Project Flock Kandang wajib diisi!',
|
||||
(value) => value !== undefined && value !== null && value > 0
|
||||
)
|
||||
.required('Project Flock Kandang wajib diisi!'),
|
||||
product: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
product_id: Yup.number()
|
||||
.min(1, 'Produk wajib diisi!')
|
||||
.required('Produk wajib diisi!')
|
||||
.typeError('Produk wajib diisi!'),
|
||||
depletion_product: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
depletion_product_id: Yup.number()
|
||||
.default(0)
|
||||
.typeError('Jenis deplesi harus berupa angka'),
|
||||
transaction_type: Yup.string()
|
||||
.min(1, 'Tipe transaksi wajib diisi!')
|
||||
.oneOf(
|
||||
['PEMBELIAN', 'PENJUALAN', 'RECORDING'],
|
||||
'Tipe transaksi tidak valid'
|
||||
)
|
||||
.required('Tipe transaksi wajib diisi')
|
||||
.typeError('Tipe transaksi wajib diisi!'),
|
||||
transaction_subtype: Yup.string().required(
|
||||
'Sub tipe transaksi wajib diisi'
|
||||
),
|
||||
|
||||
product_category_id: Yup.number().nullable(),
|
||||
|
||||
product: Yup.mixed<OptionType>()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Produk wajib diisi!',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
|
||||
product_id: Yup.number()
|
||||
.nullable()
|
||||
.required('Produk wajib diisi!')
|
||||
.min(1, 'Produk wajib diisi!'),
|
||||
|
||||
warehouse: Yup.mixed<OptionType>()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Warehouse wajib diisi!',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
|
||||
warehouse_id: Yup.number()
|
||||
.nullable()
|
||||
.required('Warehouse wajib diisi!')
|
||||
.min(1, 'Warehouse wajib diisi!'),
|
||||
|
||||
transaction_type: Yup.string()
|
||||
.oneOf(['increase', 'decrease'], 'Tipe transaksi tidak valid')
|
||||
.nullable()
|
||||
.required('Tipe transaksi wajib diisi'),
|
||||
|
||||
quantity: Yup.number()
|
||||
.typeError('Kuantitas harus berupa angka')
|
||||
.min(1, 'Minimal kuantitas adalah 1')
|
||||
.required('Kuantitas wajib diisi'),
|
||||
|
||||
note: Yup.string().required('Catatan wajib diisi!'),
|
||||
});
|
||||
qty: Yup.number()
|
||||
.typeError('Kuantitas harus berupa angka')
|
||||
.min(1, 'Minimal kuantitas adalah 1')
|
||||
.required('Kuantitas wajib diisi'),
|
||||
price: Yup.number()
|
||||
.typeError('Harga harus berupa angka')
|
||||
.min(0, 'Minimal harga adalah 0')
|
||||
.required('Harga wajib diisi'),
|
||||
notes: Yup.string().required('Catatan wajib diisi!'),
|
||||
});
|
||||
|
||||
export type InventoryAdjustmentFormValues = Yup.InferType<
|
||||
typeof InventoryAdjustmentFormSchema
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+37
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const InventoryAdjustmentTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no inventory adjustment data displayed. Enter inventory adjustment data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<InventoryAdjustment>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryAdjustmentTableSkeleton;
|
||||
@@ -1,49 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { useFormik } from 'formik';
|
||||
|
||||
import Table from '@/components/Table';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Movement } from '@/types/api/inventory/movement';
|
||||
import { MovementApi } from '@/services/api/inventory';
|
||||
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from '@/components/Button';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput from '@/components/input/SelectInput';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import MovementTableSkeleton from '@/components/pages/inventory/movement/skeleton/MovementTableSkeleton';
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { Product } from '@/types/api/master-data/product';
|
||||
import {
|
||||
MovementFilterSchema,
|
||||
MovementFilterType,
|
||||
} from '@/components/pages/inventory/movement/filter/MovementFilter';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Movement, unknown>;
|
||||
}) => (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.inventory.transfer.detail'>
|
||||
<Button
|
||||
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `movement#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-movement#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
);
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.inventory.transfer.detail'>
|
||||
<Button
|
||||
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.inventory.transfer.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon icon='mdi:delete-outline' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MovementTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -53,167 +120,410 @@ const MovementTable = () => {
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
productFilter: '',
|
||||
warehouseFilter: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
productFilter: 'product_id',
|
||||
warehouseFilter: 'warehouse_id',
|
||||
},
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
const { data: movements, isLoading } = useSWR(
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<MovementFilterType>({
|
||||
initialValues: {
|
||||
product_id: null,
|
||||
warehouse_id: null,
|
||||
},
|
||||
validationSchema: MovementFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('productFilter', values.product_id || '');
|
||||
updateFilter('warehouseFilter', values.warehouse_id || '');
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('productFilter', '');
|
||||
updateFilter('warehouseFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
// ===== PRODUCT OPTIONS =====
|
||||
const {
|
||||
setInputValue: setProductInputValue,
|
||||
options: productOptions,
|
||||
isLoadingOptions: isLoadingProductOptions,
|
||||
loadMore: loadMoreProducts,
|
||||
} = useSelect<Product>(
|
||||
filterModal.open ? ProductApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search'
|
||||
);
|
||||
|
||||
// ===== WAREHOUSE OPTIONS =====
|
||||
const {
|
||||
setInputValue: setWarehouseInputValue,
|
||||
options: warehouseOptions,
|
||||
isLoadingOptions: isLoadingWarehouseOptions,
|
||||
loadMore: loadMoreWarehouses,
|
||||
} = useSelect<Warehouse>(
|
||||
filterModal.open ? WarehouseApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search'
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterProductChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const product = val as OptionType | null;
|
||||
const productId = product?.value ? String(product.value) : null;
|
||||
formik.setFieldValue('product_id', productId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterWarehouseChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const warehouse = val as OptionType | null;
|
||||
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
|
||||
formik.setFieldValue('warehouse_id', warehouseId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const productIdValue = useMemo(() => {
|
||||
if (!formik.values.product_id) return null;
|
||||
return (
|
||||
productOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.product_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.product_id, productOptions]);
|
||||
|
||||
const warehouseIdValue = useMemo(() => {
|
||||
if (!formik.values.warehouse_id) return null;
|
||||
return (
|
||||
warehouseOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.warehouse_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.warehouse_id, warehouseOptions]);
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [selectedMovement, setSelectedMovement] = useState<
|
||||
Movement | undefined
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const singleDeleteModal = useModal();
|
||||
|
||||
const {
|
||||
data: movements,
|
||||
isLoading,
|
||||
mutate: refreshMovements,
|
||||
} = useSWR(
|
||||
`${MovementApi.basePath}${getTableFilterQueryString()}`,
|
||||
MovementApi.getAllFetcher
|
||||
);
|
||||
|
||||
const singleDeleteHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
const response = await MovementApi.delete(selectedMovement?.id as number);
|
||||
|
||||
singleDeleteModal.closeModal();
|
||||
setIsDeleteLoading(false);
|
||||
|
||||
if (isResponseSuccess(response)) {
|
||||
toast.success(response?.message || 'Successfully delete Movement!');
|
||||
refreshMovements();
|
||||
} else {
|
||||
toast.error(response?.message || 'Failed to delete Movement');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('movement-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
setPageSize(newVal.value as number);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const movementColumns: ColumnDef<Movement>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.source_warehouse?.name,
|
||||
header: 'Gudang Asal',
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.destination_warehouse?.name,
|
||||
header: 'Gudang Tujuan',
|
||||
},
|
||||
{
|
||||
accessorKey: 'transfer_reason',
|
||||
header: 'Catatan',
|
||||
},
|
||||
{
|
||||
accessorKey: 'transfer_date',
|
||||
header: 'Tanggal',
|
||||
cell: (props) =>
|
||||
new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'),
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => {
|
||||
const totalCost = row.deliveries?.reduce(
|
||||
(sum, d) => sum + (d.shipping_cost_total || 0),
|
||||
0
|
||||
);
|
||||
return totalCost?.toLocaleString('id-ID');
|
||||
const movementColumns: ColumnDef<Movement>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
header: 'Biaya Pengiriman',
|
||||
},
|
||||
{
|
||||
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;
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu type='dropdown' props={props} />
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu type='collapse' props={props} />
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{
|
||||
accessorFn: (row) => row.source_warehouse?.name,
|
||||
header: 'Gudang Asal',
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
accessorFn: (row) => row.destination_warehouse?.name,
|
||||
header: 'Gudang Tujuan',
|
||||
},
|
||||
{
|
||||
accessorKey: 'transfer_reason',
|
||||
header: 'Catatan',
|
||||
},
|
||||
{
|
||||
accessorKey: 'transfer_date',
|
||||
header: 'Tanggal',
|
||||
cell: (props) =>
|
||||
new Date(props.row.original.transfer_date).toLocaleDateString(
|
||||
'id-ID'
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => {
|
||||
const totalCost = row.deliveries?.reduce(
|
||||
(sum, d) => sum + (d.shipping_cost_total || 0),
|
||||
0
|
||||
);
|
||||
return totalCost?.toLocaleString('id-ID');
|
||||
},
|
||||
header: 'Biaya Pengiriman',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Movement, unknown>) => {
|
||||
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 = () => {
|
||||
setSelectedMovement(props.row.original);
|
||||
singleDeleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
tableFilterState.pageSize,
|
||||
tableFilterState.page,
|
||||
singleDeleteModal,
|
||||
setSelectedMovement,
|
||||
]
|
||||
);
|
||||
|
||||
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='w-full flex flex-row gap-2'>
|
||||
<RequirePermission permissions='lti.inventory.transfer.create'>
|
||||
<Button
|
||||
href='/inventory/movement/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Movement
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Movement'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.inventory.transfer.create'>
|
||||
<Button
|
||||
href='/inventory/movement/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Movement
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-4'>
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
{/* Search and Filter */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper:
|
||||
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize', 'search']}
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<Movement>
|
||||
data={isResponseSuccess(movements) ? movements?.data : []}
|
||||
columns={movementColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(movements) ? movements?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(movements) && movements?.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',
|
||||
}}
|
||||
/>
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(movements) || movements.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<MovementTableSkeleton
|
||||
columns={movementColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Movement>
|
||||
data={isResponseSuccess(movements) ? movements?.data : []}
|
||||
columns={movementColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(movements)
|
||||
? movements?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<Modal
|
||||
ref={filterModal.ref}
|
||||
className={{
|
||||
modal: 'p-0',
|
||||
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={filterModal.closeModal}
|
||||
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Produk'
|
||||
placeholder='Pilih Produk'
|
||||
options={productOptions}
|
||||
value={productIdValue}
|
||||
onChange={handleFilterProductChange}
|
||||
onInputChange={setProductInputValue}
|
||||
isLoading={isLoadingProductOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreProducts}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Gudang'
|
||||
placeholder='Pilih Gudang'
|
||||
options={warehouseOptions}
|
||||
value={warehouseIdValue}
|
||||
onChange={handleFilterWarehouseChange}
|
||||
onInputChange={setWarehouseInputValue}
|
||||
isLoading={isLoadingWarehouseOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationModal
|
||||
ref={singleDeleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: singleDeleteHandler,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { string, object } from 'yup';
|
||||
|
||||
export const MovementFilterSchema = object().shape({
|
||||
product_id: string().nullable(),
|
||||
warehouse_id: string().nullable(),
|
||||
});
|
||||
|
||||
export type MovementFilterType = {
|
||||
product_id: string | null;
|
||||
warehouse_id: string | null;
|
||||
};
|
||||
@@ -82,6 +82,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
quantity: number;
|
||||
transfer_available_qty?: number;
|
||||
}
|
||||
|
||||
// ===== USE SELECT HOOKS =====
|
||||
@@ -379,6 +380,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
warehouse_id: formik.values.source_warehouse_id
|
||||
? formik.values.source_warehouse_id.toString()
|
||||
: '',
|
||||
transfer_context: 'inventory_transfer',
|
||||
stock_mode: 'exclude_chickin',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -391,6 +394,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
warehouse_id: pw.warehouse.id,
|
||||
warehouse_name: pw.warehouse.name,
|
||||
quantity: pw.quantity,
|
||||
transfer_available_qty: pw.transfer_available_qty,
|
||||
}))
|
||||
: [];
|
||||
}, [productWarehouses]);
|
||||
@@ -834,6 +838,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
}, [formik.values.products, formik.values.deliveries]);
|
||||
|
||||
const getAvailableStock = useCallback(
|
||||
(productId: number) => {
|
||||
if (type === 'detail') return 0;
|
||||
const productWarehouse = productWarehouseOptions.find(
|
||||
(pw) => pw.product_id === productId
|
||||
);
|
||||
|
||||
return (
|
||||
productWarehouse?.transfer_available_qty ??
|
||||
productWarehouse?.quantity ??
|
||||
0
|
||||
);
|
||||
},
|
||||
[productWarehouseOptions, type]
|
||||
);
|
||||
|
||||
const getTotalStock = useCallback(
|
||||
(productId: number) => {
|
||||
if (type === 'detail') return 0;
|
||||
const productWarehouse = productWarehouseOptions.find(
|
||||
@@ -844,6 +864,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
[productWarehouseOptions, type]
|
||||
);
|
||||
|
||||
const hasAvailableQty = useCallback(
|
||||
(productId: number) => {
|
||||
const productWarehouse = productWarehouseOptions.find(
|
||||
(pw) => pw.product_id === productId
|
||||
);
|
||||
return productWarehouse?.transfer_available_qty !== undefined;
|
||||
},
|
||||
[productWarehouseOptions]
|
||||
);
|
||||
|
||||
const getProductQtyBottomLabel = useCallback(
|
||||
(productIdx: number) => {
|
||||
if (type === 'detail') return undefined;
|
||||
@@ -851,16 +881,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
if (!product || !product.product_id) return undefined;
|
||||
|
||||
const availableStock = getAvailableStock(product.product_id);
|
||||
const totalStock = getTotalStock(product.product_id);
|
||||
const requestedQty = Number(product.product_qty) || 0;
|
||||
const remainingStock = availableStock - requestedQty;
|
||||
const isAyamProduct = hasAvailableQty(product.product_id);
|
||||
|
||||
if (requestedQty > 0) {
|
||||
if (isAyamProduct) {
|
||||
return `Sisa: ${formatNumber(remainingStock)} (Total: ${formatNumber(totalStock)})`;
|
||||
}
|
||||
return `Sisa: ${formatNumber(remainingStock)}`;
|
||||
}
|
||||
|
||||
if (isAyamProduct) {
|
||||
return `Tersedia: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)})`;
|
||||
}
|
||||
|
||||
return `Tersedia: ${formatNumber(availableStock)}`;
|
||||
},
|
||||
[formik.values.products, getAvailableStock, type]
|
||||
[
|
||||
formik.values.products,
|
||||
getAvailableStock,
|
||||
getTotalStock,
|
||||
hasAvailableQty,
|
||||
type,
|
||||
]
|
||||
);
|
||||
|
||||
const getDeliveryProductQtyBottomLabel = useCallback(
|
||||
@@ -922,15 +967,26 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
if (!product || !product.product_id) return null;
|
||||
|
||||
const availableStock = getAvailableStock(product.product_id);
|
||||
const totalStock = getTotalStock(product.product_id);
|
||||
const requestedQty = Number(product.product_qty) || 0;
|
||||
const isAyamProduct = hasAvailableQty(product.product_id);
|
||||
|
||||
if (requestedQty > availableStock) {
|
||||
if (isAyamProduct) {
|
||||
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)}, terpakai untuk chickin: ${formatNumber(totalStock - availableStock)})`;
|
||||
}
|
||||
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[formik.values.products, getAvailableStock, type]
|
||||
[
|
||||
formik.values.products,
|
||||
getAvailableStock,
|
||||
getTotalStock,
|
||||
hasAvailableQty,
|
||||
type,
|
||||
]
|
||||
);
|
||||
|
||||
const validateDeliveryQty = useCallback(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Movement } from '@/types/api/inventory/movement';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const MovementTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no movement data displayed. Enter movement data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Movement>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovementTableSkeleton;
|
||||
@@ -2,46 +2,78 @@
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import Table from '@/components/Table';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
|
||||
import { InventoryProductApi } from '@/services/api/inventory';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import { InventoryProduct } from '@/types/api/inventory/product';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<InventoryProduct, unknown>;
|
||||
}) => (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.inventory.product_stock.detail'>
|
||||
<Button
|
||||
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
|
||||
}) => {
|
||||
const popoverId = `product#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-product#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
);
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.inventory.product_stock.detail'>
|
||||
<Button
|
||||
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InventoryProductTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -65,20 +97,23 @@ const InventoryProductTable = () => {
|
||||
InventoryProductApi.getAllFetcher
|
||||
);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
setPageSize(newVal.value as number);
|
||||
setPage(1);
|
||||
useEffect(() => {
|
||||
setTableState('inventory-product-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<InventoryProduct>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: '#',
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
@@ -125,7 +160,7 @@ const InventoryProductTable = () => {
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props) => {
|
||||
cell: (props: CellContext<InventoryProduct, unknown>) => {
|
||||
const currentPageSize =
|
||||
props.table.getPaginationRowModel().rows.length;
|
||||
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||
@@ -135,96 +170,108 @@ const InventoryProductTable = () => {
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu type='dropdown' props={props} />
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu type='collapse' props={props} />
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
[tableFilterState.pageSize, tableFilterState.page]
|
||||
);
|
||||
|
||||
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='w-full flex flex-row gap-2'></div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-between items-end gap-4'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Produk'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
className={{
|
||||
wrapper:
|
||||
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.inventory.product_stock.create'>
|
||||
<Button
|
||||
href='/inventory/product/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Product
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<Table<InventoryProduct>
|
||||
data={
|
||||
isResponseSuccess(inventoryProducts) ? inventoryProducts?.data : []
|
||||
}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(inventoryProducts) &&
|
||||
inventoryProducts?.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',
|
||||
}}
|
||||
/>
|
||||
{/* Search */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(inventoryProducts) ||
|
||||
inventoryProducts.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<InventoryProductTableSkeleton
|
||||
columns={columns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<InventoryProduct>
|
||||
data={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.data
|
||||
: []
|
||||
}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { InventoryProduct } from '@/types/api/inventory/product';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const InventoryProductTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no inventory product data displayed. Enter inventory product data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<InventoryProduct>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryProductTableSkeleton;
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
|
||||
@@ -112,16 +111,34 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
useState<DeliveryOrderProductFormValues | null>(null);
|
||||
const [deliveryOrderValues, setDeliveryOrderValues] = useState<
|
||||
DeliveryOrderProductFormValues[]
|
||||
>(
|
||||
isResponseSuccess(marketing)
|
||||
? mergeSOwithDO(
|
||||
marketing?.data.sales_order?.map(SalesProductToFieldValues) ?? [],
|
||||
marketing?.data.delivery_order?.flatMap((delivery) =>
|
||||
DeliveryProductToFieldValues(marketing.data.sales_order, delivery)
|
||||
) ?? [],
|
||||
true
|
||||
)
|
||||
: []
|
||||
>([]);
|
||||
|
||||
const getDeliveryOrderValues = useCallback(
|
||||
(marketingData: Marketing): DeliveryOrderProductFormValues[] => {
|
||||
const hasDeliveryOrder =
|
||||
marketingData.delivery_order &&
|
||||
marketingData.delivery_order.length > 0 &&
|
||||
marketingData.delivery_order.some(
|
||||
(doItem) => doItem.deliveries && doItem.deliveries.length > 0
|
||||
);
|
||||
|
||||
if (hasDeliveryOrder) {
|
||||
return (
|
||||
marketingData.delivery_order?.flatMap((delivery) =>
|
||||
DeliveryProductToFieldValues(marketingData.sales_order, delivery)
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
return mergeSOwithDO(
|
||||
marketingData.sales_order?.map(SalesProductToFieldValues) ?? [],
|
||||
marketingData.delivery_order?.flatMap((delivery) =>
|
||||
DeliveryProductToFieldValues(marketingData.sales_order, delivery)
|
||||
) ?? [],
|
||||
true
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ================== SETUP FORMIK ==================
|
||||
@@ -130,14 +147,8 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
>(() => {
|
||||
if (!isResponseSuccess(marketing))
|
||||
return {} as SalesOrderFormValues & DeliveryOrderFormValues;
|
||||
const deliveryValues = mergeSOwithDO(
|
||||
marketing?.data.sales_order?.map(SalesProductToFieldValues) ?? [],
|
||||
marketing?.data.delivery_order?.flatMap((delivery) =>
|
||||
DeliveryProductToFieldValues(marketing.data.sales_order, delivery)
|
||||
) ?? [],
|
||||
true
|
||||
);
|
||||
|
||||
const deliveryValues = getDeliveryOrderValues(marketing.data);
|
||||
setDeliveryOrderValues(deliveryValues);
|
||||
|
||||
return {
|
||||
@@ -163,7 +174,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
) ?? [],
|
||||
delivery_order: deliveryValues,
|
||||
};
|
||||
}, [marketing]);
|
||||
}, [marketing, getDeliveryOrderValues]);
|
||||
|
||||
const formik = useFormik<SalesOrderFormValues & DeliveryOrderFormValues>({
|
||||
enableReinitialize: true,
|
||||
@@ -188,6 +199,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
'yyyy-MM-DD'
|
||||
),
|
||||
vehicle_number: product.vehicle_number,
|
||||
weight_per_convertion: parseFloat(
|
||||
String(product.weight_per_convertion ?? 0)
|
||||
),
|
||||
};
|
||||
}
|
||||
})
|
||||
@@ -357,7 +371,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
const currentProducts = deliveryOrderValues?.find(
|
||||
(product) => product.id == id
|
||||
);
|
||||
setSelectedDeliveryProduct(values ?? currentProducts ?? null);
|
||||
|
||||
setSelectedDeliveryProduct(currentProducts ?? values ?? null);
|
||||
|
||||
if (id) {
|
||||
setStep(2);
|
||||
}
|
||||
@@ -419,6 +435,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
'yyyy-MM-DD'
|
||||
),
|
||||
vehicle_number: product.vehicle_number,
|
||||
weight_per_convertion: parseFloat(
|
||||
String(product.weight_per_convertion ?? 0)
|
||||
),
|
||||
};
|
||||
}
|
||||
})
|
||||
@@ -648,9 +667,8 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>No. Order</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{marketing.data.do_number
|
||||
? marketing.data.do_number
|
||||
: marketing.data.so_number}
|
||||
{marketing.data.do_number ||
|
||||
marketing.data.so_number}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -765,6 +783,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
<MemoizedDeliveryOrderProductForm
|
||||
formState={'edit'}
|
||||
salesOrders={marketing?.data?.sales_order ?? []}
|
||||
deliveryOrders={marketing?.data?.delivery_order ?? []}
|
||||
exisitingValues={deliveryOrderValues}
|
||||
onSubmitForm={handleAddSubmitDO}
|
||||
initialValues={selectedDeliveryProduct ?? undefined}
|
||||
|
||||
@@ -10,9 +10,14 @@ import SelectInput, {
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
|
||||
import {
|
||||
MarketingFilterFormValues,
|
||||
MarketingFilterSchema,
|
||||
} from '@/components/pages/marketing/filter/MarketingFilter';
|
||||
import { MarketingFilter } from '@/types/api/marketing/marketing';
|
||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||
import { MarketingApi } from '@/services/api/marketing/marketing';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
|
||||
|
||||
@@ -37,9 +42,12 @@ const MarketingFilterModal = ({
|
||||
isLoadingOptions: isLoadingProductsOptions,
|
||||
setInputValue: setProductsInputValue,
|
||||
loadMore: loadMoreProducts,
|
||||
} = useSelect<BaseMarketing>(MarketingApi.basePath, 'id', 'so_number', '', {
|
||||
limit: 'limit',
|
||||
});
|
||||
} = useSelect<BaseMarketing>(
|
||||
MarketingApi.basePath,
|
||||
'id',
|
||||
'so_number',
|
||||
'search'
|
||||
);
|
||||
|
||||
const productsOptions = useMemo(() => {
|
||||
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
|
||||
@@ -66,19 +74,10 @@ const MarketingFilterModal = ({
|
||||
isLoadingOptions: isLoadingCustomersOptions,
|
||||
setInputValue: setCustomersInputValue,
|
||||
loadMore: loadMoreCustomers,
|
||||
} = useSelect(MarketingApi.basePath, 'customer.id', 'customer.name', '', {
|
||||
limit: 'limit',
|
||||
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search', {
|
||||
has_marketing: 'true',
|
||||
});
|
||||
|
||||
const uniqueCustomersOptions = useMemo(() => {
|
||||
const seen = new Set();
|
||||
return customersOptions.filter((customer) => {
|
||||
if (seen.has(customer.value)) return false;
|
||||
seen.add(customer.value);
|
||||
return true;
|
||||
});
|
||||
}, [customersOptions]);
|
||||
|
||||
const statusOptions = [
|
||||
...MARKETING_APPROVAL_LINE.map((item) => ({
|
||||
value: item.step_name.split(' ').join('_').toUpperCase(),
|
||||
@@ -87,23 +86,19 @@ const MarketingFilterModal = ({
|
||||
{ value: 'DITOLAK', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const formik = useFormik<{
|
||||
product_ids: OptionType[];
|
||||
status: OptionType | null;
|
||||
customer_id: OptionType | null;
|
||||
}>({
|
||||
const formik = useFormik<MarketingFilterFormValues>({
|
||||
initialValues: {
|
||||
product_ids: [],
|
||||
status: null,
|
||||
customer_id: null,
|
||||
customer: null,
|
||||
},
|
||||
validationSchema: MarketingFilterSchema,
|
||||
|
||||
onSubmit: async (values) => {
|
||||
const formattedValues = {
|
||||
...values,
|
||||
const formattedValues: MarketingFilter = {
|
||||
product_ids: values.product_ids.map((item) => Number(item.value)),
|
||||
status: values.status?.value.toString() || '',
|
||||
customer_id: Number(values.customer_id?.value),
|
||||
customer_id: Number(values.customer?.value),
|
||||
};
|
||||
|
||||
onSubmit?.(formattedValues);
|
||||
@@ -121,7 +116,10 @@ const MarketingFilterModal = ({
|
||||
};
|
||||
|
||||
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('customer_id', val as OptionType);
|
||||
formik.setFieldValue(
|
||||
'customer',
|
||||
!Array.isArray(val) ? (val as OptionType<number> | null) : null
|
||||
);
|
||||
};
|
||||
|
||||
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
@@ -187,9 +185,9 @@ const MarketingFilterModal = ({
|
||||
label='Customer'
|
||||
isClearable
|
||||
placeholder='Pilih customer'
|
||||
options={uniqueCustomersOptions}
|
||||
options={customersOptions}
|
||||
isLoading={isLoadingCustomersOptions}
|
||||
value={formik.values.customer_id}
|
||||
value={formik.values.customer}
|
||||
onChange={customerChangeHandler}
|
||||
onInputChange={setCustomersInputValue}
|
||||
onMenuScrollToBottom={loadMoreCustomers}
|
||||
|
||||
@@ -30,6 +30,8 @@ import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import StatusBadge from '@/components/helper/StatusBadge';
|
||||
import MarketingFilterModal from '@/components/pages/marketing/MarketingFilter';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import MarketingTableSkeleton from '@/components/pages/marketing/skeleton/MarketingTableSkeleton';
|
||||
|
||||
const RowsOptionsMenu = ({
|
||||
props,
|
||||
@@ -214,32 +216,6 @@ const MarketingTable = () => {
|
||||
updateFilter('customer_id', '');
|
||||
};
|
||||
|
||||
// ===== ACTIVE FILTERS COUNT =====
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
let count = 0;
|
||||
|
||||
// Product filter
|
||||
if (tableFilterState.product_ids) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (tableFilterState.status) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// Customer filter
|
||||
if (tableFilterState.customer_id) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return count;
|
||||
}, [
|
||||
tableFilterState.product_ids,
|
||||
tableFilterState.status,
|
||||
tableFilterState.customer_id,
|
||||
]);
|
||||
|
||||
const approveClickHandler = () => {
|
||||
setApproveAction('APPROVED');
|
||||
confirmationModal.openModal();
|
||||
@@ -588,28 +564,14 @@ const MarketingTable = () => {
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-row gap-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize', 'search']}
|
||||
onClick={() => {
|
||||
filterModal.openModal();
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||
{
|
||||
'border-primary-gradient text-primary':
|
||||
activeFiltersCount > 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
Filter
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
@@ -655,28 +617,49 @@ const MarketingTable = () => {
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
data={allData}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
|
||||
totalItems={
|
||||
isResponseSuccess(marketing) ? marketing?.meta?.total_results : 0
|
||||
}
|
||||
isLoading={isLoadingMarketing}
|
||||
className={{
|
||||
containerClassName: cn('p-3', {
|
||||
'w-full mb-20':
|
||||
isResponseSuccess(marketing) && marketing?.data?.length === 0,
|
||||
}),
|
||||
bodyColumnClassName:
|
||||
'last:text-end last:w-17 first:text-start first:w-5',
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoadingMarketing ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(marketing) || marketing.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<MarketingTableSkeleton
|
||||
columns={columns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
data={allData}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
|
||||
totalItems={
|
||||
isResponseSuccess(marketing)
|
||||
? marketing?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
isLoading={isLoadingMarketing}
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
bodyColumnClassName:
|
||||
'last:text-end last:w-17 first:text-start first:w-5',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
@@ -763,7 +746,7 @@ const MarketingTable = () => {
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
header: 'Kandang',
|
||||
header: 'Gudang Fisik',
|
||||
accessorFn(row) {
|
||||
return row.product_warehouse.warehouse.name;
|
||||
},
|
||||
|
||||
@@ -195,7 +195,9 @@ const SalesOrderFormModal = ({
|
||||
product.marketing_type?.value?.toLowerCase() === 'telur'
|
||||
? convertionUnitValue === 'PETI'
|
||||
? 'PETI'
|
||||
: 'KG' // termasuk "QTY" dan "KG"
|
||||
: convertionUnitValue === 'QTY'
|
||||
? 'QTY'
|
||||
: 'KG'
|
||||
: undefined;
|
||||
|
||||
// Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM"
|
||||
@@ -207,7 +209,6 @@ const SalesOrderFormModal = ({
|
||||
|
||||
return {
|
||||
vehicle_number: product.vehicle_number as string,
|
||||
kandang_id: product.kandang_id as number,
|
||||
product_warehouse_id: product.product_warehouse_id as number,
|
||||
unit_price: parseFloat(String(product.unit_price || 0)),
|
||||
total_weight: parseFloat(String(product.total_weight || 0)),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { array, mixed, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
|
||||
export const MarketingFilterSchema = object({
|
||||
product_ids: array().of(mixed<OptionType<number>>().required()).required(),
|
||||
status: mixed<OptionType<string>>().nullable(),
|
||||
customer: mixed<OptionType<number>>().nullable(),
|
||||
});
|
||||
|
||||
export type MarketingFilterFormValues = {
|
||||
product_ids: OptionType<number>[];
|
||||
status: OptionType<string> | null;
|
||||
customer: OptionType<number> | null;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Marketing,
|
||||
} from '@/types/api/marketing/marketing';
|
||||
import { formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
|
||||
|
||||
type MarketingSchemaType = {
|
||||
customer_id: number | undefined;
|
||||
@@ -97,17 +98,21 @@ export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
|
||||
export const SalesProductToFieldValues = (
|
||||
product: BaseSalesOrder
|
||||
): SalesOrderProductFormValues => {
|
||||
const warehouseOption = {
|
||||
value: product.product_warehouse.warehouse.id,
|
||||
label: product.product_warehouse.warehouse.name,
|
||||
};
|
||||
|
||||
return {
|
||||
id: product.id,
|
||||
vehicle_number: product.vehicle_number,
|
||||
warehouse_id: product.product_warehouse.warehouse.id,
|
||||
warehouse: warehouseOption,
|
||||
kandang_id: product.product_warehouse.warehouse.id,
|
||||
kandang: {
|
||||
value: product.product_warehouse.warehouse.id,
|
||||
label: product.product_warehouse.warehouse.name,
|
||||
},
|
||||
kandang: warehouseOption,
|
||||
product_warehouse: {
|
||||
value: product.product_warehouse.id,
|
||||
label: product.product_warehouse.product.name,
|
||||
label: getProductWarehouseOptionLabel(product.product_warehouse),
|
||||
},
|
||||
product_warehouse_data: product.product_warehouse,
|
||||
product_warehouse_id: product.product_warehouse.id,
|
||||
@@ -139,11 +144,34 @@ export const DeliveryProductToFieldValues = (
|
||||
delivery: BaseDeliveryOrder
|
||||
): DeliveryOrderProductFormValues[] => {
|
||||
const data = delivery.deliveries.map((item) => {
|
||||
const soId = salesOrders.find(
|
||||
const salesOrder = salesOrders.find(
|
||||
(so) => so.product_warehouse.id === item.product_warehouse.id
|
||||
)?.id;
|
||||
);
|
||||
const warehouseOption = {
|
||||
value: item.product_warehouse.warehouse.id,
|
||||
label: item.product_warehouse.warehouse.name,
|
||||
};
|
||||
|
||||
const initialSisaBerat =
|
||||
item?.total_weight &&
|
||||
salesOrder?.weight_per_convertion &&
|
||||
salesOrder?.total_peti
|
||||
? Number(item.total_weight) -
|
||||
Number(salesOrder.weight_per_convertion) *
|
||||
Number(salesOrder.total_peti)
|
||||
: 0;
|
||||
|
||||
const initialPricePerConvertion =
|
||||
item?.total_price &&
|
||||
salesOrder?.total_peti &&
|
||||
Number(salesOrder.total_peti) !== 0
|
||||
? (Number(item.total_price) -
|
||||
initialSisaBerat * Number(item.unit_price || 0)) /
|
||||
Number(salesOrder.total_peti)
|
||||
: Number(item?.unit_price || 0);
|
||||
|
||||
return {
|
||||
id: soId,
|
||||
id: salesOrder?.id,
|
||||
unit_price: item.unit_price,
|
||||
total_weight: item.total_weight,
|
||||
qty: item.qty,
|
||||
@@ -152,19 +180,31 @@ export const DeliveryProductToFieldValues = (
|
||||
vehicle_number: item.vehicle_number,
|
||||
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
|
||||
do_number: delivery.do_number,
|
||||
marketing_product_id: soId,
|
||||
marketing_product_id: salesOrder?.id,
|
||||
marketing_type: salesOrder?.marketing_type
|
||||
? {
|
||||
value: salesOrder?.marketing_type,
|
||||
label: formatTitleCase(salesOrder?.marketing_type),
|
||||
}
|
||||
: null,
|
||||
convertion_unit: salesOrder?.convertion_unit
|
||||
? {
|
||||
value: salesOrder?.convertion_unit.toLowerCase(),
|
||||
label: formatTitleCase(salesOrder?.convertion_unit),
|
||||
}
|
||||
: null,
|
||||
marketing_product: {
|
||||
id: soId,
|
||||
id: salesOrder?.id,
|
||||
vehicle_number: item.vehicle_number,
|
||||
warehouse_id: item.product_warehouse.warehouse.id,
|
||||
warehouse: warehouseOption,
|
||||
kandang_id: item.product_warehouse.warehouse.id,
|
||||
kandang: {
|
||||
value: item.product_warehouse.warehouse.id,
|
||||
label: item.product_warehouse.warehouse.name,
|
||||
},
|
||||
kandang: warehouseOption,
|
||||
product_warehouse: {
|
||||
value: item.product_warehouse.id,
|
||||
label: item.product_warehouse.product.name,
|
||||
label: getProductWarehouseOptionLabel(item.product_warehouse),
|
||||
},
|
||||
product_warehouse_data: item.product_warehouse,
|
||||
product_warehouse_id: item.product_warehouse.id,
|
||||
unit_price: item.unit_price,
|
||||
total_weight: item.total_weight,
|
||||
@@ -172,8 +212,13 @@ export const DeliveryProductToFieldValues = (
|
||||
avg_weight: item.avg_weight,
|
||||
total_price: item.total_price,
|
||||
},
|
||||
total_peti: salesOrder?.total_peti,
|
||||
weight_per_convertion:
|
||||
item?.weight_per_convertion ?? salesOrder?.weight_per_convertion ?? 0,
|
||||
price_per_convertion: initialPricePerConvertion,
|
||||
} as DeliveryOrderProductFormValues;
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
export const mergeSOwithDO = (
|
||||
@@ -181,10 +226,25 @@ export const mergeSOwithDO = (
|
||||
deliveryOrders: DeliveryOrderProductFormValues[],
|
||||
autofill?: boolean
|
||||
): DeliveryOrderProductFormValues[] => {
|
||||
const hasDeliveryOrders = deliveryOrders.length > 0;
|
||||
|
||||
return salesOrders.map((so) => {
|
||||
const delivery = deliveryOrders.find(
|
||||
(d) => d?.marketing_product_id === so.id
|
||||
);
|
||||
const isTelurQty =
|
||||
so.marketing_type?.value?.toLowerCase() === 'telur' &&
|
||||
so.convertion_unit?.value?.toLowerCase() === 'qty';
|
||||
const salesOrderUnitPrice =
|
||||
isTelurQty && Number(so.total_price || 0) > 0 && Number(so.qty || 0) > 0
|
||||
? Number(so.total_price) / Number(so.qty)
|
||||
: so.unit_price;
|
||||
const salesOrderPricePerQty =
|
||||
isTelurQty &&
|
||||
Number(so.total_price || 0) > 0 &&
|
||||
Number(so.total_weight || 0) > 0
|
||||
? Number(so.total_price) / Number(so.total_weight)
|
||||
: so.price_per_qty;
|
||||
|
||||
return {
|
||||
...so, // nilai dasar dari sales order
|
||||
@@ -192,30 +252,50 @@ export const mergeSOwithDO = (
|
||||
delivery_date: delivery?.delivery_date || undefined,
|
||||
do_number: delivery?.do_number || undefined,
|
||||
vehicle_number: delivery?.vehicle_number || so.vehicle_number,
|
||||
unit_price: autofill ? so.unit_price : delivery?.unit_price,
|
||||
total_weight: autofill ? so.total_weight : delivery?.total_weight,
|
||||
qty: autofill ? so.qty : delivery?.qty,
|
||||
avg_weight: autofill ? so.avg_weight : delivery?.avg_weight,
|
||||
total_price: autofill ? so.total_price : delivery?.total_price,
|
||||
unit_price:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.unit_price
|
||||
: salesOrderUnitPrice,
|
||||
total_weight:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.total_weight
|
||||
: so.total_weight,
|
||||
qty: autofill && hasDeliveryOrders ? delivery?.qty : so.qty,
|
||||
avg_weight:
|
||||
autofill && hasDeliveryOrders ? delivery?.avg_weight : so.avg_weight,
|
||||
total_price:
|
||||
autofill && hasDeliveryOrders ? delivery?.total_price : so.total_price,
|
||||
marketing_product: so, // jika ada, override
|
||||
uom: autofill ? so.uom : delivery?.uom,
|
||||
weight_per_convertion: autofill
|
||||
? so.weight_per_convertion
|
||||
: delivery?.weight_per_convertion,
|
||||
price_per_convertion: autofill
|
||||
? so.price_per_convertion
|
||||
: delivery?.price_per_convertion,
|
||||
convertion_unit: autofill
|
||||
? so.convertion_unit
|
||||
: delivery?.convertion_unit,
|
||||
marketing_type: autofill ? so.marketing_type : delivery?.marketing_type,
|
||||
total_peti: autofill ? so.total_peti : delivery?.total_peti,
|
||||
price_per_qty: autofill ? so.price_per_qty : delivery?.price_per_qty,
|
||||
sisa_berat: autofill ? so.sisa_berat : delivery?.sisa_berat,
|
||||
price_sisa_berat: autofill
|
||||
? so.price_sisa_berat
|
||||
: delivery?.price_sisa_berat,
|
||||
week: autofill ? so.week : delivery?.week,
|
||||
uom: autofill && hasDeliveryOrders ? delivery?.uom : so.uom,
|
||||
weight_per_convertion:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.weight_per_convertion
|
||||
: so.weight_per_convertion,
|
||||
price_per_convertion:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.price_per_convertion
|
||||
: so.price_per_convertion,
|
||||
convertion_unit:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.convertion_unit
|
||||
: so.convertion_unit,
|
||||
marketing_type:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.marketing_type
|
||||
: so.marketing_type,
|
||||
total_peti:
|
||||
autofill && hasDeliveryOrders ? delivery?.total_peti : so.total_peti,
|
||||
price_per_qty:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.price_per_qty
|
||||
: salesOrderPricePerQty,
|
||||
sisa_berat:
|
||||
autofill && hasDeliveryOrders ? delivery?.sisa_berat : so.sisa_berat,
|
||||
price_sisa_berat:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.price_sisa_berat
|
||||
: so.price_sisa_berat,
|
||||
week: autofill && hasDeliveryOrders ? delivery?.week : so.week,
|
||||
} as DeliveryOrderProductFormValues;
|
||||
});
|
||||
};
|
||||
|
||||
+232
-71
@@ -10,7 +10,10 @@ import NumberInput from '@/components/input/NumberInput';
|
||||
import PatternInput from '@/components/input/PatternInput';
|
||||
import { formatTitleCase, formatVechicleNumber } from '@/lib/helper';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import { BaseSalesOrder } from '@/types/api/marketing/marketing';
|
||||
import {
|
||||
BaseSalesOrder,
|
||||
BaseDeliveryOrder,
|
||||
} from '@/types/api/marketing/marketing';
|
||||
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
|
||||
import * as Yup from 'yup';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
@@ -29,9 +32,67 @@ import Dropdown from '@/components/Dropdown';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { handleMarketingCalculation } from '@/lib/marketing-calculation';
|
||||
|
||||
type PricingOption =
|
||||
| string
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type PricingSource =
|
||||
| {
|
||||
marketing_type?: PricingOption;
|
||||
convertion_unit?: PricingOption;
|
||||
total_price?: string | number | null;
|
||||
qty?: string | number | null;
|
||||
total_weight?: string | number | null;
|
||||
unit_price?: string | number | null;
|
||||
price_per_qty?: number | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const getOptionValue = (value?: PricingOption) => {
|
||||
if (!value) return undefined;
|
||||
if (typeof value === 'string') return value.toLowerCase();
|
||||
|
||||
return value.value?.toLowerCase();
|
||||
};
|
||||
|
||||
const isTelurQtyProduct = (value?: PricingSource) =>
|
||||
getOptionValue(value?.marketing_type) === 'telur' &&
|
||||
getOptionValue(value?.convertion_unit) === 'qty';
|
||||
|
||||
const getDisplayedUnitPrice = (value?: PricingSource) => {
|
||||
if (
|
||||
isTelurQtyProduct(value) &&
|
||||
Number(value?.total_price || 0) > 0 &&
|
||||
Number(value?.qty || 0) > 0
|
||||
) {
|
||||
return Number(value?.total_price) / Number(value?.qty);
|
||||
}
|
||||
|
||||
return value?.unit_price ?? undefined;
|
||||
};
|
||||
|
||||
const getDisplayedPricePerQty = (value?: PricingSource) => {
|
||||
if (
|
||||
isTelurQtyProduct(value) &&
|
||||
Number(value?.total_price || 0) > 0 &&
|
||||
Number(value?.total_weight || 0) > 0
|
||||
) {
|
||||
return Number(value?.total_price) / Number(value?.total_weight);
|
||||
}
|
||||
|
||||
return value?.price_per_qty ?? null;
|
||||
};
|
||||
|
||||
const DeliveryOrderProductForm = ({
|
||||
formState,
|
||||
salesOrders,
|
||||
deliveryOrders,
|
||||
initialValues,
|
||||
exisitingValues,
|
||||
onUpdateForm,
|
||||
@@ -39,6 +100,7 @@ const DeliveryOrderProductForm = ({
|
||||
}: {
|
||||
formState: 'add' | 'edit';
|
||||
salesOrders: BaseSalesOrder[];
|
||||
deliveryOrders?: BaseDeliveryOrder[];
|
||||
initialValues?: DeliveryOrderProductFormValues;
|
||||
exisitingValues?: DeliveryOrderProductFormValues[];
|
||||
onSubmitForm?: (value: DeliveryOrderProductFormValues) => Promise<void>;
|
||||
@@ -71,7 +133,7 @@ const DeliveryOrderProductForm = ({
|
||||
? (Number(initialValues.total_price) -
|
||||
initialSisaBerat * Number(initialValues.unit_price || 0)) /
|
||||
Number(initialValues.total_peti)
|
||||
: 0;
|
||||
: Number(initialValues?.unit_price || 0);
|
||||
|
||||
const initialPriceSisaBerat =
|
||||
initialValues?.total_price && initialValues?.total_peti
|
||||
@@ -107,7 +169,7 @@ const DeliveryOrderProductForm = ({
|
||||
if (!Boolean(item.qty)) {
|
||||
return {
|
||||
value: item.id,
|
||||
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.kandang?.label}`,
|
||||
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.warehouse?.label ?? item.marketing_product?.kandang?.label}`,
|
||||
} as OptionType;
|
||||
} else {
|
||||
return null;
|
||||
@@ -115,22 +177,82 @@ const DeliveryOrderProductForm = ({
|
||||
})
|
||||
?.filter((item) => item != null) as OptionType[];
|
||||
|
||||
const hasDeliveryOrder = useMemo(() => {
|
||||
return (
|
||||
deliveryOrders &&
|
||||
deliveryOrders.length > 0 &&
|
||||
deliveryOrders.some(
|
||||
(doItem) => doItem.deliveries && doItem.deliveries.length > 0
|
||||
)
|
||||
);
|
||||
}, [deliveryOrders]);
|
||||
|
||||
const deliveryOrder = useMemo(() => {
|
||||
if (!hasDeliveryOrder || !deliveryOrders) return null;
|
||||
|
||||
for (const doItem of deliveryOrders) {
|
||||
const found = doItem.deliveries.find(
|
||||
(d) =>
|
||||
d.product_warehouse.id ===
|
||||
initialValues?.marketing_product?.product_warehouse_id
|
||||
);
|
||||
if (found) {
|
||||
return {
|
||||
...found,
|
||||
delivery_date: doItem.delivery_date,
|
||||
do_number: doItem.do_number,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [deliveryOrders, hasDeliveryOrder, initialValues]);
|
||||
|
||||
const salesOrder = salesOrders.find(
|
||||
(item) => item.id === initialValues?.marketing_product_id
|
||||
);
|
||||
|
||||
const defaultPricingSource: PricingSource = {
|
||||
marketing_type:
|
||||
initialValues?.marketing_type ?? salesOrder?.marketing_type ?? null,
|
||||
convertion_unit:
|
||||
initialValues?.convertion_unit ?? salesOrder?.convertion_unit ?? null,
|
||||
total_price:
|
||||
deliveryOrder?.total_price ??
|
||||
initialValues?.total_price ??
|
||||
salesOrder?.total_price,
|
||||
qty: deliveryOrder?.qty ?? initialValues?.qty ?? salesOrder?.qty,
|
||||
total_weight:
|
||||
deliveryOrder?.total_weight ??
|
||||
initialValues?.total_weight ??
|
||||
salesOrder?.total_weight,
|
||||
unit_price:
|
||||
deliveryOrder?.unit_price ??
|
||||
initialValues?.unit_price ??
|
||||
salesOrder?.unit_price,
|
||||
price_per_qty: initialValues?.price_per_qty ?? null,
|
||||
};
|
||||
|
||||
const formik = useFormik<DeliveryOrderProductFormValues>({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
delivery_date: initialValues?.delivery_date || undefined,
|
||||
vehicle_number: initialValues?.vehicle_number || undefined,
|
||||
delivery_date:
|
||||
deliveryOrder?.delivery_date ||
|
||||
initialValues?.delivery_date ||
|
||||
undefined,
|
||||
vehicle_number:
|
||||
deliveryOrder?.vehicle_number ||
|
||||
initialValues?.vehicle_number ||
|
||||
undefined,
|
||||
marketing_product_id:
|
||||
salesOrder?.id || initialValues?.marketing_product_id || undefined,
|
||||
unit_price: initialValues?.unit_price || undefined,
|
||||
total_weight: initialValues?.total_weight || undefined,
|
||||
qty: initialValues?.qty || undefined,
|
||||
avg_weight: initialValues?.avg_weight || undefined,
|
||||
total_price: initialValues?.total_price || undefined,
|
||||
unit_price: getDisplayedUnitPrice(defaultPricingSource),
|
||||
total_weight:
|
||||
deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined,
|
||||
qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined,
|
||||
avg_weight:
|
||||
deliveryOrder?.avg_weight ?? initialValues?.avg_weight ?? undefined,
|
||||
total_price:
|
||||
deliveryOrder?.total_price ?? initialValues?.total_price ?? undefined,
|
||||
marketing_product: initialValues?.marketing_product || undefined,
|
||||
uom: initialValues?.uom || '',
|
||||
weight_per_convertion:
|
||||
@@ -141,7 +263,7 @@ const DeliveryOrderProductForm = ({
|
||||
convertion_unit: initialValues?.convertion_unit || null,
|
||||
marketing_type: initialValues?.marketing_type || null,
|
||||
total_peti: initialValues?.total_peti ?? null,
|
||||
price_per_qty: initialValues?.price_per_qty ?? null,
|
||||
price_per_qty: getDisplayedPricePerQty(defaultPricingSource),
|
||||
sisa_berat: initialSisaBerat,
|
||||
price_sisa_berat: initialPriceSisaBerat,
|
||||
week: initialValues?.week ?? null,
|
||||
@@ -236,6 +358,25 @@ const DeliveryOrderProductForm = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty)
|
||||
const handleFieldChange = (
|
||||
field: string,
|
||||
value: number | string,
|
||||
callback?: () => void
|
||||
) => {
|
||||
formik.setFieldValue(field, value);
|
||||
|
||||
setTimeout(() => {
|
||||
handleMarketingCalculation(field, {
|
||||
values: { ...formik.values, [field]: value },
|
||||
setFieldValue: formik.setFieldValue,
|
||||
hasSisaBerat,
|
||||
});
|
||||
}, 0);
|
||||
|
||||
if (callback) callback();
|
||||
};
|
||||
|
||||
// Handler khusus untuk toggle sisa berat - langsung pakai nilai baru
|
||||
const handleSisaBeratToggle = (newHasSisaBerat: boolean) => {
|
||||
setHasSisaBerat(newHasSisaBerat);
|
||||
@@ -265,11 +406,15 @@ const DeliveryOrderProductForm = ({
|
||||
if (!Boolean(initialValues.qty)) {
|
||||
handleResetForm();
|
||||
} else {
|
||||
setFormikValues(initialValues);
|
||||
setFormikValues({
|
||||
...initialValues,
|
||||
unit_price: getDisplayedUnitPrice(initialValues),
|
||||
price_per_qty: getDisplayedPricePerQty(initialValues),
|
||||
});
|
||||
if (initialValues?.marketing_product_id) {
|
||||
setSelectedProduct({
|
||||
value: initialValues?.id,
|
||||
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.kandang?.label}`,
|
||||
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
|
||||
} as OptionType);
|
||||
}
|
||||
}
|
||||
@@ -394,10 +539,11 @@ const DeliveryOrderProductForm = ({
|
||||
marketing_product_id: selected.value as number,
|
||||
marketing_product: soFieldValues,
|
||||
qty: so.qty,
|
||||
unit_price: so.unit_price,
|
||||
unit_price: getDisplayedUnitPrice(so),
|
||||
total_price: so.total_price,
|
||||
avg_weight: so.avg_weight,
|
||||
total_weight: so.total_weight,
|
||||
price_per_qty: getDisplayedPricePerQty(so),
|
||||
vehicle_number: so.vehicle_number,
|
||||
week: soFieldValues.week ?? null,
|
||||
});
|
||||
@@ -408,7 +554,11 @@ const DeliveryOrderProductForm = ({
|
||||
text={
|
||||
exisitingValues?.find(
|
||||
(item) => item.id === selectedProduct?.value
|
||||
)?.marketing_product?.kandang?.label ?? ''
|
||||
)?.marketing_product?.warehouse?.label ??
|
||||
exisitingValues?.find(
|
||||
(item) => item.id === selectedProduct?.value
|
||||
)?.marketing_product?.kandang?.label ??
|
||||
''
|
||||
}
|
||||
color='success'
|
||||
className={{
|
||||
@@ -520,13 +670,11 @@ const DeliveryOrderProductForm = ({
|
||||
} per ${formik.values.convertion_unit?.value}`}
|
||||
value={formik.values.weight_per_convertion ?? ''}
|
||||
onChange={(e) => {
|
||||
formik.setFieldValue(
|
||||
'weight_per_convertion',
|
||||
Number(e.target.value)
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('weight_per_convertion', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
setCurrentInput(e.target.name);
|
||||
}}
|
||||
onBlur={() => handleBlurField('weight_per_convertion')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,10 +712,11 @@ const DeliveryOrderProductForm = ({
|
||||
name='total_peti'
|
||||
value={formik.values.total_peti ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('total_peti', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('total_peti')}
|
||||
isError={
|
||||
formik.touched.total_peti && Boolean(formik.errors.total_peti)
|
||||
}
|
||||
@@ -575,7 +724,7 @@ const DeliveryOrderProductForm = ({
|
||||
placeholder='Masukan Total Peti'
|
||||
endAdornment={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-base-content/50'>Kg</span>
|
||||
<span className='text-sm text-base-content/50'>Peti</span>
|
||||
</div>
|
||||
}
|
||||
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
|
||||
@@ -592,10 +741,11 @@ const DeliveryOrderProductForm = ({
|
||||
name='avg_weight'
|
||||
value={formik.values.avg_weight}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('avg_weight', value, () =>
|
||||
setCurrentInput('avg_weight')
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('avg_weight')}
|
||||
isError={
|
||||
formik.touched.avg_weight &&
|
||||
Boolean(formik.errors.avg_weight)
|
||||
@@ -613,16 +763,20 @@ const DeliveryOrderProductForm = ({
|
||||
name='total_weight'
|
||||
value={formik.values.total_weight}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('total_weight', value, () =>
|
||||
setCurrentInput('total_weight')
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('total_weight')}
|
||||
isError={
|
||||
formik.touched.total_weight &&
|
||||
Boolean(formik.errors.total_weight)
|
||||
}
|
||||
errorMessage={formik.errors.total_weight}
|
||||
placeholder='Masukan Total Bobot'
|
||||
disabled={
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -638,10 +792,11 @@ const DeliveryOrderProductForm = ({
|
||||
name='qty'
|
||||
value={formik.values.qty}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('qty', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('qty')}
|
||||
isError={Boolean(formik.errors.qty)}
|
||||
errorMessage={formik.errors.qty}
|
||||
placeholder='Masukan Kuantitas'
|
||||
@@ -677,10 +832,11 @@ const DeliveryOrderProductForm = ({
|
||||
name='price_per_convertion'
|
||||
value={formik.values.price_per_convertion ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('price_per_convertion', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('price_per_convertion')}
|
||||
isError={
|
||||
formik.touched.price_per_convertion &&
|
||||
Boolean(formik.errors.price_per_convertion)
|
||||
@@ -690,47 +846,49 @@ const DeliveryOrderProductForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga per butir untuk TELUR + QTY */}
|
||||
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga / Butir (Rp)'
|
||||
name='price_per_qty'
|
||||
value={formik.values.price_per_qty ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.setFieldValue('price_per_qty', Number(e.target.value));
|
||||
setCurrentInput('price_per_qty');
|
||||
}}
|
||||
onBlur={() => handleBlurField('price_per_qty')}
|
||||
isError={
|
||||
formik.touched.price_per_qty &&
|
||||
Boolean(formik.errors.price_per_qty)
|
||||
}
|
||||
errorMessage={formik.errors.price_per_qty}
|
||||
placeholder='Masukan Harga per Butir'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga Satuan */}
|
||||
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
|
||||
<NumberInput
|
||||
required
|
||||
label={`Harga / ${isResponseSuccess(productData) ? productData?.data?.uom?.name : 'Produk'} (Rp)`}
|
||||
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
|
||||
name='unit_price'
|
||||
value={formik.values.unit_price}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('unit_price', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('unit_price')}
|
||||
isError={Boolean(formik.errors.unit_price)}
|
||||
errorMessage={formik.errors.unit_price}
|
||||
placeholder='Masukan Harga Satuan'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga per kg untuk TELUR + QTY */}
|
||||
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga / Kg (Rp)'
|
||||
name='price_per_qty'
|
||||
value={formik.values.price_per_qty ?? undefined}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('price_per_qty', value, () =>
|
||||
setCurrentInput('price_per_qty')
|
||||
);
|
||||
}}
|
||||
isError={
|
||||
formik.touched.price_per_qty &&
|
||||
Boolean(formik.errors.price_per_qty)
|
||||
}
|
||||
errorMessage={formik.errors.price_per_qty}
|
||||
placeholder='Masukan Harga per Kg'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sisa kg diluar peti */}
|
||||
{formik.values.convertion_unit?.value.toLowerCase() === 'peti' && (
|
||||
<div className='flex flex-col'>
|
||||
@@ -760,10 +918,11 @@ const DeliveryOrderProductForm = ({
|
||||
name='sisa_berat'
|
||||
value={formik.values.sisa_berat ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('sisa_berat', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('sisa_berat')}
|
||||
isError={
|
||||
formik.touched.sisa_berat && Boolean(formik.errors.sisa_berat)
|
||||
}
|
||||
@@ -776,10 +935,11 @@ const DeliveryOrderProductForm = ({
|
||||
name='price_sisa_berat'
|
||||
value={formik.values.price_sisa_berat ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('price_sisa_berat', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('price_sisa_berat')}
|
||||
isError={
|
||||
formik.touched.price_sisa_berat &&
|
||||
Boolean(formik.errors.price_sisa_berat)
|
||||
@@ -797,10 +957,11 @@ const DeliveryOrderProductForm = ({
|
||||
name='total_price'
|
||||
value={formik.values.total_price}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('total_price', value, () =>
|
||||
setCurrentInput('total_price')
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('total_price')}
|
||||
isError={
|
||||
formik.touched.total_price && Boolean(formik.errors.total_price)
|
||||
}
|
||||
|
||||
+19
-7
@@ -3,6 +3,11 @@ import * as Yup from 'yup';
|
||||
|
||||
type SalesOrderProductSchemaType = {
|
||||
id?: number | undefined;
|
||||
warehouse_id?: number;
|
||||
warehouse?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
kandang_id?: number;
|
||||
kandang?: {
|
||||
value: number;
|
||||
@@ -44,15 +49,22 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
|
||||
Yup.object({
|
||||
id: Yup.number(),
|
||||
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
|
||||
kandang: Yup.object({
|
||||
warehouse: Yup.object({
|
||||
value: Yup.number()
|
||||
.min(1, 'Kandang wajib diisi!')
|
||||
.required('Kandang wajib diisi!'),
|
||||
label: Yup.string().required('Kandang wajib diisi!'),
|
||||
.min(1, 'Gudang fisik wajib diisi!')
|
||||
.required('Gudang fisik wajib diisi!'),
|
||||
label: Yup.string().required('Gudang fisik wajib diisi!'),
|
||||
}).nullable(),
|
||||
kandang_id: Yup.number()
|
||||
.min(1, 'Kandang wajib diisi!')
|
||||
.required('Kandang wajib diisi!'),
|
||||
warehouse_id: Yup.number()
|
||||
.min(1, 'Gudang fisik wajib diisi!')
|
||||
.required('Gudang fisik wajib diisi!'),
|
||||
kandang: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
kandang_id: Yup.number().optional(),
|
||||
product_warehouse: Yup.object({
|
||||
value: Yup.number()
|
||||
.min(1, 'Produk wajib diisi!')
|
||||
|
||||
+198
-96
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
|
||||
import { RefObject, useEffect, useMemo, useState } from 'react';
|
||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { WarehouseApi } from '@/services/api/master-data';
|
||||
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
||||
import { ProductWarehouseApi } from '@/services/api/inventory';
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { Icon } from '@iconify/react';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { handleMarketingCalculation } from '@/lib/marketing-calculation';
|
||||
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
|
||||
|
||||
const SalesOrderProductForm = ({
|
||||
initialValues,
|
||||
@@ -67,7 +68,25 @@ const SalesOrderProductForm = ({
|
||||
? (Number(initialValues.total_price) -
|
||||
initialSisaBerat * Number(initialValues.unit_price || 0)) /
|
||||
Number(initialValues.total_peti)
|
||||
: 0;
|
||||
: Number(initialValues?.unit_price || 0);
|
||||
|
||||
const isInitialTelurQty =
|
||||
initialValues?.marketing_type?.value?.toLowerCase() === 'telur' &&
|
||||
initialValues?.convertion_unit?.value?.toLowerCase() === 'qty';
|
||||
|
||||
const initialUnitPrice =
|
||||
isInitialTelurQty &&
|
||||
Number(initialValues?.total_price || 0) > 0 &&
|
||||
Number(initialValues?.qty || 0) > 0
|
||||
? Number(initialValues?.total_price) / Number(initialValues?.qty)
|
||||
: initialValues?.unit_price || '';
|
||||
|
||||
const initialPricePerQty =
|
||||
isInitialTelurQty &&
|
||||
Number(initialValues?.total_price || 0) > 0 &&
|
||||
Number(initialValues?.total_weight || 0) > 0
|
||||
? Number(initialValues?.total_price) / Number(initialValues?.total_weight)
|
||||
: (initialValues?.price_per_qty ?? null);
|
||||
|
||||
const initialPriceSisaBerat =
|
||||
initialValues?.total_price && initialValues?.total_peti
|
||||
@@ -84,11 +103,15 @@ const SalesOrderProductForm = ({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
vehicle_number: initialValues?.vehicle_number || '',
|
||||
warehouse_id:
|
||||
initialValues?.warehouse_id ?? initialValues?.kandang_id ?? undefined,
|
||||
warehouse: initialValues?.warehouse ?? initialValues?.kandang ?? null,
|
||||
kandang_id: initialValues?.kandang_id || undefined,
|
||||
kandang: initialValues?.kandang || null,
|
||||
product_warehouse: initialValues?.product_warehouse || null,
|
||||
product_warehouse_data: initialValues?.product_warehouse_data || null,
|
||||
product_warehouse_id: initialValues?.product_warehouse_id || undefined,
|
||||
unit_price: initialValues?.unit_price || '',
|
||||
unit_price: initialUnitPrice,
|
||||
total_weight: initialValues?.total_weight || '',
|
||||
qty: initialValues?.qty || '',
|
||||
avg_weight: initialValues?.avg_weight || '',
|
||||
@@ -102,7 +125,7 @@ const SalesOrderProductForm = ({
|
||||
convertion_unit: initialValues?.convertion_unit || null,
|
||||
marketing_type: initialValues?.marketing_type || null,
|
||||
total_peti: initialValues?.total_peti ?? null,
|
||||
price_per_qty: initialValues?.price_per_qty ?? null,
|
||||
price_per_qty: initialPricePerQty,
|
||||
sisa_berat: initialSisaBerat,
|
||||
price_sisa_berat: initialPriceSisaBerat,
|
||||
week: initialValues?.week ?? null,
|
||||
@@ -132,11 +155,11 @@ const SalesOrderProductForm = ({
|
||||
|
||||
// ===== Options =====
|
||||
const {
|
||||
options: kandangSourceOptions,
|
||||
isLoadingOptions: isLoadingKandangSourceOptions,
|
||||
setInputValue: setKandangInputValue,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
|
||||
options: warehouseOptions,
|
||||
isLoadingOptions: isLoadingWarehouseOptions,
|
||||
setInputValue: setWarehouseSearchValue,
|
||||
loadMore: loadMoreWarehouses,
|
||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
|
||||
|
||||
// Options Week dari minggu 1 - 22
|
||||
// const optionsWeek = useMemo(() => {
|
||||
@@ -147,7 +170,6 @@ const SalesOrderProductForm = ({
|
||||
// }, []);
|
||||
|
||||
const {
|
||||
options: warehouseSourceOptions,
|
||||
rawData: warehouseSourceRawData,
|
||||
isLoadingOptions: isLoadingWarehouseSourceOptions,
|
||||
setInputValue: setWarehouseInputValue,
|
||||
@@ -156,32 +178,69 @@ const SalesOrderProductForm = ({
|
||||
ProductWarehouseApi.basePath,
|
||||
'id',
|
||||
'product.name',
|
||||
'',
|
||||
'search',
|
||||
{
|
||||
warehouse_id: formik.values.kandang_id?.toString() ?? '',
|
||||
limit: '100',
|
||||
available_only: 'true',
|
||||
warehouse_id: formik.values.warehouse_id?.toString() ?? '',
|
||||
type: formik.values.marketing_type?.value.toLocaleUpperCase() ?? '',
|
||||
}
|
||||
);
|
||||
|
||||
const productOptionsFiltered = useMemo(() => {
|
||||
return warehouseSourceOptions.filter(
|
||||
(product) =>
|
||||
!exisitingValues
|
||||
?.map((item) => item.product_warehouse_id)
|
||||
.includes(product.value)
|
||||
if (!isResponseSuccess(warehouseSourceRawData)) {
|
||||
return initialValues?.product_warehouse
|
||||
? [initialValues.product_warehouse]
|
||||
: [];
|
||||
}
|
||||
|
||||
const selectedProductIds = new Set(
|
||||
exisitingValues
|
||||
?.filter((item) => item.id !== initialValues?.id)
|
||||
.map((item) => Number(item.product_warehouse_id))
|
||||
.filter((item) => item > 0) ?? []
|
||||
);
|
||||
}, [warehouseSourceOptions, exisitingValues]);
|
||||
|
||||
const options = warehouseSourceRawData.data
|
||||
.filter((item: ProductWarehouse) => !selectedProductIds.has(item.id))
|
||||
.map((item: ProductWarehouse) => ({
|
||||
value: item.id,
|
||||
label: getProductWarehouseOptionLabel(item),
|
||||
}));
|
||||
|
||||
if (
|
||||
initialValues?.product_warehouse &&
|
||||
initialValues?.product_warehouse_id
|
||||
) {
|
||||
const exists = options.find(
|
||||
(option) =>
|
||||
Number(option.value) === Number(initialValues.product_warehouse_id)
|
||||
);
|
||||
if (!exists) {
|
||||
options.push(initialValues.product_warehouse);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [warehouseSourceRawData, exisitingValues, initialValues]);
|
||||
|
||||
// ===== Handler =====
|
||||
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('kandang', val as OptionType);
|
||||
formik.setFieldValue('kandang_id', (val as OptionType)?.value);
|
||||
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const warehouse = (val as OptionType | null) ?? null;
|
||||
|
||||
formik.setFieldValue('warehouse', warehouse);
|
||||
formik.setFieldValue('warehouse_id', warehouse?.value);
|
||||
formik.setFieldValue('kandang', warehouse);
|
||||
formik.setFieldValue('kandang_id', warehouse?.value);
|
||||
formik.setFieldValue('product_warehouse_id', null);
|
||||
formik.setFieldValue('product_warehouse', null);
|
||||
formik.setFieldValue('product_warehouse_data', null);
|
||||
formik.setFieldValue('qty', '');
|
||||
};
|
||||
|
||||
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const productWarehouseChangeHandler = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('product_warehouse', val as OptionType);
|
||||
const newId = (val as OptionType)?.value;
|
||||
formik.setFieldValue('product_warehouse_id', newId);
|
||||
@@ -191,6 +250,7 @@ const SalesOrderProductForm = ({
|
||||
(item: ProductWarehouse) => item.id === newId
|
||||
);
|
||||
setSelectedProductWarehouse(productWarehouse || null);
|
||||
formik.setFieldValue('product_warehouse_data', productWarehouse || null);
|
||||
formik.setFieldValue('qty', productWarehouse?.quantity);
|
||||
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
|
||||
if (
|
||||
@@ -204,6 +264,8 @@ const SalesOrderProductForm = ({
|
||||
}
|
||||
handleBlurField('qty');
|
||||
} else {
|
||||
setSelectedProductWarehouse(null);
|
||||
formik.setFieldValue('product_warehouse_data', null);
|
||||
formik.setFieldValue('qty', '');
|
||||
formik.setFieldValue('uom', '');
|
||||
formik.setFieldValue('week', null);
|
||||
@@ -217,9 +279,12 @@ const SalesOrderProductForm = ({
|
||||
formik.resetForm({
|
||||
values: {
|
||||
vehicle_number: '',
|
||||
warehouse_id: undefined,
|
||||
warehouse: null,
|
||||
kandang_id: undefined,
|
||||
kandang: null,
|
||||
product_warehouse: null,
|
||||
product_warehouse_data: null,
|
||||
product_warehouse_id: undefined,
|
||||
unit_price: '',
|
||||
total_weight: '',
|
||||
@@ -250,6 +315,25 @@ const SalesOrderProductForm = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty)
|
||||
const handleFieldChange = (
|
||||
field: string,
|
||||
value: number | string,
|
||||
callback?: () => void
|
||||
) => {
|
||||
formik.setFieldValue(field, value);
|
||||
|
||||
setTimeout(() => {
|
||||
handleMarketingCalculation(field, {
|
||||
values: { ...formik.values, [field]: value },
|
||||
setFieldValue: formik.setFieldValue,
|
||||
hasSisaBerat,
|
||||
});
|
||||
}, 0);
|
||||
|
||||
if (callback) callback();
|
||||
};
|
||||
|
||||
// Handler khusus untuk toggle sisa berat - langsung pakai nilai baru
|
||||
const handleSisaBeratToggle = (newHasSisaBerat: boolean) => {
|
||||
setHasSisaBerat(newHasSisaBerat);
|
||||
@@ -291,6 +375,10 @@ const SalesOrderProductForm = ({
|
||||
handleBlurField('week');
|
||||
}, [formik.values.week]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProductWarehouse(initialValues?.product_warehouse_data || null);
|
||||
}, [initialValues?.product_warehouse_data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
@@ -329,22 +417,22 @@ const SalesOrderProductForm = ({
|
||||
errorMessage={formik.errors.vehicle_number}
|
||||
/>
|
||||
|
||||
{/* Gudang */}
|
||||
{/* Gudang Fisik */}
|
||||
<SelectInputRadio
|
||||
required
|
||||
label='Gudang'
|
||||
options={kandangSourceOptions}
|
||||
isLoading={isLoadingKandangSourceOptions}
|
||||
value={formik.values.kandang}
|
||||
onChange={kandangChangeHandler}
|
||||
label='Gudang Fisik'
|
||||
options={warehouseOptions}
|
||||
isLoading={isLoadingWarehouseOptions}
|
||||
value={formik.values.warehouse}
|
||||
onChange={warehouseChangeHandler}
|
||||
isClearable
|
||||
onInputChange={setKandangInputValue}
|
||||
onMenuScrollToBottom={loadMoreKandang}
|
||||
onInputChange={setWarehouseSearchValue}
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
isError={
|
||||
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
|
||||
formik.touched.warehouse_id && Boolean(formik.errors.warehouse_id)
|
||||
}
|
||||
errorMessage={formik.errors.kandang_id}
|
||||
placeholder='Pilih Gudang'
|
||||
errorMessage={formik.errors.warehouse_id}
|
||||
placeholder='Pilih Gudang Fisik'
|
||||
/>
|
||||
|
||||
{/* Kategori */}
|
||||
@@ -355,8 +443,9 @@ const SalesOrderProductForm = ({
|
||||
value={formik.values.marketing_type}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue('marketing_type', val);
|
||||
warehouseChangeHandler(null);
|
||||
productWarehouseChangeHandler(null);
|
||||
formik.setFieldValue('product_warehouse', null);
|
||||
formik.setFieldValue('product_warehouse_data', null);
|
||||
formik.setFieldValue('product_warehouse_id', null);
|
||||
formik.setFieldValue('convertion_unit', null);
|
||||
formik.setFieldValue('weight_per_convertion', null);
|
||||
@@ -373,18 +462,18 @@ const SalesOrderProductForm = ({
|
||||
options={productOptionsFiltered}
|
||||
isLoading={isLoadingWarehouseSourceOptions}
|
||||
value={formik.values.product_warehouse}
|
||||
onChange={warehouseChangeHandler}
|
||||
onChange={productWarehouseChangeHandler}
|
||||
onInputChange={setWarehouseInputValue}
|
||||
onMenuScrollToBottom={loadMoreWarehouse}
|
||||
isClearable
|
||||
placeholder={
|
||||
formik.values.kandang_id
|
||||
formik.values.warehouse_id
|
||||
? productOptionsFiltered.length == 0
|
||||
? 'Tidak ada produk yang tersedia'
|
||||
: 'Pilih produk'
|
||||
: 'Pilih Kandang Terlebih Dahulu'
|
||||
: 'Pilih Gudang Fisik Terlebih Dahulu'
|
||||
}
|
||||
isDisabled={!formik.values.kandang_id}
|
||||
isDisabled={!formik.values.warehouse_id}
|
||||
isError={
|
||||
formik.touched.product_warehouse_id &&
|
||||
Boolean(formik.errors.product_warehouse_id)
|
||||
@@ -452,7 +541,7 @@ const SalesOrderProductForm = ({
|
||||
<input
|
||||
type='radio'
|
||||
checked={
|
||||
formik.values.convertion_unit?.value ===
|
||||
formik.values.convertion_unit?.value.toLowerCase() ===
|
||||
option.value
|
||||
}
|
||||
onChange={() => null}
|
||||
@@ -475,13 +564,13 @@ const SalesOrderProductForm = ({
|
||||
} per ${formik.values.convertion_unit?.value}`}
|
||||
value={formik.values.weight_per_convertion ?? ''}
|
||||
onChange={(e) => {
|
||||
formik.setFieldValue(
|
||||
'weight_per_convertion',
|
||||
Number(e.target.value)
|
||||
const value = Number(e.target.value)
|
||||
? Number(e.target.value)
|
||||
: '';
|
||||
handleFieldChange('weight_per_convertion', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
setCurrentInput(e.target.name);
|
||||
}}
|
||||
onBlur={() => handleBlurField('weight_per_convertion')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -519,10 +608,11 @@ const SalesOrderProductForm = ({
|
||||
name='total_peti'
|
||||
value={formik.values.total_peti ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('total_peti', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('total_peti')}
|
||||
isError={
|
||||
formik.touched.total_peti && Boolean(formik.errors.total_peti)
|
||||
}
|
||||
@@ -530,7 +620,7 @@ const SalesOrderProductForm = ({
|
||||
placeholder='Masukan Total Peti'
|
||||
endAdornment={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-base-content/50'>Kg</span>
|
||||
<span className='text-sm text-base-content/50'>Peti</span>
|
||||
</div>
|
||||
}
|
||||
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
|
||||
@@ -547,10 +637,11 @@ const SalesOrderProductForm = ({
|
||||
name='avg_weight'
|
||||
value={formik.values.avg_weight}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('avg_weight', value, () =>
|
||||
setCurrentInput('avg_weight')
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('avg_weight')}
|
||||
isError={
|
||||
formik.touched.avg_weight &&
|
||||
Boolean(formik.errors.avg_weight)
|
||||
@@ -568,16 +659,20 @@ const SalesOrderProductForm = ({
|
||||
name='total_weight'
|
||||
value={formik.values.total_weight}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('total_weight', value, () =>
|
||||
setCurrentInput('total_weight')
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('total_weight')}
|
||||
isError={
|
||||
formik.touched.total_weight &&
|
||||
Boolean(formik.errors.total_weight)
|
||||
}
|
||||
errorMessage={formik.errors.total_weight}
|
||||
placeholder='Masukan Total Bobot'
|
||||
disabled={
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -593,10 +688,11 @@ const SalesOrderProductForm = ({
|
||||
name='qty'
|
||||
value={formik.values.qty}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('qty', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('qty')}
|
||||
isError={formik.touched.qty && Boolean(formik.errors.qty)}
|
||||
errorMessage={formik.errors.qty}
|
||||
placeholder='Masukan Kuantitas'
|
||||
@@ -630,10 +726,11 @@ const SalesOrderProductForm = ({
|
||||
name='price_per_convertion'
|
||||
value={formik.values.price_per_convertion ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('price_per_convertion', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('price_per_convertion')}
|
||||
isError={
|
||||
formik.touched.price_per_convertion &&
|
||||
Boolean(formik.errors.price_per_convertion)
|
||||
@@ -643,46 +740,48 @@ const SalesOrderProductForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga per butir untuk TELUR + QTY */}
|
||||
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga / Butir (Rp)'
|
||||
name='price_per_qty'
|
||||
value={formik.values.price_per_qty ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.setFieldValue('price_per_qty', Number(e.target.value));
|
||||
setCurrentInput('price_per_qty');
|
||||
}}
|
||||
onBlur={() => handleBlurField('price_per_qty')}
|
||||
isError={
|
||||
formik.touched.price_per_qty &&
|
||||
Boolean(formik.errors.price_per_qty)
|
||||
}
|
||||
errorMessage={formik.errors.price_per_qty}
|
||||
placeholder='Masukan Harga per Butir'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga Satuan per Uom Produk Warehouse */}
|
||||
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
|
||||
<NumberInput
|
||||
required
|
||||
label={`Harga / ${formik.values.convertion_unit?.label !== 'qty' ? 'Kg' : (selectedProductWarehouse?.product?.uom?.name ?? 'Produk')} (Rp)`}
|
||||
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
|
||||
name='unit_price'
|
||||
value={formik.values.unit_price}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('unit_price', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('unit_price')}
|
||||
isError={
|
||||
formik.touched.unit_price && Boolean(formik.errors.unit_price)
|
||||
}
|
||||
errorMessage={formik.errors.unit_price}
|
||||
placeholder='Masukan Harga Satuan'
|
||||
placeholder='Masukan Harga Satuan...'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga per kg untuk TELUR + QTY */}
|
||||
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga / Kg (Rp)'
|
||||
name='price_per_qty'
|
||||
value={formik.values.price_per_qty ?? undefined}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('price_per_qty', value, () =>
|
||||
setCurrentInput('price_per_qty')
|
||||
);
|
||||
}}
|
||||
isError={
|
||||
formik.touched.price_per_qty &&
|
||||
Boolean(formik.errors.price_per_qty)
|
||||
}
|
||||
errorMessage={formik.errors.price_per_qty}
|
||||
placeholder='Masukan Harga per Kg'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -715,10 +814,11 @@ const SalesOrderProductForm = ({
|
||||
name='sisa_berat'
|
||||
value={formik.values.sisa_berat ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('sisa_berat', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('sisa_berat')}
|
||||
isError={
|
||||
formik.touched.sisa_berat && Boolean(formik.errors.sisa_berat)
|
||||
}
|
||||
@@ -731,10 +831,11 @@ const SalesOrderProductForm = ({
|
||||
name='price_sisa_berat'
|
||||
value={formik.values.price_sisa_berat ?? undefined}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('price_sisa_berat', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('price_sisa_berat')}
|
||||
isError={
|
||||
formik.touched.price_sisa_berat &&
|
||||
Boolean(formik.errors.price_sisa_berat)
|
||||
@@ -752,10 +853,11 @@ const SalesOrderProductForm = ({
|
||||
name='total_price'
|
||||
value={formik.values.total_price}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('total_price', value, () =>
|
||||
setCurrentInput('total_price')
|
||||
);
|
||||
}}
|
||||
onBlur={() => handleBlurField('total_price')}
|
||||
isError={
|
||||
formik.touched.total_price && Boolean(formik.errors.total_price)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/for
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
|
||||
import { Marketing } from '@/types/api/marketing/marketing';
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { DeliveryProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
|
||||
|
||||
type DeliveryOrderProductTableProps = {
|
||||
data: DeliveryOrderProductFormValues[];
|
||||
@@ -42,7 +44,34 @@ const DeliveryOrderProductTable = ({
|
||||
|
||||
const approvalStepNumber = marketing?.latest_approval?.step_number;
|
||||
|
||||
const renderTableContent = (item: DeliveryOrderProductFormValues) => {
|
||||
const hasDeliveryOrder = useMemo(() => {
|
||||
return (
|
||||
marketing?.delivery_order &&
|
||||
marketing.delivery_order.length > 0 &&
|
||||
marketing.delivery_order.some(
|
||||
(doItem) => doItem.deliveries && doItem.deliveries.length > 0
|
||||
)
|
||||
);
|
||||
}, [marketing?.delivery_order]);
|
||||
|
||||
const deliveryItems = useMemo(() => {
|
||||
if (!hasDeliveryOrder) return [];
|
||||
|
||||
return (
|
||||
marketing?.delivery_order?.flatMap((doItem) =>
|
||||
DeliveryProductToFieldValues(marketing?.sales_order, doItem).map(
|
||||
(delivery) => ({
|
||||
...delivery,
|
||||
do_number: doItem.do_number,
|
||||
delivery_date: doItem.delivery_date,
|
||||
warehouse: doItem.warehouse,
|
||||
})
|
||||
)
|
||||
) ?? []
|
||||
);
|
||||
}, [marketing?.delivery_order, hasDeliveryOrder]);
|
||||
|
||||
const renderSalesOrderContent = (item: DeliveryOrderProductFormValues) => {
|
||||
const doItem = marketing?.delivery_order?.find(
|
||||
(doItem) => doItem.do_number === item.do_number
|
||||
);
|
||||
@@ -56,7 +85,7 @@ const DeliveryOrderProductTable = ({
|
||||
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
|
||||
<div>Value</div>
|
||||
{formType !== 'success' &&
|
||||
{/* {formType !== 'success' &&
|
||||
(formType === 'add_delivery' ||
|
||||
formType === 'edit_delivery' ||
|
||||
formType === 'detail') && (
|
||||
@@ -73,15 +102,16 @@ const DeliveryOrderProductTable = ({
|
||||
<Icon icon='heroicons:pencil' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Gudang</td>
|
||||
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{doItem?.warehouse?.name ||
|
||||
item.marketing_product?.warehouse?.label ||
|
||||
item.marketing_product?.product_warehouse_data?.warehouse?.name}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -111,12 +141,15 @@ const DeliveryOrderProductTable = ({
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Total Bobot</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatNumber(Number(item.total_weight))}
|
||||
{formatNumber(Number(item.total_weight))} Kg
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
Total Harga Satuan
|
||||
{item.convertion_unit?.label.toLowerCase() === 'peti' && ' (Kg)'}
|
||||
</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(parseFloat(item.unit_price as string))}
|
||||
</td>
|
||||
@@ -185,50 +218,240 @@ const DeliveryOrderProductTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderDeliveryOrderContent = (
|
||||
item: DeliveryOrderProductFormValues & {
|
||||
do_number: string;
|
||||
delivery_date: string;
|
||||
warehouse: Warehouse;
|
||||
}
|
||||
) => {
|
||||
const parentDoItem = marketing?.delivery_order?.find(
|
||||
(doItem) => doItem.do_number === item.do_number
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className='border-b border-tools-table-outline border-base-content/5'>
|
||||
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||
Label
|
||||
</th>
|
||||
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
|
||||
<div>Value</div>
|
||||
{formType !== 'success' &&
|
||||
(formType === 'add_delivery' ||
|
||||
formType === 'edit_delivery' ||
|
||||
formType === 'detail') && (
|
||||
<div className='flex flex-row gap-1.5 items-center'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
onEditRef.current(item.id as number, item);
|
||||
}}
|
||||
className='p-0 hover:text-base-content'
|
||||
>
|
||||
<Icon icon='heroicons:pencil' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
|
||||
<td className='text-sm px-4 py-3'>{item.warehouse?.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Produk</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.marketing_product?.product_warehouse_data?.product.name}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Qty</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.qty
|
||||
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{Number(item.avg_weight ?? 0) > 0 && (
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Avg Bobot</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatNumber(Number(item.avg_weight))} Kg
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{Number(item.total_weight ?? 0) > 0 && (
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Total Bobot</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatNumber(Number(item.total_weight))}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(Number(item.unit_price))}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Total Penjualan</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(Number(item.total_price))}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
<tr className='border-b border-t border-tools-table-outline border-base-content/5'>
|
||||
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||
Label
|
||||
</th>
|
||||
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
|
||||
<div>Value</div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<>
|
||||
{approvalStepNumber !== 1 && (
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Tanggal Pengiriman</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.delivery_date
|
||||
? formatDate(item.delivery_date, 'DD MMM YYYY')
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{item.do_number && (
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>No. Pengiriman</td>
|
||||
<td className='text-sm px-4 py-3'>{item.do_number}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>No. Polisi</td>
|
||||
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
|
||||
</tr>
|
||||
{parentDoItem && (
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Dokumen Pengiriman</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
<DeliveryOrderExport
|
||||
data={marketing}
|
||||
deliveryOrder={parentDoItem}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
|
||||
{data.map((item) => (
|
||||
<div key={`table-${item.id}`}>
|
||||
{formType === 'success' ? (
|
||||
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
|
||||
<table
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
className='border-none w-full'
|
||||
>
|
||||
<tbody className='w-full'>{renderTableContent(item)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
key={`table-${item.id}`}
|
||||
title={
|
||||
item.marketing_product?.product_warehouse?.label || 'Produk'
|
||||
}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
variant='bordered'
|
||||
className={{
|
||||
wrapper: 'w-full rounded-lg',
|
||||
body: 'p-0',
|
||||
title: 'px-2 py-1.5 font-normal text-sm',
|
||||
collapsible: 'rounded-lg',
|
||||
}}
|
||||
{hasDeliveryOrder
|
||||
? deliveryItems.map((item, index) => (
|
||||
<div
|
||||
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
className='border-none w-full'
|
||||
>
|
||||
<tbody className='w-full'>{renderTableContent(item)}</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{formType === 'success' ? (
|
||||
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
|
||||
<table
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
className='border-none w-full'
|
||||
>
|
||||
<tbody className='w-full'>
|
||||
{renderDeliveryOrderContent(item)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
|
||||
title={
|
||||
item.marketing_product?.product_warehouse_data?.product
|
||||
.name || 'Produk'
|
||||
}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
variant='bordered'
|
||||
className={{
|
||||
wrapper: 'w-full rounded-lg',
|
||||
body: 'p-0',
|
||||
title: 'px-2 py-1.5 font-normal text-sm',
|
||||
collapsible: 'rounded-lg',
|
||||
}}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
className='border-none w-full'
|
||||
>
|
||||
<tbody className='w-full'>
|
||||
{renderDeliveryOrderContent(item)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
: data.map((item) => (
|
||||
<div key={`table-${item.id}`}>
|
||||
{formType === 'success' ? (
|
||||
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
|
||||
<table
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
className='border-none w-full'
|
||||
>
|
||||
<tbody className='w-full'>
|
||||
{renderSalesOrderContent(item)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
key={`table-${item.id}`}
|
||||
title={
|
||||
item.marketing_product?.product_warehouse?.label ||
|
||||
'Produk'
|
||||
}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
variant='bordered'
|
||||
className={{
|
||||
wrapper: 'w-full rounded-lg',
|
||||
body: 'p-0',
|
||||
title: 'px-2 py-1.5 font-normal text-sm',
|
||||
collapsible: 'rounded-lg',
|
||||
}}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
className='border-none w-full'
|
||||
>
|
||||
<tbody className='w-full'>
|
||||
{renderSalesOrderContent(item)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -73,8 +73,10 @@ const SalesOrderProductTable = ({
|
||||
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Gudang</td>
|
||||
<td className='text-sm px-4 py-3'>{item.kandang?.label}</td>
|
||||
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.warehouse?.label ?? item.kandang?.label}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Kategori</td>
|
||||
@@ -135,8 +137,22 @@ const SalesOrderProductTable = ({
|
||||
{`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`}
|
||||
</td>
|
||||
</tr>
|
||||
{item.convertion_unit?.value.toLowerCase() === 'peti' && (
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Harga Satuan Per Peti</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(
|
||||
parseFloat(item.unit_price as string) *
|
||||
parseFloat(String(item.weight_per_convertion))
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Harga Satuan</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
Harga Satuan
|
||||
{item.convertion_unit?.value.toLowerCase() === 'peti' && ' (Kg)'}
|
||||
</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(parseFloat(item.unit_price as string))}
|
||||
</td>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Button from '@/components/Button';
|
||||
import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
|
||||
import { Document, Page, pdf, Text, View } from '@react-pdf/renderer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
|
||||
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
|
||||
@@ -101,8 +101,8 @@ const PDFDocument = ({
|
||||
<View style={pdfStyles.header}>
|
||||
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
|
||||
<Text style={pdfStyles.address}>
|
||||
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
|
||||
Cipedes, Kec. Sukajadi, Kota Bandung 40162
|
||||
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
|
||||
Bandung Barat, Jawa Barat 40514
|
||||
</Text>
|
||||
<View style={pdfStyles.divider} />
|
||||
</View>
|
||||
|
||||
@@ -87,8 +87,8 @@ const PDFDocument = ({ data }: { data: Marketing }) => {
|
||||
<View style={pdfStyles.header}>
|
||||
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
|
||||
<Text style={pdfStyles.address}>
|
||||
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
|
||||
Cipedes, Kec. Sukajadi, Kota Bandung 40162
|
||||
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
|
||||
Bandung Barat, Jawa Barat 40514
|
||||
</Text>
|
||||
<View style={pdfStyles.divider} />
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Marketing } from '@/types/api/marketing/marketing';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const MarketingTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no marketing data displayed. Enter marketing data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Marketing>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketingTableSkeleton;
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -11,75 +11,101 @@ 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 RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import AreaTableSkeleton from '@/components/pages/master-data/area/skeleton/AreaTableSkeleton';
|
||||
|
||||
import { Area } from '@/types/api/master-data/area';
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Area, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `area#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-area#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.master.area.detail'>
|
||||
<Button
|
||||
href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<RequirePermission permissions='lti.master.area.update'>
|
||||
<Button
|
||||
href={`/master-data/area/detail/edit/?areaId=${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>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.master.area.delete'>
|
||||
<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>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.master.area.detail'>
|
||||
<Button
|
||||
href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.area.update'>
|
||||
<Button
|
||||
href={`/master-data/area/detail/edit/?areaId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.area.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||
>
|
||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AreasTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -87,10 +113,17 @@ const AreasTable = () => {
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '' },
|
||||
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
|
||||
initial: {
|
||||
search: searchValue,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
data: areas,
|
||||
isLoading,
|
||||
@@ -101,65 +134,21 @@ const AreasTable = () => {
|
||||
);
|
||||
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const areasColumns: ColumnDef<Area>[] = [
|
||||
{
|
||||
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;
|
||||
useEffect(() => {
|
||||
setTableState('areas-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedArea(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu
|
||||
type='dropdown'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu
|
||||
type='collapse'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
@@ -179,95 +168,132 @@ const AreasTable = () => {
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
const areasColumns: ColumnDef<Area>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Area, unknown>) => {
|
||||
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 pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
setPageSize(newVal.value as number);
|
||||
};
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedArea(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
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='w-full flex flex-row'>
|
||||
<div className='w-full flex flex-row'>
|
||||
<RequirePermission permissions='lti.master.area.create'>
|
||||
<Button
|
||||
href='/master-data/area/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Area'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.master.area.create'>
|
||||
<Button
|
||||
href='/master-data/area/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Area
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-end'>
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
{/* Search */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
className={{ wrapper: 'max-w-28' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<Area>
|
||||
data={isResponseSuccess(areas) ? areas?.data : []}
|
||||
columns={areasColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(areas) ? areas?.meta?.page : 0}
|
||||
totalItems={isResponseSuccess(areas) ? areas?.meta?.total_results : 0}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20': isResponseSuccess(areas) && areas?.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',
|
||||
}}
|
||||
/>
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(areas) || areas.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<AreaTableSkeleton
|
||||
columns={areasColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Area>
|
||||
data={isResponseSuccess(areas) ? areas?.data : []}
|
||||
columns={areasColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(areas) ? areas?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(areas) ? areas?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: 'p-3 mb-0',
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Area } from '@/types/api/master-data/area';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const AreaTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no area data displayed. Enter area data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Area>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreaTableSkeleton;
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -11,75 +11,101 @@ 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 RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import BankTableSkeleton from '@/components/pages/master-data/bank/skeleton/BankTableSkeleton';
|
||||
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { BankApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Bank, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `bank#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-bank#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.master.banks.detail'>
|
||||
<Button
|
||||
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<RequirePermission permissions='lti.master.banks.update'>
|
||||
<Button
|
||||
href={`/master-data/bank/detail/edit/?bankId=${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>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.master.banks.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.master.banks.detail'>
|
||||
<Button
|
||||
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.banks.update'>
|
||||
<Button
|
||||
href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.banks.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||
>
|
||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BanksTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -87,10 +113,17 @@ const BanksTable = () => {
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '' },
|
||||
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
|
||||
initial: {
|
||||
search: searchValue,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
data: banks,
|
||||
isLoading,
|
||||
@@ -101,78 +134,21 @@ const BanksTable = () => {
|
||||
);
|
||||
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const banksColumns: ColumnDef<Bank>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'alias',
|
||||
header: 'Alias',
|
||||
},
|
||||
{
|
||||
accessorKey: 'account_number',
|
||||
header: 'No. Rekening',
|
||||
},
|
||||
{
|
||||
accessorKey: 'owner',
|
||||
header: 'Pemilik',
|
||||
cell: (props) => (props.getValue() ? props.getValue() : '-'),
|
||||
},
|
||||
{
|
||||
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;
|
||||
useEffect(() => {
|
||||
setTableState('banks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedBank(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu
|
||||
type='dropdown'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu
|
||||
type='collapse'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
@@ -192,93 +168,145 @@ const BanksTable = () => {
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
const banksColumns: ColumnDef<Bank>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'alias',
|
||||
header: 'Alias',
|
||||
},
|
||||
{
|
||||
accessorKey: 'account_number',
|
||||
header: 'No. Rekening',
|
||||
},
|
||||
{
|
||||
accessorKey: 'owner',
|
||||
header: 'Pemilik',
|
||||
cell: (props) => props.getValue() || '-',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Bank, unknown>) => {
|
||||
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 pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
setPageSize(newVal.value as number);
|
||||
};
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedBank(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
// track sorting
|
||||
useEffect(() => {
|
||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
|
||||
if (!isNameSorted) {
|
||||
updateFilter('nameSort', '');
|
||||
} else {
|
||||
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||
}
|
||||
}, [sorting]);
|
||||
return (
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
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='w-full flex flex-row'>
|
||||
<RequirePermission permissions='lti.master.banks.create'>
|
||||
<Button
|
||||
href='/master-data/bank/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Bank'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.master.banks.create'>
|
||||
<Button
|
||||
href='/master-data/bank/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Bank
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-end'>
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
{/* Search */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
className={{ wrapper: 'max-w-28' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<Bank>
|
||||
data={isResponseSuccess(banks) ? banks?.data : []}
|
||||
columns={banksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(banks) ? banks?.meta?.page : 0}
|
||||
totalItems={isResponseSuccess(banks) ? banks?.meta?.total_results : 0}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20': isResponseSuccess(banks) && banks?.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',
|
||||
}}
|
||||
/>
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(banks) || banks.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<BankTableSkeleton
|
||||
columns={banksColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Bank>
|
||||
data={isResponseSuccess(banks) ? banks?.data : []}
|
||||
columns={banksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(banks) ? banks?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(banks) ? banks?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: 'p-3 mb-0',
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const BankTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no bank data displayed. Enter bank data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Bank>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BankTableSkeleton;
|
||||
@@ -1,81 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import { ChangeEventHandler, useEffect, useMemo, 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 SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import Button from '@/components/Button';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import Table from '@/components/Table';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import CustomerTableSkeleton from '@/components/pages/master-data/customer/skeleton/CustomerTableSkeleton';
|
||||
|
||||
import { Customer } from '@/types/api/master-data/customer';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Customer, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `customer#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-customer#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.master.customer.detail'>
|
||||
<Button
|
||||
href={`/master-data/customer/detail/?customerId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.customer.update'>
|
||||
<Button
|
||||
href={`/master-data/customer/detail/edit/?customerId=${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>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.customer.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.master.customer.detail'>
|
||||
<Button
|
||||
href={`/master-data/customer/detail/?customerId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.customer.update'>
|
||||
<Button
|
||||
href={`/master-data/customer/detail/edit/?customerId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.customer.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||
>
|
||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomersTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -83,16 +113,17 @@ const CustomersTable = () => {
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '', picSort: '' },
|
||||
initial: {
|
||||
search: searchValue,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
picSort: 'sort_pic',
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch Data
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
data: customers,
|
||||
isLoading,
|
||||
@@ -102,87 +133,25 @@ const CustomersTable = () => {
|
||||
CustomerApi.getAllFetcher
|
||||
);
|
||||
|
||||
// State
|
||||
const deleteModal = useModal();
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<
|
||||
Customer | undefined
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
// Columns Definition
|
||||
const customersColumns: ColumnDef<Customer>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'pic',
|
||||
header: 'PIC',
|
||||
cell: (props) => props.row.original.pic.name,
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
cell: (props) => props.row.original.type,
|
||||
},
|
||||
{
|
||||
accessorKey: 'phone',
|
||||
header: 'Phone',
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
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;
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
useEffect(() => {
|
||||
setTableState('customers-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedCustomer(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu
|
||||
type='dropdown'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu
|
||||
type='collapse'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Handler
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
@@ -202,81 +171,147 @@ const CustomersTable = () => {
|
||||
toast.success('Successfully delete Customer!');
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
setPageSize(newVal.value as number);
|
||||
};
|
||||
|
||||
const customersColumns: ColumnDef<Customer>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.pic?.name ?? '-',
|
||||
header: 'PIC',
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
},
|
||||
{
|
||||
accessorKey: 'phone',
|
||||
header: 'Phone',
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Customer, unknown>) => {
|
||||
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 = () => {
|
||||
setSelectedCustomer(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
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='w-full flex flex-row'>
|
||||
<RequirePermission permissions='lti.master.customer.create'>
|
||||
<Button
|
||||
href='/master-data/customer/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Kandang'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.master.customer.create'>
|
||||
<Button
|
||||
href='/master-data/customer/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Customer
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-end'>
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
{/* Search */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
className={{ wrapper: 'max-w-28' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<Customer>
|
||||
data={isResponseSuccess(customers) ? customers?.data : []}
|
||||
columns={customersColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(customers) ? customers?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(customers) ? customers?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(customers) && customers?.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',
|
||||
}}
|
||||
/>
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(customers) || customers.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<CustomerTableSkeleton
|
||||
columns={customersColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Customer>
|
||||
data={customers?.data}
|
||||
columns={customersColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={customers?.meta?.page ?? 0}
|
||||
totalItems={customers?.meta?.total_results ?? 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={false}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: 'p-3 mb-0',
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Customer } from '@/types/api/master-data/customer';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const CustomerTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no customer data displayed. Enter customer data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Customer>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerTableSkeleton;
|
||||
@@ -1,91 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { Flock } from '@/types/api/master-data/flock';
|
||||
import { cn } from '@/lib/helper';
|
||||
import Button from '@/components/Button';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useState } from 'react';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { FlockApi } from '@/services/api/master-data';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import Table from '@/components/Table';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
|
||||
const RowsOptions = ({
|
||||
type = 'dropdown',
|
||||
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 RequirePermission from '@/components/helper/RequirePermission';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import FlockTableSkeleton from '@/components/pages/master-data/flock/skeleton/FlockTableSkeleton';
|
||||
|
||||
import { Flock } from '@/types/api/master-data/flock';
|
||||
import { FlockApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Flock, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `flock#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-flock#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.master.flocks.detail'>
|
||||
<Button
|
||||
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon
|
||||
icon='mdi:eye-outline'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.flocks.update'>
|
||||
<Button
|
||||
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.flocks.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.master.flocks.detail'>
|
||||
<Button
|
||||
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.flocks.update'>
|
||||
<Button
|
||||
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.flocks.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||
>
|
||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FlockTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -93,15 +113,17 @@ const FlockTable = () => {
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '' },
|
||||
initial: {
|
||||
search: searchValue,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch Data
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
data: flocks,
|
||||
isLoading,
|
||||
@@ -111,74 +133,25 @@ const FlockTable = () => {
|
||||
FlockApi.getAllFetcher
|
||||
);
|
||||
|
||||
// State
|
||||
const deleteModal = useModal();
|
||||
const [selectedFlock, setSelectedFlock] = useState<Flock | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
// Columns Definition
|
||||
const flocksColumns: ColumnDef<Flock>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Dibuat pada',
|
||||
cell: (props) =>
|
||||
new Date(props.row.original.created_at).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
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;
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
useEffect(() => {
|
||||
setTableState('flocks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedFlock(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowsOptions
|
||||
type='dropdown'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowsOptions
|
||||
type='collapse'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Handler
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
@@ -196,85 +169,143 @@ const FlockTable = () => {
|
||||
toast.success('Successfully delete Flock!');
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
setPageSize(newVal.value as number);
|
||||
};
|
||||
|
||||
const flocksColumns: ColumnDef<Flock>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Dibuat pada',
|
||||
cell: (props) =>
|
||||
new Date(props.row.original.created_at).toLocaleDateString('id-ID'),
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Flock, unknown>) => {
|
||||
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 = () => {
|
||||
setSelectedFlock(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
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='w-full flex flex-row'>
|
||||
<RequirePermission permissions='lti.master.flocks.create'>
|
||||
<Button
|
||||
href='/master-data/flock/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Flock'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.master.flocks.create'>
|
||||
<Button
|
||||
href='/master-data/flock/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Flock
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-end'>
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
{/* Search */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
className={{ wrapper: 'max-w-28' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<Flock>
|
||||
data={isResponseSuccess(flocks) ? flocks?.data : []}
|
||||
columns={flocksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(flocks) ? flocks?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(flocks) ? flocks?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20': isResponseSuccess(flocks) && flocks?.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',
|
||||
}}
|
||||
/>
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(flocks) || flocks.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<FlockTableSkeleton
|
||||
columns={flocksColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Flock>
|
||||
data={flocks?.data}
|
||||
columns={flocksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={flocks?.meta?.page ?? 0}
|
||||
totalItems={flocks?.meta?.total_results ?? 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={false}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: 'p-3 mb-0',
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Supplier ini (${selectedFlock?.name})?`}
|
||||
text={`Apakah anda yakin ingin menghapus data Flock ini (${selectedFlock?.name})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Flock } from '@/types/api/master-data/flock';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const FlockTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no flock data displayed. Enter flock data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Flock>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlockTableSkeleton;
|
||||
@@ -1,90 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
ColumnSort,
|
||||
SortingState,
|
||||
} from '@tanstack/react-table';
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useFormik } from 'formik';
|
||||
|
||||
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 Modal, { 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 RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import KandangTableSkeleton from '@/components/pages/master-data/kandang/skeleton/KandangTableSkeleton';
|
||||
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { KandangApi } from '@/services/api/master-data';
|
||||
import { cn, formatNumber } from '@/lib/helper';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
import { UserApi } from '@/services/api/user';
|
||||
import { User } from '@/types/api/api-general';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
KandangFilterSchema,
|
||||
KandangFilterType,
|
||||
} from '@/components/pages/master-data/kandang/filter/KandangFilter';
|
||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Kandang, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `kandang#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-kandang#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.master.kandangs.detail'>
|
||||
<Button
|
||||
href={`/master-data/kandang/detail/?kandangId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<RequirePermission permissions='lti.master.kandangs.update'>
|
||||
<Button
|
||||
href={`/master-data/kandang/detail/edit/?kandangId=${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>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.master.kandangs.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.master.kandangs.detail'>
|
||||
<Button
|
||||
href={`/master-data/kandang/detail/?kandangId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.kandangs.update'>
|
||||
<Button
|
||||
href={`/master-data/kandang/detail/edit/?kandangId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.kandangs.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||
>
|
||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const KandangsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -94,21 +134,112 @@ const KandangsTable = () => {
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
nameSort: '',
|
||||
locationSort: '',
|
||||
capacitySort: '',
|
||||
picSort: '',
|
||||
locationFilter: '',
|
||||
picFilter: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
locationSort: 'sort_location',
|
||||
capacitySort: 'sort_capacity',
|
||||
picSort: ' sort_pic',
|
||||
locationFilter: 'location_id',
|
||||
picFilter: 'pic_id',
|
||||
},
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<KandangFilterType>({
|
||||
initialValues: {
|
||||
location_id: null,
|
||||
pic_id: null,
|
||||
},
|
||||
validationSchema: KandangFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('locationFilter', values.location_id || '');
|
||||
updateFilter('picFilter', values.pic_id || '');
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('locationFilter', '');
|
||||
updateFilter('picFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
// ===== LOCATION OPTIONS =====
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(
|
||||
filterModal.open ? LocationApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search'
|
||||
);
|
||||
|
||||
// ===== PIC OPTIONS =====
|
||||
const {
|
||||
setInputValue: setPicInputValue,
|
||||
options: picOptions,
|
||||
isLoadingOptions: isLoadingPicOptions,
|
||||
loadMore: loadMorePics,
|
||||
} = useSelect<User>(
|
||||
filterModal.open ? UserApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search'
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterLocationChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const location = val as OptionType | null;
|
||||
const locationId = location?.value ? String(location.value) : null;
|
||||
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterPicChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const pic = val as OptionType | null;
|
||||
const picId = pic?.value ? String(pic.value) : null;
|
||||
|
||||
formik.setFieldValue('pic_id', picId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const locationIdValue = useMemo(() => {
|
||||
if (!formik.values.location_id) return null;
|
||||
return (
|
||||
locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.location_id, locationOptions]);
|
||||
|
||||
const picIdValue = useMemo(() => {
|
||||
if (!formik.values.pic_id) return null;
|
||||
return (
|
||||
picOptions.find((opt) => String(opt.value) === formik.values.pic_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.pic_id, picOptions]);
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
data: kandangs,
|
||||
isLoading,
|
||||
@@ -119,82 +250,23 @@ const KandangsTable = () => {
|
||||
);
|
||||
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [selectedKandang, setSelectedKandang] = useState<Kandang | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const kandangsColumns: ColumnDef<Kandang>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'location',
|
||||
header: 'Lokasi',
|
||||
cell: (props) => props.row.original.location.name,
|
||||
},
|
||||
{
|
||||
accessorKey: 'capacity',
|
||||
header: 'Kapasitas',
|
||||
cell: (props) => formatNumber(props.row.original.capacity ?? 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'pic',
|
||||
header: 'PIC',
|
||||
cell: (props) => props.row.original.pic.name,
|
||||
},
|
||||
{
|
||||
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;
|
||||
useEffect(() => {
|
||||
setTableState('kandangs-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedKandang(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu
|
||||
type='dropdown'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu
|
||||
type='collapse'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
@@ -216,114 +288,154 @@ const KandangsTable = () => {
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
const kandangsColumns: ColumnDef<Kandang>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.location?.name ?? '-',
|
||||
header: 'Lokasi',
|
||||
},
|
||||
{
|
||||
accessorKey: 'capacity',
|
||||
header: 'Kapasitas',
|
||||
cell: (props) => formatNumber(props.row.original.capacity ?? 0),
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.pic?.name ?? '-',
|
||||
header: 'PIC',
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.kandang_group?.name ?? '-',
|
||||
header: 'Kandang Group',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Kandang, unknown>) => {
|
||||
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 pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
setPageSize(newVal.value as number);
|
||||
};
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedKandang(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
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]
|
||||
return (
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
// track sorting
|
||||
useEffect(() => {
|
||||
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
const locationSortFilter = sorting.find(
|
||||
(sortItem) => sortItem.id === 'location'
|
||||
);
|
||||
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
|
||||
|
||||
updateSortingFilter('nameSort', nameSortFilter);
|
||||
updateSortingFilter('locationSort', locationSortFilter);
|
||||
updateSortingFilter('picSort', picSortFilter);
|
||||
}, [sorting, updateSortingFilter]);
|
||||
|
||||
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='w-full flex flex-row'>
|
||||
<div className='w-full flex flex-row'>
|
||||
<RequirePermission permissions='lti.master.kandangs.create'>
|
||||
<Button
|
||||
href='/master-data/kandang/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Kandang'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.master.kandangs.create'>
|
||||
<Button
|
||||
href='/master-data/kandang/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Kandang
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-end'>
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
{/* Search and Filter */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
className={{ wrapper: 'max-w-28' }}
|
||||
/>
|
||||
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize', 'search']}
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
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 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',
|
||||
}}
|
||||
/>
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(kandangs) || kandangs.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<KandangTableSkeleton
|
||||
columns={kandangsColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Kandang>
|
||||
data={kandangs?.data}
|
||||
columns={kandangsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={kandangs?.meta?.page ?? 0}
|
||||
totalItems={kandangs?.meta?.total_results ?? 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={false}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: 'p-3 mb-0',
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
@@ -340,6 +452,81 @@ const KandangsTable = () => {
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<Modal
|
||||
ref={filterModal.ref}
|
||||
className={{
|
||||
modal: 'p-0',
|
||||
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={filterModal.closeModal}
|
||||
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationIdValue}
|
||||
onChange={handleFilterLocationChange}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInputRadio
|
||||
label='PIC'
|
||||
placeholder='Pilih PIC'
|
||||
options={picOptions}
|
||||
value={picIdValue}
|
||||
onChange={handleFilterPicChange}
|
||||
onInputChange={setPicInputValue}
|
||||
isLoading={isLoadingPicOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMorePics}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { string, object } from 'yup';
|
||||
|
||||
export const KandangFilterSchema = object().shape({
|
||||
location_id: string().nullable(),
|
||||
pic_id: string().nullable(),
|
||||
});
|
||||
|
||||
export type KandangFilterType = {
|
||||
location_id: string | null;
|
||||
pic_id: string | null;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
type KandangFormSchemaType = {
|
||||
@@ -19,6 +20,7 @@ type KandangFormSchemaType = {
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
group?: OptionType;
|
||||
};
|
||||
|
||||
export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
|
||||
@@ -42,6 +44,11 @@ export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
|
||||
group: Yup.object({
|
||||
value: Yup.number().min(1).required('Kandang Grup wajib diisi!'),
|
||||
label: Yup.string().required('Kandang Grup wajib diisi!'),
|
||||
}).required('Kandang Grup wajib diisi!'),
|
||||
});
|
||||
|
||||
export const UpdateKandangFormSchema = KandangFormSchema;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useFormik } from 'formik';
|
||||
import { getIn, useFormik } from 'formik';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -34,6 +34,8 @@ import NumberInput from '@/components/input/NumberInput';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { User } from '@/types/api/api-general';
|
||||
import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
|
||||
interface KandangFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -96,6 +98,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
label: initialValues.pic.name,
|
||||
}
|
||||
: null,
|
||||
group: initialValues?.kandang_group
|
||||
? {
|
||||
value: initialValues.kandang_group.id,
|
||||
label: initialValues.kandang_group.name,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
@@ -111,6 +119,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
location_id: values.locationId!,
|
||||
capacity: values.capacity ? parseInt(values.capacity.toString()) : 0,
|
||||
pic_id: values.picId!,
|
||||
group_id: values.group?.value as number,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
@@ -162,6 +171,23 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
formik.setFieldValue('picId', (val as OptionType)?.value);
|
||||
};
|
||||
|
||||
// Kandang Group
|
||||
const {
|
||||
setInputValue: setKandangGroupSelectInputValue,
|
||||
options: kandangGroupOptions,
|
||||
isLoadingOptions: isLoadingKandangGroupOptions,
|
||||
loadMore: loadMoreKandangGroups,
|
||||
} = useSelect<DailyChecklistKandang>(
|
||||
DailyChecklistKandangApi.basePath,
|
||||
'id',
|
||||
'name'
|
||||
);
|
||||
|
||||
const kandangGroupChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('group', true);
|
||||
formik.setFieldValue('group', val);
|
||||
};
|
||||
|
||||
const deleteKandangClickHandler = () => {
|
||||
deleteModal.openModal();
|
||||
};
|
||||
@@ -269,6 +295,24 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
required
|
||||
label='Kandang Group'
|
||||
value={formik.values.group ?? undefined}
|
||||
onChange={kandangGroupChangeHandler}
|
||||
options={kandangGroupOptions}
|
||||
onInputChange={setKandangGroupSelectInputValue}
|
||||
onMenuScrollToBottom={loadMoreKandangGroups}
|
||||
isLoading={isLoadingKandangGroupOptions}
|
||||
isError={formik.touched.group && Boolean(formik.errors.group)}
|
||||
errorMessage={
|
||||
getIn(formik.errors.group, 'value') ??
|
||||
(formik.errors.group as string)
|
||||
}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const KandangTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no kandang data displayed. Enter kandang data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Kandang>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KandangTableSkeleton;
|
||||
@@ -1,90 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
ColumnSort,
|
||||
SortingState,
|
||||
} from '@tanstack/react-table';
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useFormik } from 'formik';
|
||||
|
||||
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 Modal, { 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 RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import LocationTableSkeleton from '@/components/pages/master-data/location/skeleton/LocationTableSkeleton';
|
||||
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { Area } from '@/types/api/master-data/area';
|
||||
import { LocationApi, AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
LocationFilterSchema,
|
||||
LocationFilterType,
|
||||
} from '@/components/pages/master-data/location/filter/LocationFilter';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Location, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `location#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-location#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.master.locations.detail'>
|
||||
<Button
|
||||
href={`/master-data/location/detail/?locationId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<RequirePermission permissions='lti.master.locations.update'>
|
||||
<Button
|
||||
href={`/master-data/location/detail/edit/?locationId=${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>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.master.locations.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.master.locations.detail'>
|
||||
<Button
|
||||
href={`/master-data/location/detail/?locationId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.locations.update'>
|
||||
<Button
|
||||
href={`/master-data/location/detail/edit/?locationId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.locations.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||
>
|
||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LocationsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -92,16 +128,75 @@ const LocationsTable = () => {
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '', addressSort: '', areaSort: '' },
|
||||
initial: {
|
||||
search: '',
|
||||
areaFilter: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
addressSort: 'sort_address',
|
||||
areaSort: ' sort_area',
|
||||
areaFilter: 'area_id',
|
||||
},
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<LocationFilterType>({
|
||||
initialValues: {
|
||||
area_id: null,
|
||||
},
|
||||
validationSchema: LocationFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('areaFilter', values.area_id || '');
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('areaFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
// ===== AREA OPTIONS =====
|
||||
const {
|
||||
setInputValue: setAreaInputValue,
|
||||
options: areaOptions,
|
||||
isLoadingOptions: isLoadingAreaOptions,
|
||||
loadMore: loadMoreAreas,
|
||||
} = useSelect<Area>(
|
||||
filterModal.open ? AreaApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search'
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterAreaChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const area = val as OptionType | null;
|
||||
const areaId = area?.value ? String(area.value) : null;
|
||||
|
||||
formik.setFieldValue('area_id', areaId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const areaIdValue = useMemo(() => {
|
||||
if (!formik.values.area_id) return null;
|
||||
return (
|
||||
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.area_id, areaOptions]);
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const {
|
||||
data: locations,
|
||||
isLoading,
|
||||
@@ -112,76 +207,25 @@ const LocationsTable = () => {
|
||||
);
|
||||
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState<
|
||||
Location | undefined
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('locations-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const locationsColumns: ColumnDef<Location>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'address',
|
||||
header: 'Alamat',
|
||||
},
|
||||
{
|
||||
accessorKey: 'area',
|
||||
header: 'Area',
|
||||
cell: (props) => props.row.original.area.name,
|
||||
},
|
||||
{
|
||||
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 = () => {
|
||||
setSelectedLocation(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu
|
||||
type='dropdown'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu
|
||||
type='collapse'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
@@ -203,114 +247,145 @@ const LocationsTable = () => {
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
const locationsColumns: ColumnDef<Location>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'address',
|
||||
header: 'Alamat',
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.area?.name ?? '-',
|
||||
header: 'Area',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Location, unknown>) => {
|
||||
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 pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
setPageSize(newVal.value as number);
|
||||
};
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedLocation(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
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]
|
||||
return (
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
// track sorting
|
||||
useEffect(() => {
|
||||
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
const addressSortFilter = sorting.find(
|
||||
(sortItem) => sortItem.id === 'address'
|
||||
);
|
||||
const areaSortFilter = sorting.find((sortItem) => sortItem.id === 'area');
|
||||
|
||||
updateSortingFilter('nameSort', nameSortFilter);
|
||||
updateSortingFilter('addressSort', addressSortFilter);
|
||||
updateSortingFilter('areaSort', areaSortFilter);
|
||||
}, [sorting, updateSortingFilter]);
|
||||
|
||||
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='w-full flex flex-row'>
|
||||
<div className='w-full flex flex-row'>
|
||||
<RequirePermission permissions='lti.master.locations.create'>
|
||||
<Button
|
||||
href='/master-data/location/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Location'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.master.locations.create'>
|
||||
<Button
|
||||
href='/master-data/location/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Location
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-end'>
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
{/* Search and Filter */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
className={{ wrapper: 'max-w-28' }}
|
||||
/>
|
||||
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize', 'search']}
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<Location>
|
||||
data={isResponseSuccess(locations) ? locations?.data : []}
|
||||
columns={locationsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(locations) ? locations?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(locations) ? locations?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(locations) && locations?.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',
|
||||
}}
|
||||
/>
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(locations) || locations.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<LocationTableSkeleton
|
||||
columns={locationsColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Location>
|
||||
data={locations?.data}
|
||||
columns={locationsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={locations?.meta?.page ?? 0}
|
||||
totalItems={locations?.meta?.total_results ?? 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={false}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: 'p-3 mb-0',
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
@@ -327,6 +402,68 @@ const LocationsTable = () => {
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<Modal
|
||||
ref={filterModal.ref}
|
||||
className={{
|
||||
modal: 'p-0',
|
||||
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={filterModal.closeModal}
|
||||
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
options={areaOptions}
|
||||
value={areaIdValue}
|
||||
onChange={handleFilterAreaChange}
|
||||
onInputChange={setAreaInputValue}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreAreas}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { string, object } from 'yup';
|
||||
|
||||
export const LocationFilterSchema = object().shape({
|
||||
area_id: string().nullable(),
|
||||
});
|
||||
|
||||
export type LocationFilterType = {
|
||||
area_id: string | null;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const LocationTableSkeleton = ({
|
||||
columns,
|
||||
icon,
|
||||
title = 'No Data Available',
|
||||
subtitle = 'There is no location data displayed. Enter location data to get started.',
|
||||
}: {
|
||||
columns: ColumnDef<Location>[];
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className='relative size-full'>
|
||||
<Table
|
||||
data={[]}
|
||||
columns={columns}
|
||||
isLoading={true}
|
||||
className={{
|
||||
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||
headerColumnClassName: 'whitespace-nowrap',
|
||||
containerClassName: 'mb-0 overflow-hidden',
|
||||
tableWrapperClassName: 'overflow-hidden',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationTableSkeleton;
|
||||
@@ -1,13 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
ColumnSort,
|
||||
SortingState,
|
||||
} from '@tanstack/react-table';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -16,75 +11,101 @@ 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 RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import NonstockTableSkeleton from '@/components/pages/master-data/nonstock/skeleton/NonstockTableSkeleton';
|
||||
|
||||
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||
import { NonstockApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Nonstock, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const popoverId = `nonstock#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-nonstock#${props.row.original.id}`;
|
||||
|
||||
const closePopover = () => {
|
||||
document.getElementById(popoverId)?.hidePopover();
|
||||
};
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.master.nonstocks.detail'>
|
||||
<Button
|
||||
href={`/master-data/nonstock/detail/?nonstockId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
|
||||
<RequirePermission permissions='lti.master.nonstocks.update'>
|
||||
<Button
|
||||
href={`/master-data/nonstock/detail/edit/?nonstockId=${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>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.master.nonstocks.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.master.nonstocks.detail'>
|
||||
<Button
|
||||
href={`/master-data/nonstock/detail/?nonstockId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.nonstocks.update'>
|
||||
<Button
|
||||
href={`/master-data/nonstock/detail/edit/?nonstockId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
onClick={closePopover}
|
||||
>
|
||||
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.nonstocks.delete'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteClickHandler();
|
||||
closePopover();
|
||||
}}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||
>
|
||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NonstocksTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -92,16 +113,25 @@ const NonstocksTable = () => {
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '', locationSort: '', picSort: '' },
|
||||
initial: {
|
||||
search: searchValue,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
locationSort: 'sort_location',
|
||||
picSort: ' sort_pic',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('nonstocks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
data: nonstocks,
|
||||
isLoading,
|
||||
@@ -112,88 +142,15 @@ const NonstocksTable = () => {
|
||||
);
|
||||
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [selectedNonstock, setSelectedNonstock] = useState<
|
||||
Nonstock | undefined
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const nonstocksColumns: ColumnDef<Nonstock>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'uom',
|
||||
header: 'UOM',
|
||||
cell: (props) => props.row.original.uom.name,
|
||||
},
|
||||
{
|
||||
accessorKey: 'suppliers',
|
||||
header: 'Supplier',
|
||||
cell: (props) => {
|
||||
const supplierNames = props.row.original.suppliers.map(
|
||||
(supplier) => supplier.name
|
||||
);
|
||||
|
||||
return supplierNames.join(', ') || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'flags',
|
||||
header: 'Flag',
|
||||
cell: (props) => props.row.original.flags?.join(', ') || '-',
|
||||
},
|
||||
{
|
||||
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 = () => {
|
||||
setSelectedNonstock(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu
|
||||
type='dropdown'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu
|
||||
type='collapse'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
@@ -215,112 +172,143 @@ const NonstocksTable = () => {
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
const nonstocksColumns: ColumnDef<Nonstock>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.uom?.name ?? '-',
|
||||
header: 'UOM',
|
||||
},
|
||||
{
|
||||
accessorFn: (row) =>
|
||||
row.suppliers?.map((supplier) => supplier.name).join(', ') || '-',
|
||||
header: 'Supplier',
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.flags?.join(', ') || '-',
|
||||
header: 'Flag',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Nonstock, unknown>) => {
|
||||
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 pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
setPageSize(newVal.value as number);
|
||||
};
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedNonstock(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
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]
|
||||
return (
|
||||
<RowOptionsMenu
|
||||
props={props}
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
// track sorting
|
||||
useEffect(() => {
|
||||
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
const locationSortFilter = sorting.find(
|
||||
(sortItem) => sortItem.id === 'location'
|
||||
);
|
||||
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
|
||||
|
||||
updateSortingFilter('nameSort', nameSortFilter);
|
||||
updateSortingFilter('locationSort', locationSortFilter);
|
||||
updateSortingFilter('picSort', picSortFilter);
|
||||
}, [sorting]);
|
||||
|
||||
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='w-full flex flex-row'>
|
||||
<RequirePermission permissions='lti.master.nonstocks.create'>
|
||||
<Button
|
||||
href='/master-data/nonstock/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Nonstock'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.master.nonstocks.create'>
|
||||
<Button
|
||||
href='/master-data/nonstock/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Nonstock
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-end'>
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
{/* Search */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
className={{ wrapper: 'max-w-28' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<Nonstock>
|
||||
data={isResponseSuccess(nonstocks) ? nonstocks?.data : []}
|
||||
columns={nonstocksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(nonstocks) ? nonstocks?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(nonstocks) ? nonstocks?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(nonstocks) && nonstocks?.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',
|
||||
}}
|
||||
/>
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(nonstocks) || nonstocks.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<NonstockTableSkeleton
|
||||
columns={nonstocksColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<Nonstock>
|
||||
data={nonstocks?.data}
|
||||
columns={nonstocksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={nonstocks?.meta?.page ?? 0}
|
||||
totalItems={nonstocks?.meta?.total_results ?? 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={false}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: 'p-3 mb-0',
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -17,7 +17,7 @@ export const NonstockFormSchema = Yup.object({
|
||||
})
|
||||
),
|
||||
|
||||
flags: Yup.array().of(Yup.string()).notRequired(),
|
||||
flags: Yup.boolean().default(false),
|
||||
});
|
||||
|
||||
export const UpdateNonstockFormSchema = NonstockFormSchema;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user