mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 212fd3b4f2 | |||
| 46860a93b9 | |||
| 302da65c59 | |||
| ce8471343c | |||
| 880ff5740d | |||
| 9b53c75f2f | |||
| f662f2951e | |||
| 1fd4b2aba5 | |||
| b75b5956eb | |||
| aa7b6581d9 | |||
| 24b702548d | |||
| 96babba4bb | |||
| acd66b0323 | |||
| 5cf98ed95e | |||
| a83452a0e4 | |||
| 3f712a638f | |||
| 0ea78fee92 | |||
| b561ed6193 | |||
| d7ae7e00d3 | |||
| f0eabedcb2 | |||
| 94a5ce5604 | |||
| 9b56308cf0 | |||
| 6353b3aee4 | |||
| 527a155997 | |||
| f6163b1f69 | |||
| d771b20956 | |||
| da91201dde | |||
| 9b13ce2be6 | |||
| 764dacc627 | |||
| d1f43c4e42 | |||
| 5c0da471ae | |||
| 95556bfdd7 | |||
| 24269d8c76 | |||
| a6be56e6f2 | |||
| f8f5e8403a | |||
| 9f2add3a57 | |||
| 3051e931ca | |||
| 21cc01fe68 | |||
| 21b9396323 | |||
| 1d7f100507 | |||
| 372f1698ca | |||
| 0d5e8383fd | |||
| b9015ed673 | |||
| ca42570a40 | |||
| 16a15fce66 | |||
| 8c507aa410 | |||
| 10749f06da | |||
| 293f457ecb | |||
| 780c0bb9d0 | |||
| b8548b72c9 | |||
| c53f91ec3f | |||
| 96fea80f62 | |||
| d24d50474d | |||
| f3d0e12bcd | |||
| 143d640a1e | |||
| 0e49e29002 | |||
| 8461667ca2 | |||
| 3e7da624aa | |||
| 1968761b5d | |||
| 396ebe5001 | |||
| f5952f5a36 | |||
| 250c42a04b | |||
| e1569c607c | |||
| 3241cc9868 | |||
| 26ec456937 | |||
| 2b9b8e9920 | |||
| 3cac49725f | |||
| 4f0e02a93b | |||
| af60e682ee | |||
| dcebd53c45 | |||
| 1ae2d13335 | |||
| 452139eeed | |||
| eb10dfe29f | |||
| 70bdfc3b43 | |||
| c1bc7beb4a | |||
| 76cd64de5b | |||
| 07691bfd9e | |||
| 1f0c58d264 | |||
| 19ce3989ba | |||
| a136ee1190 | |||
| acd28e5deb | |||
| bfc81da349 | |||
| 64bb87f92f | |||
| f0c2910469 | |||
| 952110d7af | |||
| 6441a38a9d | |||
| 531a257e78 | |||
| be844312d3 | |||
| 6ff19f05fd | |||
| 26093034fa | |||
| 0e5b718fd7 | |||
| 0675d95a2a | |||
| d5294e9b0b | |||
| 8c84e08f3b | |||
| f32e1ceec4 | |||
| f7b0933c0f | |||
| 508a530c3a | |||
| 05a67bdc75 | |||
| 54d2c85677 | |||
| 288e4b92ff | |||
| 7e0dd1bdb1 | |||
| 57e5fafabd | |||
| e53d4e22b2 | |||
| 3c0babb62b | |||
| e7e5456d15 | |||
| d3977a0951 | |||
| 5b1dab2860 | |||
| 8ed12578b4 | |||
| 7ea599168c | |||
| a2345165c1 | |||
| e1c34cf0fb | |||
| 4332881ba6 | |||
| 73cefbb7a3 | |||
| 9ba7b5dba4 | |||
| 172d8efd8e | |||
| 69ecacc1be | |||
| 4bf4981fd4 | |||
| 6dd6147c29 | |||
| c494f8dbd5 | |||
| 211951132a | |||
| b82637fb3b | |||
| eebc9940cc | |||
| 777f0f5e81 | |||
| d29d1f27f8 | |||
| bbe55ee4c3 | |||
| 6dec9268c9 | |||
| b5d9c55fbc | |||
| d941674f9a | |||
| e6c14f57d9 | |||
| f27b261869 | |||
| 6a396ccce6 | |||
| 65e3833cd5 | |||
| 34e9e60173 | |||
| f1a8fda667 | |||
| 36113f6c2a | |||
| e259d1720c | |||
| 78750060de | |||
| ae159b9617 | |||
| 56476c7dd9 | |||
| fa5d09e4fb | |||
| df1b4c29e5 | |||
| 20f6686afc | |||
| 18027f0bb9 | |||
| 2976ffffbf | |||
| 5983a44311 | |||
| 42dd91117e | |||
| 60d0d77dff | |||
| 7723e2a8d3 | |||
| 88fe135cb4 | |||
| 83701a9689 | |||
| e765a7a5fb | |||
| 75a5caa63b | |||
| c40c707c17 | |||
| c3da39ef1b | |||
| 230e966197 | |||
| 62b3894983 | |||
| 8dd1ebdfe4 | |||
| 35c809193b | |||
| e6acfc1214 | |||
| 36b66d9b2f | |||
| 5c73f8f4af | |||
| 2a6f2a1646 | |||
| d40a5dd898 | |||
| ca9205618a | |||
| 14046a1add | |||
| 8ad49a4480 | |||
| 4ff196cb9d | |||
| 0afde48135 | |||
| 9b2930375d | |||
| 8a6a1e6b5c | |||
| 6924aef8c4 | |||
| fa96d7a98a | |||
| 3d3df42576 | |||
| 05886896f1 | |||
| a347024188 | |||
| 6969a2bcb8 |
@@ -39,3 +39,9 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# prettier
|
||||||
|
.prettierrc
|
||||||
|
|
||||||
|
# idea
|
||||||
|
.idea
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
stages: [notify]
|
||||||
|
|
||||||
|
# --- Notify when MR is opened/updated ---
|
||||||
|
notify_discord_mr:
|
||||||
|
stage: notify
|
||||||
|
image: alpine:3.20
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||||
|
variables:
|
||||||
|
WEBHOOK_URL: $DISCORD_WEBHOOK_URL
|
||||||
|
before_script:
|
||||||
|
- apk add --no-cache curl jq
|
||||||
|
script: |
|
||||||
|
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg repo "$CI_PROJECT_PATH" \
|
||||||
|
--arg mr "#${CI_MERGE_REQUEST_IID}" \
|
||||||
|
--arg url "$MR_URL" \
|
||||||
|
--arg requestor "${GITLAB_USER_LOGIN:-$GITLAB_USER_NAME}" \
|
||||||
|
--arg source "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" \
|
||||||
|
--arg target "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \
|
||||||
|
--arg title "$CI_MERGE_REQUEST_TITLE" \
|
||||||
|
'{
|
||||||
|
username: "CI Bot - FE",
|
||||||
|
embeds: [{
|
||||||
|
title: "📣 [LTI WEB CLIENT] Merge Request Opened/Updated",
|
||||||
|
description: ($mr + " in " + $repo),
|
||||||
|
url: $url,
|
||||||
|
color: 3447003,
|
||||||
|
fields: [
|
||||||
|
{name: "Author", value: $requestor, inline: true},
|
||||||
|
{name: "Source → Target", value: ($source + " → " + $target), inline: true},
|
||||||
|
{name: "Title", value: $title}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}' \
|
||||||
|
| curl -sS -H "Content-Type: application/json" -d @- "$WEBHOOK_URL"
|
||||||
|
|
||||||
|
# --- Notify when MR is merged ---
|
||||||
|
notify_discord_merge:
|
||||||
|
stage: notify
|
||||||
|
image: alpine:3.20
|
||||||
|
rules:
|
||||||
|
# Only run for merge request pipelines that are in merged state
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged"'
|
||||||
|
variables:
|
||||||
|
WEBHOOK_URL: $DISCORD_WEBHOOK_URL
|
||||||
|
before_script:
|
||||||
|
- apk add --no-cache curl jq
|
||||||
|
script: |
|
||||||
|
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg repo "$CI_PROJECT_PATH" \
|
||||||
|
--arg mr "#${CI_MERGE_REQUEST_IID}" \
|
||||||
|
--arg url "$MR_URL" \
|
||||||
|
--arg requestor "${CI_MERGE_REQUEST_AUTHOR}" \
|
||||||
|
--arg source "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" \
|
||||||
|
--arg target "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \
|
||||||
|
--arg title "$CI_MERGE_REQUEST_TITLE" \
|
||||||
|
'{
|
||||||
|
username: "CI Bot - FE",
|
||||||
|
embeds: [{
|
||||||
|
title: "✅ [LTI WEB CLIENT] Merge Request Merged",
|
||||||
|
description: ($mr + " has been merged into " + $repo),
|
||||||
|
url: $url,
|
||||||
|
color: 3066993,
|
||||||
|
fields: [
|
||||||
|
{name: "Author", value: $requestor, inline: true},
|
||||||
|
{name: "Source → Target", value: ($source + " → " + $target), inline: true},
|
||||||
|
{name: "Title", value: $title}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}' \
|
||||||
|
| curl -sS -H "Content-Type: application/json" -d @- "$WEBHOOK_URL"
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"embeddedLanguageFormatting": "auto",
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"embeddedLanguageFormatting": "auto",
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
Generated
+40
@@ -17,9 +17,11 @@
|
|||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
"swr": "^2.3.6",
|
"swr": "^2.3.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"use-debounce": "^10.0.6",
|
||||||
"yup": "^1.7.0",
|
"yup": "^1.7.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
@@ -4039,6 +4041,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/goober": {
|
||||||
|
"version": "2.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||||
|
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"csstype": "^3.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -5760,6 +5771,23 @@
|
|||||||
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
|
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hot-toast": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"goober": "^2.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -6792,6 +6820,18 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-debounce": {
|
||||||
|
"version": "10.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz",
|
||||||
|
"integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-isomorphic-layout-effect": {
|
"node_modules/use-isomorphic-layout-effect": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||||
|
|||||||
@@ -18,9 +18,11 @@
|
|||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
"swr": "^2.3.6",
|
"swr": "^2.3.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"use-debounce": "^10.0.6",
|
||||||
"yup": "^1.7.0",
|
"yup": "^1.7.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const Dashboard = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<h1 className='text-3xl font-bold text-primary'>Dashboard</h1>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import InventoryAdjustmentForm from "@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm";
|
||||||
|
|
||||||
|
const CreateInventoryAdjustment = () => {
|
||||||
|
return (
|
||||||
|
<section className="w-full p-4 flex flex-row justify-center">
|
||||||
|
<InventoryAdjustmentForm/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateInventoryAdjustment;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import InventoryAdjustmentForm from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm';
|
||||||
|
import type { InventoryAdjustment } from '@/types/api/inventory/adjustment';
|
||||||
|
|
||||||
|
const DetailInventoryAdjustment = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [inventoryAdjustment, setInventoryAdjustment] = useState<InventoryAdjustment | null>(null);
|
||||||
|
|
||||||
|
// Ambil data dari router state
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("Router State");
|
||||||
|
console.log(window.history.state);
|
||||||
|
const state = window.history.state?.usr as
|
||||||
|
| { inventoryAdjustment?: InventoryAdjustment }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (state?.inventoryAdjustment) {
|
||||||
|
// jika object dikirim via router.push(state)
|
||||||
|
setInventoryAdjustment(state.inventoryAdjustment);
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const finalData = inventoryAdjustment;
|
||||||
|
|
||||||
|
console.log("Final Data");
|
||||||
|
console.log(finalData);
|
||||||
|
|
||||||
|
if (!finalData) {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row justify-center items-center p-4">
|
||||||
|
<span className="loading loading-spinner loading-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full p-4 flex flex-row justify-center">
|
||||||
|
<InventoryAdjustmentForm initialValues={finalData} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetailInventoryAdjustment;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import InventoryAdjustmentTable from '@/components/pages/inventory/adjustment/InventoryAdjustmentTable';
|
||||||
|
|
||||||
|
const InventoryAdjustment = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<InventoryAdjustmentTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InventoryAdjustment;
|
||||||
+8
-1
@@ -1,7 +1,10 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import '@/app/globals.css';
|
import '@/app/globals.css';
|
||||||
|
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
import MainDrawer from '@/components/MainDrawer';
|
import MainDrawer from '@/components/MainDrawer';
|
||||||
|
import RequireAuth from '@/components/helper/RequireAuth';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: '--font-inter',
|
variable: '--font-inter',
|
||||||
@@ -27,7 +30,11 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang='en'>
|
<html lang='en'>
|
||||||
<body className={`${inter.variable} antialiased font-inter`}>
|
<body className={`${inter.variable} antialiased font-inter`}>
|
||||||
<MainDrawer>{children}</MainDrawer>
|
<RequireAuth>
|
||||||
|
<MainDrawer>{children}</MainDrawer>
|
||||||
|
</RequireAuth>
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import AreaForm from '@/components/pages/master-data/area/form/AreaForm';
|
||||||
|
|
||||||
|
const AddNonstock = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<AreaForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNonstock;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import AreaForm from '@/components/pages/master-data/area/form/AreaForm';
|
||||||
|
|
||||||
|
import { AreaApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const AreaEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const areaId = searchParams.get('areaId');
|
||||||
|
|
||||||
|
const { data: area, isLoading: isLoadingArea } = useSWR(
|
||||||
|
areaId,
|
||||||
|
(id: number) => AreaApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!areaId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingArea && (!area || isResponseError(area))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingArea && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingArea && isResponseSuccess(area) && (
|
||||||
|
<AreaForm type='edit' initialValues={area.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AreaEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import AreaForm from '@/components/pages/master-data/area/form/AreaForm';
|
||||||
|
|
||||||
|
import { AreaApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const AreaDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const areaId = searchParams.get('areaId');
|
||||||
|
|
||||||
|
const { data: area, isLoading: isLoadingArea } = useSWR(
|
||||||
|
areaId,
|
||||||
|
(id: number) => AreaApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!areaId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingArea && (!area || isResponseError(area))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingArea && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingArea && isResponseSuccess(area) && (
|
||||||
|
<AreaForm type='detail' initialValues={area.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AreaDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import AreasTable from '@/components/pages/master-data/area/AreasTable';
|
||||||
|
|
||||||
|
const Nonstock = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<AreasTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Nonstock;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import BankForm from '@/components/pages/master-data/bank/form/BankForm';
|
||||||
|
|
||||||
|
const AddBank = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<BankForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddBank;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import BankForm from '@/components/pages/master-data/bank/form/BankForm';
|
||||||
|
|
||||||
|
import { BankApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const BankEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const bankId = searchParams.get('bankId');
|
||||||
|
|
||||||
|
const { data: bank, isLoading: isLoadingBank } = useSWR(
|
||||||
|
bankId,
|
||||||
|
(id: number) => BankApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bankId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingBank && (!bank || isResponseError(bank))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingBank && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingBank && isResponseSuccess(bank) && (
|
||||||
|
<BankForm type='edit' initialValues={bank.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BankEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import BankForm from '@/components/pages/master-data/bank/form/BankForm';
|
||||||
|
|
||||||
|
import { BankApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const BankDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const bankId = searchParams.get('bankId');
|
||||||
|
|
||||||
|
const { data: bank, isLoading: isLoadingBank } = useSWR(
|
||||||
|
bankId,
|
||||||
|
(id: number) => BankApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bankId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingBank && (!bank || isResponseError(bank))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingBank && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingBank && isResponseSuccess(bank) && (
|
||||||
|
<BankForm type='detail' initialValues={bank.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BankDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import BanksTable from '@/components/pages/master-data/bank/BanksTable';
|
||||||
|
|
||||||
|
const Bank = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<BanksTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Bank;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm";
|
||||||
|
|
||||||
|
const AddCustomer = () => {
|
||||||
|
return (
|
||||||
|
<section className="w-full p-4 flex flex-row justify-center">
|
||||||
|
<CustomerForm/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddCustomer;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { CustomerApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm';
|
||||||
|
|
||||||
|
const CustomerEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const costumerId = searchParams.get('customerId');
|
||||||
|
|
||||||
|
const { data: costumer, isLoading: isLoadingCostumer } = useSWR(
|
||||||
|
costumerId,
|
||||||
|
(id: number) => CustomerApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!costumerId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingCostumer && (!costumer || isResponseError(costumer))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingCostumer && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingCostumer && isResponseSuccess(costumer) && (
|
||||||
|
<CustomerForm formType='edit' initialValues={costumer.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomerEdit;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { CustomerApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from "@/lib/api-helper";
|
||||||
|
import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm";
|
||||||
|
|
||||||
|
const CustomerDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const costumerId = searchParams.get("customerId");
|
||||||
|
|
||||||
|
const { data: costumer, isLoading: isLoadingCostumer } = useSWR(
|
||||||
|
costumerId,
|
||||||
|
(id: number) => CustomerApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if(!costumerId){
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row justify-center items-center p-4">
|
||||||
|
<span className="loading loading-spinner loading-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!isLoadingCostumer && (!costumer || isResponseError(costumer))){
|
||||||
|
router.replace("/404");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full p-4 flex flex-row justify-center">
|
||||||
|
{isLoadingCostumer && <span className="loading loading-spinner loading-xl" />}
|
||||||
|
{!isLoadingCostumer && isResponseSuccess(costumer) && (
|
||||||
|
<CustomerForm formType="detail" initialValues={costumer.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomerDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import CustomersTable from "@/components/pages/master-data/customer/CustomersTable";
|
||||||
|
|
||||||
|
const Customer = () => {
|
||||||
|
return (
|
||||||
|
<section className="w-full p-4">
|
||||||
|
<CustomersTable />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Customer;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
|
||||||
|
|
||||||
|
const AddFcr = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<FcrForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddFcr;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
|
||||||
|
|
||||||
|
import { FcrApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
import { FcrWithStandards } from '@/types/api/master-data/fcr';
|
||||||
|
|
||||||
|
const FcrEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const fcrId = searchParams.get('fcrId');
|
||||||
|
|
||||||
|
const { data: fcr, isLoading: isLoadingFcr } = useSWR(
|
||||||
|
fcrId,
|
||||||
|
(id: number) =>
|
||||||
|
FcrApi.getSingle(id) as Promise<
|
||||||
|
BaseApiResponse<FcrWithStandards> | undefined
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fcrId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingFcr && (!fcr || isResponseError(fcr))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingFcr && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingFcr && isResponseSuccess(fcr) && (
|
||||||
|
<FcrForm type='edit' initialValues={fcr.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FcrEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
|
||||||
|
|
||||||
|
import { FcrApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { FcrWithStandards } from '@/types/api/master-data/fcr';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
const FcrDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const fcrId = searchParams.get('fcrId');
|
||||||
|
|
||||||
|
const { data: fcr, isLoading: isLoadingFcr } = useSWR(
|
||||||
|
fcrId,
|
||||||
|
(id: number) =>
|
||||||
|
FcrApi.getSingle(id) as Promise<
|
||||||
|
BaseApiResponse<FcrWithStandards> | undefined
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fcrId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingFcr && (!fcr || isResponseError(fcr))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingFcr && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingFcr && isResponseSuccess(fcr) && (
|
||||||
|
<FcrForm type='detail' initialValues={fcr.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FcrDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import FcrsTable from '@/components/pages/master-data/fcr/FcrsTable';
|
||||||
|
|
||||||
|
const Fcr = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<FcrsTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Fcr;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm';
|
||||||
|
|
||||||
|
const AddNonstock = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<KandangForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNonstock;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm';
|
||||||
|
|
||||||
|
import { KandangApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const KandangEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const kandangId = searchParams.get('kandangId');
|
||||||
|
|
||||||
|
const { data: kandang, isLoading: isLoadingKandang } = useSWR(
|
||||||
|
kandangId,
|
||||||
|
(id: number) => KandangApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!kandangId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingKandang && (!kandang || isResponseError(kandang))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingKandang && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingKandang && isResponseSuccess(kandang) && (
|
||||||
|
<KandangForm type='edit' initialValues={kandang.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KandangEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm';
|
||||||
|
|
||||||
|
import { KandangApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const KandangDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const kandangId = searchParams.get('kandangId');
|
||||||
|
|
||||||
|
const { data: kandang, isLoading: isLoadingKandang } = useSWR(
|
||||||
|
kandangId,
|
||||||
|
(id: number) => KandangApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!kandangId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingKandang && (!kandang || isResponseError(kandang))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingKandang && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingKandang && isResponseSuccess(kandang) && (
|
||||||
|
<KandangForm type='detail' initialValues={kandang.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KandangDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable';
|
||||||
|
|
||||||
|
const Nonstock = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<KandangsTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Nonstock;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import LocationForm from '@/components/pages/master-data/location/form/LocationForm';
|
||||||
|
|
||||||
|
const AddNonstock = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<LocationForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNonstock;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import LocationForm from '@/components/pages/master-data/location/form/LocationForm';
|
||||||
|
|
||||||
|
import { LocationApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const LocationEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const locationId = searchParams.get('locationId');
|
||||||
|
|
||||||
|
const { data: location, isLoading: isLoadingLocation } = useSWR(
|
||||||
|
locationId,
|
||||||
|
(id: number) => LocationApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingLocation && (!location || isResponseError(location))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingLocation && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingLocation && isResponseSuccess(location) && (
|
||||||
|
<LocationForm type='edit' initialValues={location.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import LocationForm from '@/components/pages/master-data/location/form/LocationForm';
|
||||||
|
|
||||||
|
import { LocationApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const LocationDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const locationId = searchParams.get('locationId');
|
||||||
|
|
||||||
|
const { data: location, isLoading: isLoadingLocation } = useSWR(
|
||||||
|
locationId,
|
||||||
|
(id: number) => LocationApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingLocation && (!location || isResponseError(location))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingLocation && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingLocation && isResponseSuccess(location) && (
|
||||||
|
<LocationForm type='detail' initialValues={location.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import LocationsTable from '@/components/pages/master-data/location/LocationsTable';
|
||||||
|
|
||||||
|
const Nonstock = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<LocationsTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Nonstock;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
||||||
|
|
||||||
|
const AddNonstock = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<NonstockForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNonstock;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
||||||
|
|
||||||
|
import { NonstockApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const NonstockEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const nonstockId = searchParams.get('nonstockId');
|
||||||
|
|
||||||
|
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
|
||||||
|
nonstockId,
|
||||||
|
(id: number) => NonstockApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nonstockId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingNonstock && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingNonstock && isResponseSuccess(nonstock) && (
|
||||||
|
<NonstockForm type='edit' initialValues={nonstock.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NonstockEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
||||||
|
|
||||||
|
import { NonstockApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const NonstockDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const nonstockId = searchParams.get('nonstockId');
|
||||||
|
|
||||||
|
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
|
||||||
|
nonstockId,
|
||||||
|
(id: number) => NonstockApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nonstockId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingNonstock && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingNonstock && isResponseSuccess(nonstock) && (
|
||||||
|
<NonstockForm type='detail' initialValues={nonstock.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NonstockDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable';
|
||||||
|
|
||||||
|
const Nonstock = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<NonstocksTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Nonstock;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ProductCategoryForm from "@/components/pages/master-data/product-category/form/ProductCategoryForm";
|
||||||
|
|
||||||
|
const AddProductCategory = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full p-4 flex flex-row justify-center">
|
||||||
|
<ProductCategoryForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddProductCategory;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm';
|
||||||
|
|
||||||
|
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ProductCategoryEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const productCategoryId = searchParams.get('productCategoryId');
|
||||||
|
|
||||||
|
const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR(
|
||||||
|
productCategoryId,
|
||||||
|
(id: number) => ProductCategoryApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!productCategoryId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingProductCategory && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingProductCategory && isResponseSuccess(productCategory) && (
|
||||||
|
<ProductCategoryForm type='edit' initialValues={productCategory.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductCategoryEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm';
|
||||||
|
|
||||||
|
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ProductCategoryDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const productCategoryId = searchParams.get('productCategoryId');
|
||||||
|
|
||||||
|
const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR(
|
||||||
|
productCategoryId,
|
||||||
|
(id: number) => ProductCategoryApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!productCategoryId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingProductCategory && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingProductCategory && isResponseSuccess(productCategory) && (
|
||||||
|
<ProductCategoryForm type='detail' initialValues={productCategory.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCategoryDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ProductCategoryTable from "@/components/pages/master-data/product-category/ProductCategoryTable";
|
||||||
|
|
||||||
|
const ProductCategory = () => {
|
||||||
|
return (
|
||||||
|
<section className="w-full p-4">
|
||||||
|
<ProductCategoryTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCategory;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
|
||||||
|
|
||||||
|
const AddProduct = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full p-4 flex flex-row justify-center">
|
||||||
|
<ProductForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddProduct;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
|
||||||
|
import { ProductApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ProductEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const productId = searchParams.get('productId');
|
||||||
|
|
||||||
|
const { data: product, isLoading } = useSWR(
|
||||||
|
productId,
|
||||||
|
(id: number) => ProductApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
router.back();
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && (!product || isResponseError(product))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoading && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoading && isResponseSuccess(product) && (
|
||||||
|
<ProductForm type='edit' initialValues={product.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
|
||||||
|
import { ProductApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ProductDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const productId = searchParams.get('productId');
|
||||||
|
|
||||||
|
const { data: product, isLoading } = useSWR(
|
||||||
|
productId,
|
||||||
|
(id: number) => ProductApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
router.back();
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && (!product || isResponseError(product))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoading && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoading && isResponseSuccess(product) && (
|
||||||
|
<ProductForm type='detail' initialValues={product.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ProductsTable from "@/components/pages/master-data/product/ProductTable";
|
||||||
|
|
||||||
|
const Product = () => {
|
||||||
|
return (
|
||||||
|
<section className="w-full p-4">
|
||||||
|
<ProductsTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Product;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm';
|
||||||
|
|
||||||
|
const AddSupplier = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<SupplierForm />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddSupplier;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { SupplierApi } from '@/services/api/master-data';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const SupplierEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// Get Query Params
|
||||||
|
const supplierId = searchParams.get('supplierId');
|
||||||
|
|
||||||
|
// Fetch Data
|
||||||
|
const { data: supplier, isLoading: isLoadingSupplier } = useSWR(
|
||||||
|
supplierId,
|
||||||
|
(id: number) => SupplierApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!supplierId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingSupplier && (!supplier || isResponseError(supplier))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingSupplier && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingSupplier && isResponseSuccess(supplier) && (
|
||||||
|
<SupplierForm formType='edit' initialValues={supplier.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SupplierEdit;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { SupplierApi } from '@/services/api/master-data';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const SupplierDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// Get Query Params
|
||||||
|
const supplierId = searchParams.get('supplierId');
|
||||||
|
|
||||||
|
// Fetch Data
|
||||||
|
const { data: supplier, isLoading: isLoadingSupplier } = useSWR(
|
||||||
|
supplierId,
|
||||||
|
(id: number) => SupplierApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!supplierId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingSupplier && (!supplier || isResponseError(supplier))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingSupplier && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingSupplier && isResponseSuccess(supplier) && (
|
||||||
|
<SupplierForm formType='detail' initialValues={supplier.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SupplierDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuppliersTable from "@/components/pages/master-data/supplier/SupplierTable";
|
||||||
|
|
||||||
|
const Supplier = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<SuppliersTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Supplier;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import UomForm from '@/components/pages/master-data/uom/form/UomForm';
|
||||||
|
|
||||||
|
const AddNonstock = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<UomForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNonstock;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import UomForm from '@/components/pages/master-data/uom/form/UomForm';
|
||||||
|
|
||||||
|
import { UomApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const UomEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const uomId = searchParams.get('uomId');
|
||||||
|
|
||||||
|
const { data: uom, isLoading: isLoadingUom } = useSWR(uomId, (id: number) =>
|
||||||
|
UomApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uomId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingUom && (!uom || isResponseError(uom))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingUom && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingUom && isResponseSuccess(uom) && (
|
||||||
|
<UomForm type='edit' initialValues={uom.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UomEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import UomForm from '@/components/pages/master-data/uom/form/UomForm';
|
||||||
|
|
||||||
|
import { UomApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const UomDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const uomId = searchParams.get('uomId');
|
||||||
|
|
||||||
|
const { data: uom, isLoading: isLoadingUom } = useSWR(uomId, (id: number) =>
|
||||||
|
UomApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uomId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingUom && (!uom || isResponseError(uom))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingUom && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingUom && isResponseSuccess(uom) && (
|
||||||
|
<UomForm type='detail' initialValues={uom.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UomDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import UomsTable from '@/components/pages/master-data/uom/UomsTable';
|
||||||
|
|
||||||
|
const Nonstock = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<UomsTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Nonstock;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm';
|
||||||
|
|
||||||
|
const AddNonstock = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<WarehouseForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNonstock;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm';
|
||||||
|
|
||||||
|
import { WarehouseApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const WarehouseEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const warehouseId = searchParams.get('warehouseId');
|
||||||
|
|
||||||
|
const { data: warehouse, isLoading: isLoadingWarehouse } = useSWR(
|
||||||
|
warehouseId,
|
||||||
|
(id: number) => WarehouseApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!warehouseId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingWarehouse && (!warehouse || isResponseError(warehouse))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingWarehouse && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingWarehouse && isResponseSuccess(warehouse) && (
|
||||||
|
<WarehouseForm type='edit' initialValues={warehouse.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WarehouseEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm';
|
||||||
|
|
||||||
|
import { WarehouseApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const WarehouseDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const warehouseId = searchParams.get('warehouseId');
|
||||||
|
|
||||||
|
const { data: warehouse, isLoading: isLoadingWarehouse } = useSWR(
|
||||||
|
warehouseId,
|
||||||
|
(id: number) => WarehouseApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!warehouseId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingWarehouse && (!warehouse || isResponseError(warehouse))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingWarehouse && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingWarehouse && isResponseSuccess(warehouse) && (
|
||||||
|
<WarehouseForm type='detail' initialValues={warehouse.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WarehouseDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable';
|
||||||
|
|
||||||
|
const Warehouse = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<WarehousesTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Warehouse;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
interface AlertProps {
|
||||||
|
variant?: 'outline' | 'dash' | 'soft';
|
||||||
|
color?: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Alert = ({ children, variant, color, className }: AlertProps) => {
|
||||||
|
const alertBaseClassName = cn('alert', {
|
||||||
|
'alert-soft': variant === 'soft',
|
||||||
|
'alert-outline': variant === 'outline',
|
||||||
|
'alert-dash': variant === 'dash',
|
||||||
|
|
||||||
|
'alert-info': color === 'info',
|
||||||
|
'alert-success': color === 'success',
|
||||||
|
'alert-warning': color === 'warning',
|
||||||
|
'alert-error': color === 'error',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div className={cn(alertBaseClassName, className)}>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Alert;
|
||||||
@@ -61,7 +61,7 @@ const Button = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isLoading && children}
|
{!isLoading && children}
|
||||||
{isLoading && <span className='loading loading-dots loading-xl' />}
|
{isLoading && <span className='loading loading-dots loading-md' />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ const Button = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isLoading && children}
|
{!isLoading && children}
|
||||||
{isLoading && <span className='loading loading-dots loading-xl' />}
|
{isLoading && <span className='loading loading-dots loading-md' />}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const Collapse = ({
|
|||||||
variant === 'plus' && 'collapse-plus',
|
variant === 'plus' && 'collapse-plus',
|
||||||
bordered && 'border base-content/20 border-opacity-20 rounded-box',
|
bordered && 'border base-content/20 border-opacity-20 rounded-box',
|
||||||
disabled && 'opacity-60 pointer-events-none',
|
disabled && 'opacity-60 pointer-events-none',
|
||||||
|
!open && 'w-fit',
|
||||||
className
|
className
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ export const Collapse = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div>{title}</div>;
|
return title;
|
||||||
}, [title, subtitle]);
|
}, [title, subtitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +103,7 @@ export const Collapse = ({
|
|||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
'collapse-title p-0',
|
'collapse-title w-fit p-0',
|
||||||
'focus:outline-none focus-visible:ring focus-visible:ring-primary/40',
|
'focus:outline-none focus-visible:ring focus-visible:ring-primary/40',
|
||||||
titleClassName
|
titleClassName
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@@ -29,15 +29,11 @@ const isPathActive = (pathname: string, link?: string) => {
|
|||||||
const splittedPathname = pathname.split('/');
|
const splittedPathname = pathname.split('/');
|
||||||
const splittedLink = link.split('/');
|
const splittedLink = link.split('/');
|
||||||
|
|
||||||
return splittedPathname.every((pathnameChunk, idx) => {
|
const isActiveLinkValid = splittedLink.every((linkChunk, idx) => {
|
||||||
return pathnameChunk === splittedLink[idx];
|
return linkChunk === splittedPathname[idx];
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const isCollapseActive = (pathname: string, link?: string) => {
|
return pathname.startsWith(link) && isActiveLinkValid;
|
||||||
if (!link) return false;
|
|
||||||
|
|
||||||
return pathname === link || pathname.startsWith(link);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CollapseMenu = ({
|
const CollapseMenu = ({
|
||||||
@@ -48,14 +44,15 @@ const CollapseMenu = ({
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
}: CollapseMenuProps) => {
|
}: CollapseMenuProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [open, setOpen] = useState(isCollapseActive(pathname, link));
|
const isActive = isPathActive(pathname, link);
|
||||||
|
const [open, setOpen] = useState(isActive);
|
||||||
|
|
||||||
const menuCollapseTitle = (
|
const menuCollapseTitle = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2 rounded-md text-base font-semibold transition-colors flex flex-row justify-between items-center gap-2 hover:bg-primary/10',
|
'w-full px-3 py-2 rounded-md text-base font-semibold transition-colors flex flex-row justify-between items-center gap-2 hover:bg-primary/10 opacity-40',
|
||||||
{
|
{
|
||||||
'bg-primary/10': open,
|
'bg-primary/10 opacity-100': open || isActive,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -76,42 +73,48 @@ const CollapseMenu = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const paddingLeftDepth = `pl-${4 * (depth + 1)}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
open={open}
|
open={open}
|
||||||
title={menuCollapseTitle}
|
title={menuCollapseTitle}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
titleClassName='p-0!'
|
className='w-full'
|
||||||
|
titleClassName='w-full p-0!'
|
||||||
>
|
>
|
||||||
<Menu className={cn('py-0.5', paddingLeftDepth)}>
|
<Menu>
|
||||||
{submenu?.map((item, idx) => {
|
<div
|
||||||
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
className='w-full py-0.5 flex flex-col gap-0.5'
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${0.5 * (depth + 1)}rem`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submenu?.map((item, idx) => {
|
||||||
|
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
||||||
|
|
||||||
|
if (!hasSubmenu) {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={idx}
|
||||||
|
title={item.title}
|
||||||
|
href={item.link}
|
||||||
|
icon={item.icon}
|
||||||
|
active={isPathActive(pathname, item.link)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasSubmenu) {
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<CollapseMenu
|
||||||
key={idx}
|
key={idx}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
href={item.link}
|
link={item.link}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
active={isPathActive(pathname, item.link)}
|
submenu={item.submenu}
|
||||||
|
depth={depth + 1}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
|
</div>
|
||||||
return (
|
|
||||||
<CollapseMenu
|
|
||||||
key={idx}
|
|
||||||
title={item.title}
|
|
||||||
link={item.link}
|
|
||||||
icon={item.icon}
|
|
||||||
submenu={item.submenu}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Menu>
|
</Menu>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
);
|
);
|
||||||
@@ -178,9 +181,39 @@ const MainDrawer = ({
|
|||||||
const { mainDrawerOpen, setMainDrawerOpen } = useUiStore();
|
const { mainDrawerOpen, setMainDrawerOpen } = useUiStore();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const pageTitle = MAIN_DRAWER_LINKS.find((item) =>
|
const getPageTitle = useCallback(() => {
|
||||||
pathname.startsWith(item.link)
|
let title = '';
|
||||||
)?.title;
|
|
||||||
|
const activeMenu = MAIN_DRAWER_LINKS.find((item) =>
|
||||||
|
isPathActive(pathname, item.link)
|
||||||
|
);
|
||||||
|
|
||||||
|
const traverseMenuTitle = (menu: typeof activeMenu) => {
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
title += menu?.title;
|
||||||
|
} else {
|
||||||
|
title += ' - ' + menu?.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSubmenu || !menu.submenu) return;
|
||||||
|
|
||||||
|
const activeSubmenu = menu.submenu?.find((item) =>
|
||||||
|
isPathActive(pathname, item.link)
|
||||||
|
);
|
||||||
|
|
||||||
|
traverseMenuTitle(activeSubmenu);
|
||||||
|
};
|
||||||
|
|
||||||
|
traverseMenuTitle(activeMenu);
|
||||||
|
|
||||||
|
return title;
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const pageTitle = getPageTitle();
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setMainDrawerOpen(!mainDrawerOpen);
|
setMainDrawerOpen(!mainDrawerOpen);
|
||||||
@@ -193,7 +226,7 @@ const MainDrawer = ({
|
|||||||
openOnLarge
|
openOnLarge
|
||||||
sidebarContent={<MainDrawerContent />}
|
sidebarContent={<MainDrawerContent />}
|
||||||
>
|
>
|
||||||
<main className='w-full h-full flex flex-col gap-4'>
|
<main className='w-full h-full flex flex-col'>
|
||||||
<Navbar title={pageTitle as string} toggleSidebar={toggleSidebar} />
|
<Navbar title={pageTitle as string} toggleSidebar={toggleSidebar} />
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
export const useModal = () => {
|
||||||
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const openModal = useCallback(() => {
|
||||||
|
setOpen(true);
|
||||||
|
|
||||||
|
ref.current?.showModal();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
ref.current?.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (open) {
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
openModal();
|
||||||
|
}
|
||||||
|
}, [open, closeModal, openModal]);
|
||||||
|
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.addEventListener('close', () => {
|
||||||
|
closeModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ref, open, setOpen, openModal, closeModal, toggle } as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
ref: RefObject<HTMLDialogElement | null>;
|
||||||
|
children?: ReactNode;
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
className?: {
|
||||||
|
modal?: string;
|
||||||
|
modalBox?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
|
||||||
|
return (
|
||||||
|
<dialog ref={ref} className={cn('modal', className?.modal)}>
|
||||||
|
<div className={cn('modal-box', className?.modalBox)}>{children}</div>
|
||||||
|
|
||||||
|
{closeOnBackdrop && (
|
||||||
|
<form method='dialog' className='modal-backdrop'>
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode, useCallback, useState } from 'react';
|
import { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
useReactTable,
|
useReactTable,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
FilterFn,
|
FilterFn,
|
||||||
|
SortingState,
|
||||||
|
OnChangeFn,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { rankItem } from '@tanstack/match-sorter-utils';
|
import { rankItem } from '@tanstack/match-sorter-utils';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
@@ -43,6 +45,9 @@ export interface TableProps<TData extends object> {
|
|||||||
onFuzzySearchValueChange?: (value: string) => void;
|
onFuzzySearchValueChange?: (value: string) => void;
|
||||||
className?: TableClassNames;
|
className?: TableClassNames;
|
||||||
emptyContent?: ReactNode;
|
emptyContent?: ReactNode;
|
||||||
|
sorting?: SortingState;
|
||||||
|
setSorting?: OnChangeFn<SortingState>;
|
||||||
|
manualSorting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
|
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
|
||||||
@@ -78,6 +83,9 @@ const Table = <TData extends object>({
|
|||||||
paginationClassName: '',
|
paginationClassName: '',
|
||||||
},
|
},
|
||||||
emptyContent = emptyContentDefaultValue,
|
emptyContent = emptyContentDefaultValue,
|
||||||
|
sorting,
|
||||||
|
setSorting,
|
||||||
|
manualSorting = false,
|
||||||
}: TableProps<TData>) => {
|
}: TableProps<TData>) => {
|
||||||
const isServerSideTable =
|
const isServerSideTable =
|
||||||
totalItems !== undefined &&
|
totalItems !== undefined &&
|
||||||
@@ -105,6 +113,7 @@ const Table = <TData extends object>({
|
|||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
|
manualSorting,
|
||||||
state: {
|
state: {
|
||||||
pagination,
|
pagination,
|
||||||
globalFilter: fuzzySearchValue,
|
globalFilter: fuzzySearchValue,
|
||||||
@@ -120,7 +129,16 @@ const Table = <TData extends object>({
|
|||||||
tableOptions.getFilteredRowModel = getFilteredRowModel();
|
tableOptions.getFilteredRowModel = getFilteredRowModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sorting && setSorting) {
|
||||||
|
tableOptions.onSortingChange = setSorting;
|
||||||
|
tableOptions.state = {
|
||||||
|
...tableOptions.state,
|
||||||
|
sorting,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const table = useReactTable(tableOptions);
|
const table = useReactTable(tableOptions);
|
||||||
|
const { setPageSize } = table;
|
||||||
|
|
||||||
const prevPageClickHandler = () => {
|
const prevPageClickHandler = () => {
|
||||||
table.previousPage();
|
table.previousPage();
|
||||||
@@ -148,6 +166,10 @@ const Table = <TData extends object>({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageSize(pageSize);
|
||||||
|
}, [pageSize, setPageSize]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className.containerClassName}>
|
<div className={className.containerClassName}>
|
||||||
<div className={className.tableWrapperClassName}>
|
<div className={className.tableWrapperClassName}>
|
||||||
@@ -224,6 +246,7 @@ const Table = <TData extends object>({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(data.length === 0 || table.getRowModel().rows.length === 0) &&
|
{(data.length === 0 || table.getRowModel().rows.length === 0) &&
|
||||||
|
!isLoading &&
|
||||||
emptyContent}
|
emptyContent}
|
||||||
|
|
||||||
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
|
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import useSWRImmutable from 'swr/immutable';
|
||||||
|
|
||||||
|
import { useAuth } from '@/services/hooks/useAuth';
|
||||||
|
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { GetMeResponse } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
// TODO: delete this later, DONT HARDCODE USER DATA
|
||||||
|
const DUMMY_USER = {
|
||||||
|
id: 1,
|
||||||
|
email: 'admin@mbugroup.id',
|
||||||
|
npk: '0001',
|
||||||
|
name: 'Super Admin',
|
||||||
|
image: null,
|
||||||
|
created_at: '2025-09-30T03:24:20.899229Z',
|
||||||
|
updated_at: '2025-09-30T03:24:20.899229Z',
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
key: 'mbu.super_admin',
|
||||||
|
name: 'MBU Administrator',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'mbu:purchase:read',
|
||||||
|
action: 'read',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'mbu:purchase:create',
|
||||||
|
action: 'create',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'mbu:purchase:approve',
|
||||||
|
action: 'approve',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: 'lti.super_admin',
|
||||||
|
name: 'LTI Administrator',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'lti:purchase:read',
|
||||||
|
action: 'read',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'lti:purchase:create',
|
||||||
|
action: 'create',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: 'lti:purchase:approve',
|
||||||
|
action: 'approve',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
key: 'manbu.super_admin',
|
||||||
|
name: 'MANBU Administrator',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'manbu:purchase:read',
|
||||||
|
action: 'read',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: 'manbu:purchase:create',
|
||||||
|
action: 'create',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
name: 'manbu:purchase:approve',
|
||||||
|
action: 'approve',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RequireAuthProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RequireAuth = ({ children }: RequireAuthProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setUser, setIsLoadingUser } = useAuth();
|
||||||
|
|
||||||
|
const { data: userResponse, isLoading: isLoadingUserResponse } =
|
||||||
|
useSWRImmutable<GetMeResponse & { ok?: boolean }, unknown, SWRHttpKey>(
|
||||||
|
'/auth/get-me',
|
||||||
|
httpClientFetcher,
|
||||||
|
{
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
refreshInterval: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoadingUser(isLoadingUserResponse);
|
||||||
|
}, [isLoadingUserResponse, setIsLoadingUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isResponseSuccess(userResponse)) {
|
||||||
|
setUser(userResponse.data);
|
||||||
|
} else {
|
||||||
|
// router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
|
||||||
|
// TODO: remove this later, DONT HARDCODE USER DATA
|
||||||
|
setUser(DUMMY_USER);
|
||||||
|
}
|
||||||
|
}, [userResponse, setIsLoadingUser, setUser]);
|
||||||
|
|
||||||
|
// TODO: uncomment this later
|
||||||
|
// if (isLoadingUserResponse && !userResponse) {
|
||||||
|
// return (
|
||||||
|
// <div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
// <span className='loading loading-spinner loading-xl' />
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RequireAuth;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
const SuspenseHelper = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuspenseHelper;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEvent, ChangeEventHandler, useEffect, useState } from 'react';
|
||||||
|
import { useDebounce } from 'use-debounce';
|
||||||
|
|
||||||
|
import TextInput, { TextInputProps } from '@/components/input/TextInput';
|
||||||
|
|
||||||
|
interface DebouncedTextInputProps extends TextInputProps {
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DebouncedTextInput = (props: DebouncedTextInputProps) => {
|
||||||
|
const { delay, onChange } = props;
|
||||||
|
|
||||||
|
const [internalChangeEvent, setInternalChangeEvent] =
|
||||||
|
useState<ChangeEvent<HTMLInputElement>>();
|
||||||
|
const [internalValue, setInternalValue] = useState(props.value);
|
||||||
|
|
||||||
|
const [debouncedChangeEvent] = useDebounce(internalChangeEvent, delay ?? 300);
|
||||||
|
const [debouncedValue] = useDebounce(internalValue, delay ?? 300);
|
||||||
|
|
||||||
|
const internalChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setInternalValue(e.target.value);
|
||||||
|
setInternalChangeEvent(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedChangeEvent) {
|
||||||
|
onChange?.(debouncedChangeEvent);
|
||||||
|
}
|
||||||
|
}, [debouncedValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
{...props}
|
||||||
|
value={internalValue}
|
||||||
|
onChange={internalChangeHandler}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DebouncedTextInput;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEventHandler, ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
export interface RadioOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioInputProps {
|
||||||
|
label?: string;
|
||||||
|
bottomLabel?: string;
|
||||||
|
name: string;
|
||||||
|
value?: string;
|
||||||
|
options: RadioOption[];
|
||||||
|
variant?: string;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
label?: string;
|
||||||
|
radioWrapper?: string;
|
||||||
|
radio?: string;
|
||||||
|
};
|
||||||
|
isError?: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
startAdornment?: ReactNode;
|
||||||
|
endAdornment?: ReactNode;
|
||||||
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioInput = ({
|
||||||
|
label,
|
||||||
|
bottomLabel,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
variant = 'radio-primary',
|
||||||
|
className,
|
||||||
|
isError,
|
||||||
|
errorMessage,
|
||||||
|
required = false,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
}: RadioInputProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full flex flex-col gap-2', className?.wrapper)}>
|
||||||
|
{/* Label atas */}
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
'w-full text-sm font-normal leading-5',
|
||||||
|
{ 'text-error': isError },
|
||||||
|
className?.label
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span className='text-error ml-1' title='required'>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Daftar opsi radio */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-row flex-wrap gap-4 items-center',
|
||||||
|
className?.radioWrapper
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-row items-center gap-2 cursor-pointer',
|
||||||
|
disabled && 'opacity-60 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type='radio'
|
||||||
|
name={name}
|
||||||
|
value={option.value}
|
||||||
|
checked={value === option.value}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('radio', variant, className?.radio)}
|
||||||
|
/>
|
||||||
|
<span className='text-sm'>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label bawah */}
|
||||||
|
{!isError && bottomLabel && (
|
||||||
|
<p className='text-sm opacity-60'>{bottomLabel}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pesan error */}
|
||||||
|
{isError && errorMessage && (
|
||||||
|
<p className='text-sm text-error'>{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RadioInput;
|
||||||
@@ -1,27 +1,38 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ComponentType, ReactNode, useMemo } from 'react';
|
import {
|
||||||
import Select, { OptionProps, GroupBase } from 'react-select';
|
ComponentType,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import Select, {
|
||||||
|
OptionProps,
|
||||||
|
GroupBase,
|
||||||
|
InputActionMeta,
|
||||||
|
MultiValue,
|
||||||
|
SingleValue,
|
||||||
|
} from 'react-select';
|
||||||
|
import CreatableSelect from 'react-select/creatable';
|
||||||
import makeAnimated from 'react-select/animated';
|
import makeAnimated from 'react-select/animated';
|
||||||
|
import { useDebounce } from 'use-debounce';
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
export interface OptionType {
|
export interface OptionType {
|
||||||
value: string | number;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
className?: string; // for multi select
|
className?: string;
|
||||||
labelClassName?: string; // for multi select
|
labelClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OptionComponent<T = OptionType> = ComponentType<
|
export type OptionComponent<T = OptionType> = ComponentType<
|
||||||
OptionProps<T, boolean, GroupBase<T>>
|
OptionProps<T, boolean, GroupBase<T>>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface SelectInputProps<T = OptionType> {
|
interface SelectInputBaseProps<T = OptionType> {
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
bottomLabel?: ReactNode;
|
bottomLabel?: ReactNode;
|
||||||
value?: T | T[];
|
|
||||||
onChange?: (val: T | T[] | null) => void;
|
|
||||||
options: T[];
|
options: T[];
|
||||||
optionComponent?: OptionComponent<T>;
|
optionComponent?: OptionComponent<T>;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
@@ -41,40 +52,82 @@ interface SelectInputProps<T = OptionType> {
|
|||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
isAnimated?: boolean;
|
isAnimated?: boolean;
|
||||||
openMenu?: boolean;
|
openMenu?: boolean;
|
||||||
|
delay?: number;
|
||||||
|
onInputChange?: (search: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
|
||||||
|
createables?: boolean;
|
||||||
|
value?: T | T[] | null;
|
||||||
|
onChange?: (val: T | T[] | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const animatedComponents = makeAnimated();
|
const animatedComponents = makeAnimated();
|
||||||
|
|
||||||
const SelectInput = <T extends OptionType>({
|
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||||
label,
|
const {
|
||||||
bottomLabel,
|
label,
|
||||||
value,
|
bottomLabel,
|
||||||
onChange,
|
value,
|
||||||
options,
|
onChange,
|
||||||
optionComponent,
|
options,
|
||||||
isDisabled,
|
optionComponent,
|
||||||
isLoading,
|
isDisabled,
|
||||||
isClearable,
|
isLoading,
|
||||||
isRtl,
|
isClearable,
|
||||||
isSearchable = true,
|
isRtl,
|
||||||
isMulti,
|
isSearchable = true,
|
||||||
placeholder,
|
isMulti,
|
||||||
required,
|
placeholder,
|
||||||
className,
|
required,
|
||||||
isError,
|
className,
|
||||||
errorMessage,
|
isError,
|
||||||
isAnimated = true,
|
errorMessage,
|
||||||
openMenu,
|
isAnimated = true,
|
||||||
}: SelectInputProps) => {
|
openMenu,
|
||||||
|
delay = 300,
|
||||||
|
createables = false,
|
||||||
|
onInputChange,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [internalInputValue, setInternalInputValue] = useState('');
|
||||||
|
const [debouncedInputValue] = useDebounce(internalInputValue, delay);
|
||||||
|
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
const base = isAnimated ? animatedComponents : {};
|
const base = isAnimated ? animatedComponents : {};
|
||||||
|
return { ...base, IndicatorSeparator: () => null };
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
IndicatorSeparator: () => null,
|
|
||||||
};
|
|
||||||
}, [isAnimated]);
|
}, [isAnimated]);
|
||||||
|
|
||||||
|
const internalInputChangeHandler = (
|
||||||
|
val: string,
|
||||||
|
meta: InputActionMeta
|
||||||
|
) => {
|
||||||
|
if (meta.action === 'input-change') setInternalInputValue(val);
|
||||||
|
if (meta.action === 'menu-close') setInternalInputValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onInputChange?.(debouncedInputValue);
|
||||||
|
}, [onInputChange, debouncedInputValue]);
|
||||||
|
|
||||||
|
const SelectComponent = createables ? CreatableSelect : Select;
|
||||||
|
|
||||||
|
/** 🎯 handleChange tanpa any */
|
||||||
|
const handleChange = (
|
||||||
|
val: MultiValue<T> | SingleValue<T>
|
||||||
|
): void => {
|
||||||
|
if (!val) {
|
||||||
|
onChange?.(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMulti) {
|
||||||
|
onChange?.(val as T[]);
|
||||||
|
} else {
|
||||||
|
onChange?.(val as T);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -86,30 +139,27 @@ const SelectInput = <T extends OptionType>({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-sm font-normal leading-5',
|
'w-full text-sm font-normal leading-5',
|
||||||
{
|
{ 'text-error': isError },
|
||||||
'text-error': isError,
|
|
||||||
},
|
|
||||||
className?.label
|
className?.label
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && (
|
{required && (
|
||||||
<>
|
<span className="tooltip tooltip-error" data-tip="required">
|
||||||
{' '}
|
<span className="text-error"> *</span>
|
||||||
<span className='tooltip tooltip-error' data-tip='required'>
|
</span>
|
||||||
<span className='text-error'> *</span>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Select
|
<SelectComponent<T, boolean, GroupBase<T>>
|
||||||
instanceId='select'
|
instanceId="select"
|
||||||
value={value}
|
value={value ?? (isMulti ? [] : null)}
|
||||||
onChange={(val) => onChange?.(val as T)}
|
onChange={handleChange}
|
||||||
options={options}
|
options={options}
|
||||||
menuIsOpen={openMenu}
|
menuIsOpen={openMenu}
|
||||||
|
inputValue={internalInputValue}
|
||||||
|
onInputChange={internalInputChangeHandler}
|
||||||
isMulti={isMulti}
|
isMulti={isMulti}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -117,14 +167,13 @@ const SelectInput = <T extends OptionType>({
|
|||||||
isRtl={isRtl}
|
isRtl={isRtl}
|
||||||
isSearchable={isSearchable}
|
isSearchable={isSearchable}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={cn('w-full', className)}
|
className={cn('w-full', className?.select)}
|
||||||
classNames={{
|
classNames={{
|
||||||
control: ({ isFocused, isDisabled }) =>
|
control: ({ isFocused, isDisabled }) =>
|
||||||
cn(
|
cn(
|
||||||
'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer!',
|
'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer!',
|
||||||
{
|
{
|
||||||
'border-red-500! focus-within:border-red-500 focus-within:ring-2 focus-within:ring-red-200':
|
'border-red-500! ring-2 ring-red-200': isError,
|
||||||
isError,
|
|
||||||
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
|
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
|
||||||
'border-gray-300': !isError && !isFocused,
|
'border-gray-300': !isError && !isFocused,
|
||||||
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
|
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
|
||||||
@@ -137,8 +186,6 @@ const SelectInput = <T extends OptionType>({
|
|||||||
cn({ 'text-gray-900': !isError, 'text-error!': isError }),
|
cn({ 'text-gray-900': !isError, 'text-error!': isError }),
|
||||||
input: () => cn('text-gray-900'),
|
input: () => cn('text-gray-900'),
|
||||||
indicatorsContainer: () => cn('flex items-center gap-1 pr-2'),
|
indicatorsContainer: () => cn('flex items-center gap-1 pr-2'),
|
||||||
indicatorSeparator: () => cn('mx-1 h-4 w-px bg-gray-200'),
|
|
||||||
clearIndicator: () => cn('p-1 rounded-md hover:bg-gray-100'),
|
|
||||||
dropdownIndicator: ({ isFocused }) =>
|
dropdownIndicator: ({ isFocused }) =>
|
||||||
cn('p-1 rounded-md hover:bg-gray-100', {
|
cn('p-1 rounded-md hover:bg-gray-100', {
|
||||||
'text-gray-900': isFocused,
|
'text-gray-900': isFocused,
|
||||||
@@ -146,55 +193,41 @@ const SelectInput = <T extends OptionType>({
|
|||||||
'text-error!': isError,
|
'text-error!': isError,
|
||||||
}),
|
}),
|
||||||
menu: () =>
|
menu: () =>
|
||||||
cn(
|
cn('border border-gray-200 rounded-lg bg-white shadow-lg!'),
|
||||||
'border border-gray-200 rounded-lg bg-white shadow-lg rounded-lg!'
|
|
||||||
),
|
|
||||||
menuList: () => cn('p-2! max-h-60 overflow-auto'),
|
menuList: () => cn('p-2! max-h-60 overflow-auto'),
|
||||||
groupHeading: () =>
|
option: ({ isFocused, isSelected }) =>
|
||||||
cn('ml-2 mt-2 mb-1 text-xs font-medium text-gray-500'),
|
cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', {
|
||||||
option: ({ isFocused, isSelected, isDisabled }) =>
|
|
||||||
cn('mt-1 px-3 py-2 rounded-md cursor-pointer! select-none', {
|
|
||||||
'text-gray-300': isDisabled,
|
|
||||||
'bg-indigo-600 text-white': isFocused,
|
'bg-indigo-600 text-white': isFocused,
|
||||||
'text-gray-700': !isDisabled && !isFocused,
|
|
||||||
'active:bg-indigo-50': !isDisabled,
|
|
||||||
'bg-blue-500!': isSelected,
|
'bg-blue-500!': isSelected,
|
||||||
|
'text-gray-700': !isFocused && !isSelected,
|
||||||
}),
|
}),
|
||||||
noOptionsMessage: () => cn('px-3 py-2 text-gray-500'),
|
|
||||||
loadingMessage: () => cn('px-3 py-2 text-gray-500'),
|
|
||||||
multiValue: ({ getValue, index }) => {
|
multiValue: ({ getValue, index }) => {
|
||||||
const selectedValues = getValue();
|
const selectedValues = getValue() as T[];
|
||||||
|
|
||||||
return cn(
|
return cn(
|
||||||
'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1 rounded-md!',
|
'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1!',
|
||||||
selectedValues[index]?.className
|
selectedValues[index]?.className
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
multiValueLabel: ({ getValue, index }) => {
|
multiValueLabel: ({ getValue, index }) => {
|
||||||
const selectedValues = getValue();
|
const selectedValues = getValue() as T[];
|
||||||
|
|
||||||
return cn('text-indigo-700', selectedValues[index]?.labelClassName);
|
return cn('text-indigo-700', selectedValues[index]?.labelClassName);
|
||||||
},
|
},
|
||||||
multiValueRemove: () =>
|
|
||||||
cn('p-1 rounded-sm! hover:bg-indigo-100 hover:text-indigo-800'),
|
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
...components,
|
...components,
|
||||||
...(optionComponent ? { Option: optionComponent } : {}),
|
...(optionComponent ? { Option: optionComponent } : {}),
|
||||||
}}
|
}}
|
||||||
// make the menu float above modals/etc.
|
|
||||||
menuPortalTarget={
|
menuPortalTarget={
|
||||||
typeof document !== 'undefined' ? document.body : undefined
|
typeof document !== 'undefined' ? document.body : undefined
|
||||||
}
|
}
|
||||||
styles={{
|
styles={{
|
||||||
// Tailwind can't set inline z-index on a portal; use styles here:
|
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
{isError && <p className="w-full text-sm text-error">{errorMessage}</p>}
|
||||||
{!isError && bottomLabel && (
|
{!isError && bottomLabel && (
|
||||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
<p className="w-full text-sm opacity-60">{bottomLabel}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, KeyboardEvent, ChangeEvent, useEffect } from 'react';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
export interface TagInputProps {
|
||||||
|
label?: string;
|
||||||
|
bottomLabel?: string;
|
||||||
|
name: string;
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
label?: string;
|
||||||
|
inputWrapper?: string;
|
||||||
|
input?: string;
|
||||||
|
};
|
||||||
|
isError?: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagInput: React.FC<TagInputProps> = ({
|
||||||
|
label,
|
||||||
|
bottomLabel,
|
||||||
|
name,
|
||||||
|
value = '',
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
isError,
|
||||||
|
isValid,
|
||||||
|
errorMessage,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
required = false,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const [tags, setTags] = useState<string[]>(value ? value.split(',') : []);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== undefined && value !== tags.join(',')) {
|
||||||
|
setTags(value ? value.split(',') : []);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
const newTag = inputValue.trim();
|
||||||
|
if (newTag && !tags.includes(newTag)) {
|
||||||
|
const updatedTags = [...tags, newTag];
|
||||||
|
setTags(updatedTags);
|
||||||
|
onChange?.(updatedTags.join(','));
|
||||||
|
}
|
||||||
|
setInputValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTag = (tagToRemove: string) => {
|
||||||
|
const updatedTags = tags.filter((t) => t !== tagToRemove);
|
||||||
|
setTags(updatedTags);
|
||||||
|
onChange?.(updatedTags.join(','));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full flex flex-col gap-2 text-start',
|
||||||
|
className?.wrapper
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Label */}
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={name}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-sm font-normal leading-5',
|
||||||
|
{ 'text-error': isError },
|
||||||
|
className?.label
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<span className='tooltip tooltip-error' data-tip='required'>
|
||||||
|
<span className='text-error'> *</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input wrapper */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-wrap items-start gap-2 border border-gray-400 rounded-md p-2 focus-within:ring-2 focus-within:ring-blue-500 min-h-[42px] transition-all',
|
||||||
|
{
|
||||||
|
'border-error': isError,
|
||||||
|
'border-success!': isValid,
|
||||||
|
'opacity-70 cursor-not-allowed': disabled,
|
||||||
|
},
|
||||||
|
className?.inputWrapper
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
// Fokuskan input saat area diklik
|
||||||
|
const inputEl = document.getElementById(name);
|
||||||
|
inputEl?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
className={cn(
|
||||||
|
'badge badge-primary gap-1 px-3 py-3 text-white flex items-center'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{tag}</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleRemoveTag(tag)}
|
||||||
|
className='ml-1 text-white hover:text-red-200 focus:outline-none'
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-[120px] border-none outline-none p-1 size-min',
|
||||||
|
className?.input
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom label or error message */}
|
||||||
|
{!isError && bottomLabel && (
|
||||||
|
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||||
|
)}
|
||||||
|
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagInput;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChangeEventHandler,
|
||||||
|
FocusEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
export interface TextAreaProps {
|
||||||
|
label?: string;
|
||||||
|
bottomLabel?: string;
|
||||||
|
name: string;
|
||||||
|
value?: string | number;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
label?: string;
|
||||||
|
inputWrapper?: string;
|
||||||
|
input?: string;
|
||||||
|
};
|
||||||
|
isError?: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
startAdornment?: ReactNode;
|
||||||
|
endAdornment?: ReactNode;
|
||||||
|
onChange?: ChangeEventHandler<HTMLTextAreaElement>;
|
||||||
|
onBlur?: FocusEventHandler<HTMLTextAreaElement>;
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextArea = ({
|
||||||
|
label,
|
||||||
|
bottomLabel,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
isError,
|
||||||
|
isValid,
|
||||||
|
errorMessage,
|
||||||
|
startAdornment,
|
||||||
|
endAdornment,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
readOnly = false,
|
||||||
|
isLoading = false,
|
||||||
|
rows = 3
|
||||||
|
}: TextAreaProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full flex flex-col gap-2 text-start',
|
||||||
|
className?.wrapper
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={name}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-sm font-normal leading-5',
|
||||||
|
{
|
||||||
|
'text-error': isError,
|
||||||
|
},
|
||||||
|
className?.label
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<span className='tooltip tooltip-error' data-tip='required'>
|
||||||
|
<span className='text-error'> *</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{startAdornment && startAdornment}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all',
|
||||||
|
{
|
||||||
|
'border-error': isError,
|
||||||
|
'border-success!': isValid,
|
||||||
|
},
|
||||||
|
className?.inputWrapper
|
||||||
|
)}
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
rows={rows}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(isLoading || endAdornment) && (
|
||||||
|
<div className='flex flex-row gap-2'>
|
||||||
|
{isLoading && <span className='loading loading-spinner' />}
|
||||||
|
|
||||||
|
{endAdornment && endAdornment}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isError && bottomLabel && (
|
||||||
|
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||||
|
)}
|
||||||
|
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextArea;
|
||||||
@@ -122,7 +122,9 @@ const TextInput = ({
|
|||||||
{!isError && bottomLabel && (
|
{!isError && bottomLabel && (
|
||||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||||
)}
|
)}
|
||||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
{isError && errorMessage && (
|
||||||
|
<p className='w-full text-sm text-error'>{errorMessage}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
import { Color } from '@/types/theme';
|
||||||
|
|
||||||
|
interface ConfirmationModalProps {
|
||||||
|
ref: RefObject<HTMLDialogElement | null>;
|
||||||
|
type?: 'info' | 'success' | 'error';
|
||||||
|
text?: string;
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
primaryButton?: {
|
||||||
|
text?: string;
|
||||||
|
color?: Color;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
secondaryButton?: {
|
||||||
|
text?: string;
|
||||||
|
color?: Color;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
className?: {
|
||||||
|
modal?: string;
|
||||||
|
modalBox?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmationModal = ({
|
||||||
|
ref,
|
||||||
|
type = 'info',
|
||||||
|
text,
|
||||||
|
closeOnBackdrop,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
className,
|
||||||
|
}: ConfirmationModalProps) => {
|
||||||
|
const closeModalHandler = () => {
|
||||||
|
ref.current?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
|
||||||
|
<div className='w-full flex flex-col gap-4'>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-fit p-4 mx-auto flex flex-row justify-center items-center rounded-full',
|
||||||
|
{
|
||||||
|
'bg-error': type === 'error',
|
||||||
|
'bg-info': type === 'info',
|
||||||
|
'bg-success': type === 'success',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{type === 'info' && (
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:info-outline-rounded'
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className='text-info-content'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'success' && (
|
||||||
|
<Icon
|
||||||
|
icon='qlementine-icons:success-12'
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className='text-success-content'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'error' && (
|
||||||
|
<Icon
|
||||||
|
icon='solar:danger-triangle-linear'
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className='text-error-content'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className='text-center font-medium'>
|
||||||
|
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='w-full flex flex-row gap-2'>
|
||||||
|
{secondaryButton && secondaryButton.text && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color={secondaryButton?.color ?? 'none'}
|
||||||
|
isLoading={secondaryButton?.isLoading}
|
||||||
|
disabled={secondaryButton?.isLoading}
|
||||||
|
onClick={closeModalHandler}
|
||||||
|
className='grow'
|
||||||
|
>
|
||||||
|
{secondaryButton?.text ?? 'Tidak'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{primaryButton && primaryButton.text && (
|
||||||
|
<Button
|
||||||
|
color={primaryButton?.color ?? 'info'}
|
||||||
|
onClick={primaryButton?.onClick}
|
||||||
|
isLoading={primaryButton?.isLoading}
|
||||||
|
disabled={primaryButton?.isLoading}
|
||||||
|
className='grow'
|
||||||
|
>
|
||||||
|
{primaryButton?.text ?? 'Ya'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmationModal;
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
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 { Icon } from '@iconify/react';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnSort,
|
||||||
|
SortingState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const InventoryAdjustmentTable = () => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: {
|
||||||
|
search: '',
|
||||||
|
productCategorySort: '',
|
||||||
|
productSort: '',
|
||||||
|
warehouseSort: '',
|
||||||
|
stockSort: '',
|
||||||
|
},
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
productCategorySort: 'sort_product_category',
|
||||||
|
productSort: 'sort_product',
|
||||||
|
warehouseSort: 'sort_warehouse',
|
||||||
|
stockSort: 'sort_stock',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch Data
|
||||||
|
const {
|
||||||
|
data: inventoryAdjustments,
|
||||||
|
isLoading,
|
||||||
|
} = useSWR(
|
||||||
|
`${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
inventoryAdjustmentApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
|
// 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: 'before_quantity',
|
||||||
|
header: 'Stok Sebelum',
|
||||||
|
accessorFn: (row) => formatNumber(String(row.before_quantity)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'after_quantity',
|
||||||
|
header: 'Stok Sesudah',
|
||||||
|
accessorFn: (row) => formatNumber(String(row.after_quantity)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quantity',
|
||||||
|
header: 'Kuantitas',
|
||||||
|
accessorFn: (row) => formatNumber(String(row.quantity)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'transaction_type',
|
||||||
|
header: 'Tipe Transaksi',
|
||||||
|
accessorFn: (row) => {
|
||||||
|
if (row.transaction_type === 'INCREASE') return 'Peningkatan';
|
||||||
|
if (row.transaction_type === 'DECREASE') return 'Penurunan';
|
||||||
|
return '-';
|
||||||
|
},
|
||||||
|
cell: (props) => {
|
||||||
|
const type = props.row.original.transaction_type;
|
||||||
|
const label =
|
||||||
|
type === 'INCREASE'
|
||||||
|
? 'Peningkatan'
|
||||||
|
: type === 'DECREASE'
|
||||||
|
? 'Penurunan'
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`small mx-auto badge badge-soft ${
|
||||||
|
type === 'INCREASE' ? 'badge-success' : 'badge-error'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'created_by',
|
||||||
|
header: 'Oleh',
|
||||||
|
accessorFn: (row) => row.created_user?.name ?? '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Handler
|
||||||
|
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
setPageSize(newVal.value as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Effect
|
||||||
|
useEffect(() => {
|
||||||
|
const productCategorySortFilter = sorting.find(
|
||||||
|
(sortItem) => sortItem.id === 'productCategory'
|
||||||
|
);
|
||||||
|
const productSortFilter = sorting.find(
|
||||||
|
(sortItem) => sortItem.id === 'product'
|
||||||
|
);
|
||||||
|
const warehouseSortFilter = sorting.find(
|
||||||
|
(sortItem) => sortItem.id === 'warehouse'
|
||||||
|
);
|
||||||
|
const stockSortFilter = sorting.find((sortItem) => sortItem.id === 'stock');
|
||||||
|
|
||||||
|
updateSortingFilter('productCategorySort', productCategorySortFilter);
|
||||||
|
updateSortingFilter('productSort', productSortFilter);
|
||||||
|
updateSortingFilter('warehouseSort', warehouseSortFilter);
|
||||||
|
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='flex flex-row'>
|
||||||
|
<Button href='/inventory/adjustment/add' color='primary'>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* <DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Cari Stock Adjustment'
|
||||||
|
value={tableFilterState.search}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||||
|
/> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-end'>
|
||||||
|
<SelectInput
|
||||||
|
label='Baris'
|
||||||
|
options={ROWS_OPTIONS}
|
||||||
|
value={{
|
||||||
|
label: String(tableFilterState.pageSize),
|
||||||
|
value: tableFilterState.pageSize,
|
||||||
|
}}
|
||||||
|
onChange={pageSizeChangeHandler}
|
||||||
|
className={{ wrapper: 'max-w-28' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InventoryAdjustmentTable;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
export const InventoryAdjustmentFormSchema = Yup.object({
|
||||||
|
product_category: Yup.object({
|
||||||
|
value: Yup.number().required('ID Kategori Produk wajib diisi!'),
|
||||||
|
label: Yup.string().required('Nama Kategori Produk wajib diisi!'),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
|
||||||
|
product_category_id: Yup.number().nullable(),
|
||||||
|
|
||||||
|
product: Yup.object({
|
||||||
|
value: Yup.number().required('ID Produk wajib diisi!'),
|
||||||
|
label: Yup.string().required('Nama Produk wajib diisi!'),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
|
||||||
|
product_id: Yup.number().nullable(),
|
||||||
|
|
||||||
|
warehouse: Yup.object({
|
||||||
|
value: Yup.number().required('ID Gudang wajib diisi!'),
|
||||||
|
label: Yup.string().required('Nama Gudang wajib diisi!'),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
|
||||||
|
warehouse_id: Yup.number().nullable(),
|
||||||
|
|
||||||
|
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!'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InventoryAdjustmentFormValues = Yup.InferType<
|
||||||
|
typeof InventoryAdjustmentFormSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { inventoryAdjustmentApi } from '@/services/api/inventory';
|
||||||
|
import {
|
||||||
|
CreateInventoryAdjustmentPayload,
|
||||||
|
InventoryAdjustment,
|
||||||
|
} from '@/types/api/inventory/adjustment';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import {
|
||||||
|
InventoryAdjustmentFormSchema,
|
||||||
|
InventoryAdjustmentFormValues,
|
||||||
|
} from './InventoryAdjustmentForm.schema';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import {
|
||||||
|
ProductApi,
|
||||||
|
ProductCategoryApi,
|
||||||
|
WarehouseApi,
|
||||||
|
} from '@/services/api/master-data';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import TextInput from '@/components/input/TextInput';
|
||||||
|
import RadioInput from '@/components/input/RadioInput';
|
||||||
|
import TextArea from '@/components/input/TextArea';
|
||||||
|
|
||||||
|
interface InventoryAdjustmentFormProps {
|
||||||
|
type?: 'add' | 'edit' | 'detail';
|
||||||
|
initialValues?: InventoryAdjustment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InventoryAdjustmentForm = ({
|
||||||
|
type = 'add',
|
||||||
|
initialValues,
|
||||||
|
}: InventoryAdjustmentFormProps) => {
|
||||||
|
// State
|
||||||
|
const router = useRouter();
|
||||||
|
const [
|
||||||
|
InventoryAdjustmentFormErrorMessage,
|
||||||
|
setInventoryAdjustmentFormErrorMessage,
|
||||||
|
] = useState('');
|
||||||
|
const [selectedProductCategories, setSelectedProductCategories] =
|
||||||
|
useState('');
|
||||||
|
const [disabledProduct, setDisabledProduct] = useState(true);
|
||||||
|
const [optionsProduct, setOptionsProduct] = useState<OptionType[]>([]);
|
||||||
|
const [quantityLabel, setQuantityLabel] = useState('Tambah Stok');
|
||||||
|
|
||||||
|
// Submit Handler
|
||||||
|
const createInventoryAdjustmentHandler = useCallback(
|
||||||
|
async (payload: CreateInventoryAdjustmentPayload) => {
|
||||||
|
const createInventoryAdjustmentRes = await inventoryAdjustmentApi.create(
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseError(createInventoryAdjustmentRes)) {
|
||||||
|
setInventoryAdjustmentFormErrorMessage(
|
||||||
|
createInventoryAdjustmentRes.message
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(createInventoryAdjustmentRes?.message as string);
|
||||||
|
router.push('/inventory/adjustment');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formikInitialValues = useMemo<Partial<InventoryAdjustmentFormValues>>(() => {
|
||||||
|
return {
|
||||||
|
product_category_id: initialValues?.product_category?.id ?? 0,
|
||||||
|
product_id: initialValues?.product?.id ?? 0,
|
||||||
|
warehouse_id: initialValues?.warehouse?.id ?? 0,
|
||||||
|
product_category: undefined,
|
||||||
|
product: undefined,
|
||||||
|
warehouse: undefined,
|
||||||
|
quantity: initialValues?.quantity ?? 0,
|
||||||
|
transaction_type: undefined,
|
||||||
|
note: initialValues?.note ?? '',
|
||||||
|
};
|
||||||
|
}, [initialValues]);
|
||||||
|
|
||||||
|
// Formik
|
||||||
|
const formik = useFormik<InventoryAdjustmentFormValues>({
|
||||||
|
enableReinitialize: true,
|
||||||
|
initialValues: formikInitialValues as InventoryAdjustmentFormValues,
|
||||||
|
validationSchema: InventoryAdjustmentFormSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
setInventoryAdjustmentFormErrorMessage('');
|
||||||
|
const payload: CreateInventoryAdjustmentPayload = {
|
||||||
|
product_id: values.product_id as number,
|
||||||
|
warehouse_id: values.warehouse_id as number,
|
||||||
|
quantity: values.quantity as number,
|
||||||
|
transaction_type: values.transaction_type as string,
|
||||||
|
note: values.note,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'add':
|
||||||
|
await createInventoryAdjustmentHandler(payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch Data
|
||||||
|
const productCategoriesUrl = `${
|
||||||
|
ProductCategoryApi.basePath
|
||||||
|
}?${new URLSearchParams({
|
||||||
|
search: '',
|
||||||
|
}).toString()}`;
|
||||||
|
const { data: productCategories, isLoading: isLoadingProductCategories } =
|
||||||
|
useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher);
|
||||||
|
|
||||||
|
const productUrl = `${ProductApi.basePath}?${new URLSearchParams({
|
||||||
|
search: '',
|
||||||
|
product_category_id: selectedProductCategories,
|
||||||
|
}).toString()}`;
|
||||||
|
const { data: products, isLoading: isLoadingProducts } = useSWR(
|
||||||
|
productUrl,
|
||||||
|
ProductApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({
|
||||||
|
search: '',
|
||||||
|
}).toString()}`;
|
||||||
|
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
|
||||||
|
warehouseUrl,
|
||||||
|
WarehouseApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map Data to Options
|
||||||
|
const optionsProductCategory = isResponseSuccess(productCategories)
|
||||||
|
? productCategories?.data.map((productCategory) => ({
|
||||||
|
value: productCategory.id,
|
||||||
|
label: productCategory.name,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const optionsWarehouse = isResponseSuccess(warehouses)
|
||||||
|
? warehouses?.data.map((warehouse) => ({
|
||||||
|
value: warehouse.id,
|
||||||
|
label: warehouse.name,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Options Handler
|
||||||
|
const productCategoryChangeHandler = (
|
||||||
|
val: OptionType | OptionType[] | null
|
||||||
|
) => {
|
||||||
|
formik.setFieldTouched('product_category_id', true);
|
||||||
|
formik.setFieldValue('product_category_id', (val as OptionType)?.value);
|
||||||
|
|
||||||
|
formik.setFieldValue('product_category', val);
|
||||||
|
|
||||||
|
setSelectedProductCategories((val as OptionType)?.value as string);
|
||||||
|
const disabled = (val as OptionType)?.value == null;
|
||||||
|
setDisabledProduct(disabled);
|
||||||
|
formik.setFieldValue('product_id', 0);
|
||||||
|
formik.setFieldValue('product', null);
|
||||||
|
formik.setFieldTouched('product', false);
|
||||||
|
formik.setFieldTouched('product_id', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
formik.setFieldValue('product', val);
|
||||||
|
|
||||||
|
formik.setFieldTouched('product_id', true);
|
||||||
|
formik.setFieldValue('product_id', (val as OptionType)?.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
formik.setFieldValue('warehouse', val);
|
||||||
|
|
||||||
|
formik.setFieldTouched('warehouse_id', true);
|
||||||
|
formik.setFieldValue('warehouse_id', (val as OptionType)?.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetHandler = () => {
|
||||||
|
formik.resetForm();
|
||||||
|
setQuantityLabel('Tambah Stok');
|
||||||
|
productCategoryChangeHandler(null);
|
||||||
|
productChangeHandler(null);
|
||||||
|
warehouseChangeHandler(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const { setValues: formikSetValues } = formik;
|
||||||
|
|
||||||
|
// Effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialValues?.product_warehouse?.product?.id) {
|
||||||
|
setSelectedProductCategories(
|
||||||
|
String(initialValues.product_warehouse.product.id)
|
||||||
|
);
|
||||||
|
setDisabledProduct(false);
|
||||||
|
formik.setFieldValue(
|
||||||
|
'product_id',
|
||||||
|
initialValues.product_warehouse.product.id
|
||||||
|
);
|
||||||
|
formik.setFieldValue('product', {
|
||||||
|
value: initialValues.product_warehouse.product.id,
|
||||||
|
label: initialValues.product_warehouse.product.name,
|
||||||
|
});
|
||||||
|
formik.setFieldValue(
|
||||||
|
'warehouse_id',
|
||||||
|
initialValues.product_warehouse.warehouse.id
|
||||||
|
);
|
||||||
|
formik.setFieldValue('warehouse', {
|
||||||
|
value: initialValues.product_warehouse.warehouse.id,
|
||||||
|
label: initialValues.product_warehouse.warehouse.name,
|
||||||
|
});
|
||||||
|
formik.setFieldValue(
|
||||||
|
'quantity',
|
||||||
|
initialValues.product_warehouse.quantity
|
||||||
|
);
|
||||||
|
formik.setFieldValue(
|
||||||
|
'transaction_type',
|
||||||
|
initialValues.transaction_type.toLowerCase()
|
||||||
|
);
|
||||||
|
formik.setFieldValue('note', initialValues.note);
|
||||||
|
}
|
||||||
|
if (initialValues?.transaction_type) {
|
||||||
|
const type = initialValues.transaction_type.toLowerCase();
|
||||||
|
setQuantityLabel(type === 'increase' ? 'Tambah Stok' : 'Kurangi Stok');
|
||||||
|
}
|
||||||
|
}, [formik, initialValues, setQuantityLabel, setDisabledProduct, setSelectedProductCategories]);
|
||||||
|
useEffect(() => {
|
||||||
|
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
|
||||||
|
}, [formikSetValues, formikInitialValues]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isResponseSuccess(products)) {
|
||||||
|
const options = products.data.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.name,
|
||||||
|
}));
|
||||||
|
setOptionsProduct(options);
|
||||||
|
}
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<section className='w-full max-w-xl'>
|
||||||
|
<header className='flex flex-col gap-4'>
|
||||||
|
<Button
|
||||||
|
href='/inventory/adjustment'
|
||||||
|
variant='link'
|
||||||
|
className='w-fit p-0 text-primary'
|
||||||
|
>
|
||||||
|
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
|
{type === 'add' && 'Tambah Penyesuaian Persediaan'}
|
||||||
|
{type === 'detail' && 'Detail Penyesuaian Persediaan'}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onReset={formik.handleReset}
|
||||||
|
className='w-full mt-8 flex flex-col gap-6'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
{/* Text Input Before Quantity */}
|
||||||
|
{type === 'detail' && initialValues && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label='Stok Sebelum'
|
||||||
|
name='before_quantity'
|
||||||
|
type='text'
|
||||||
|
value={formatNumber(String(initialValues.before_quantity))}
|
||||||
|
readOnly={true}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label='Stok Setelah'
|
||||||
|
name='after_quantity'
|
||||||
|
type='text'
|
||||||
|
readOnly={true}
|
||||||
|
value={formatNumber(String(initialValues.after_quantity))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Select Input Product Category */}
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Kategori Produk'
|
||||||
|
value={formik.values.product_category as OptionType}
|
||||||
|
onChange={productCategoryChangeHandler}
|
||||||
|
onInputChange={setSelectedProductCategories}
|
||||||
|
options={optionsProductCategory}
|
||||||
|
isLoading={isLoadingProductCategories}
|
||||||
|
isError={
|
||||||
|
formik.touched.product_category &&
|
||||||
|
Boolean(formik.errors.product_category)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.product_category as string}
|
||||||
|
isDisabled={type === 'detail'}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Select Input Product */}
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Produk'
|
||||||
|
value={formik.values.product as OptionType}
|
||||||
|
onChange={productChangeHandler}
|
||||||
|
options={optionsProduct}
|
||||||
|
isLoading={isLoadingProducts}
|
||||||
|
isError={formik.touched.product && Boolean(formik.errors.product)}
|
||||||
|
errorMessage={formik.errors.product as string}
|
||||||
|
isDisabled={type === 'detail' || disabledProduct}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Select Input Warehouse */}
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Warehouse'
|
||||||
|
value={formik.values.warehouse as OptionType}
|
||||||
|
onChange={warehouseChangeHandler}
|
||||||
|
options={optionsWarehouse}
|
||||||
|
isLoading={isLoadingWarehouses}
|
||||||
|
isError={
|
||||||
|
formik.touched.warehouse && Boolean(formik.errors.warehouse)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.warehouse as string}
|
||||||
|
isDisabled={type === 'detail'}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Radio Button Flag Stock */}
|
||||||
|
<RadioInput
|
||||||
|
name='transaction_type'
|
||||||
|
label='Tipe Transaksi'
|
||||||
|
options={[
|
||||||
|
{ label: 'Tambah', value: 'increase' },
|
||||||
|
{ label: 'Kurang', value: 'decrease' },
|
||||||
|
]}
|
||||||
|
value={formik.values.transaction_type}
|
||||||
|
onChange={(e) => {
|
||||||
|
formik.handleChange(e);
|
||||||
|
setQuantityLabel(
|
||||||
|
e.target.value === 'increase' ? 'Tambah Stok' : 'Kurangi Stok'
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={
|
||||||
|
formik.touched.transaction_type &&
|
||||||
|
Boolean(formik.errors.transaction_type)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.transaction_type as string}
|
||||||
|
variant='radio-primary'
|
||||||
|
required
|
||||||
|
bottomLabel={formik.values.transaction_type == undefined ? 'Pilih salah satu tipe transaksi' : undefined}
|
||||||
|
disabled={type === 'detail'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Number Input Stock */}
|
||||||
|
<TextInput
|
||||||
|
className={{
|
||||||
|
wrapper: `${formik.values.transaction_type != undefined ? '' : 'hidden'}`,
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
label={quantityLabel}
|
||||||
|
name='quantity'
|
||||||
|
type='text'
|
||||||
|
value={formatNumber(String(formik.values.quantity))}
|
||||||
|
onChange={(e) => {
|
||||||
|
const rawValue = e.target.value.replace(/,/g, '');
|
||||||
|
const numericValue = parseFloat(rawValue);
|
||||||
|
if (!isNaN(numericValue)) {
|
||||||
|
formik.setFieldValue('quantity', numericValue);
|
||||||
|
} else {
|
||||||
|
formik.setFieldValue('quantity', 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={
|
||||||
|
formik.touched.quantity && Boolean(formik.errors.quantity)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.quantity as string}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Text Area Input Reason */}
|
||||||
|
<TextArea
|
||||||
|
required
|
||||||
|
label='Alasan'
|
||||||
|
name='note'
|
||||||
|
value={formik.values.note as string}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.note && Boolean(formik.errors.note)}
|
||||||
|
errorMessage={formik.errors.note as string}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<div className='flex flex-row justify-end gap-2'>
|
||||||
|
<Button type='button' color='warning' className='px-4' onClick={resetHandler}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='primary'
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting || formik.values.product == undefined}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{InventoryAdjustmentFormErrorMessage && (
|
||||||
|
<div role='alert' className='alert alert-error'>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:error-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
<span>{InventoryAdjustmentFormErrorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InventoryAdjustmentForm;
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||||
|
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||||
|
|
||||||
|
import { Area } from '@/types/api/master-data/area';
|
||||||
|
import { AreaApi } 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';
|
||||||
|
|
||||||
|
const RowOptionsMenu = ({
|
||||||
|
type = 'dropdown',
|
||||||
|
props,
|
||||||
|
deleteClickHandler,
|
||||||
|
}: {
|
||||||
|
type: 'dropdown' | 'collapse';
|
||||||
|
props: CellContext<Area, unknown>;
|
||||||
|
deleteClickHandler: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
tabIndex={type === 'dropdown' ? 0 : undefined}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
'dropdown-content': type === 'dropdown',
|
||||||
|
'mt-2': type === 'collapse',
|
||||||
|
},
|
||||||
|
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={deleteClickHandler}
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
className='text-error hover:text-inherit'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AreasTable = () => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: { search: '', nameSort: '' },
|
||||||
|
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: areas,
|
||||||
|
isLoading,
|
||||||
|
mutate: refreshAreas,
|
||||||
|
} = useSWR(
|
||||||
|
`${AreaApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
AreaApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
|
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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='dropdown'
|
||||||
|
props={props}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowCollapseOptions>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await AreaApi.delete(selectedArea?.id as number);
|
||||||
|
refreshAreas();
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete Area!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
|
||||||
|
setPageSize(newVal.value as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
// track sorting
|
||||||
|
useEffect(() => {
|
||||||
|
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
|
|
||||||
|
if (!isNameSorted) {
|
||||||
|
updateFilter('nameSort', '');
|
||||||
|
} else {
|
||||||
|
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
}, [sorting, updateFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-4'>
|
||||||
|
<div className='flex flex-col gap-2 mb-4'>
|
||||||
|
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
||||||
|
<div className='flex flex-row'>
|
||||||
|
<Button href='/master-data/area/add' color='primary'>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah Area
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Cari Area'
|
||||||
|
value={tableFilterState.search}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-end'>
|
||||||
|
<SelectInput
|
||||||
|
label='Baris'
|
||||||
|
options={ROWS_OPTIONS}
|
||||||
|
value={{
|
||||||
|
label: String(tableFilterState.pageSize),
|
||||||
|
value: tableFilterState.pageSize,
|
||||||
|
}}
|
||||||
|
onChange={pageSizeChangeHandler}
|
||||||
|
className={{ wrapper: 'max-w-28' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data Area ini (${selectedArea?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AreasTable;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
export const AreaFormSchema = Yup.object({
|
||||||
|
name: Yup.string().required('Nama wajib diisi!'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateAreaFormSchema = AreaFormSchema;
|
||||||
|
|
||||||
|
export type AreaFormValues = Yup.InferType<typeof AreaFormSchema>;
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import TextInput from '@/components/input/TextInput';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AreaFormSchema,
|
||||||
|
AreaFormValues,
|
||||||
|
UpdateAreaFormSchema,
|
||||||
|
} from '@/components/pages/master-data/area/form/AreaForm.schema';
|
||||||
|
import { isResponseError } from '@/lib/api-helper';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
CreateAreaPayload,
|
||||||
|
UpdateAreaPayload,
|
||||||
|
} from '@/types/api/master-data/area';
|
||||||
|
import { AreaApi } from '@/services/api/master-data';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
interface AreaFormProps {
|
||||||
|
type?: 'add' | 'edit' | 'detail';
|
||||||
|
initialValues?: Area;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
|
const [areaFormErrorMessage, setAreaFormErrorMessage] = useState('');
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const createAreaHandler = useCallback(
|
||||||
|
async (payload: CreateAreaPayload) => {
|
||||||
|
const createAreaRes = await AreaApi.create(payload);
|
||||||
|
|
||||||
|
if (isResponseError(createAreaRes)) {
|
||||||
|
setAreaFormErrorMessage(createAreaRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(createAreaRes?.message as string);
|
||||||
|
router.push('/master-data/area');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAreaHandler = useCallback(
|
||||||
|
async (areaId: number, payload: UpdateAreaPayload) => {
|
||||||
|
const updateAreaRes = await AreaApi.update(areaId, payload);
|
||||||
|
|
||||||
|
if (updateAreaRes?.status === 'error') {
|
||||||
|
setAreaFormErrorMessage(updateAreaRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(updateAreaRes?.message as string);
|
||||||
|
router.refresh();
|
||||||
|
router.push('/master-data/area');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formikInitialValues = useMemo<AreaFormValues>(() => {
|
||||||
|
return {
|
||||||
|
name: initialValues?.name ?? '',
|
||||||
|
};
|
||||||
|
}, [initialValues]);
|
||||||
|
|
||||||
|
const formik = useFormik<AreaFormValues>({
|
||||||
|
initialValues: formikInitialValues,
|
||||||
|
validationSchema: type === 'edit' ? UpdateAreaFormSchema : AreaFormSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
setAreaFormErrorMessage('');
|
||||||
|
|
||||||
|
const areaPayload: CreateAreaPayload = {
|
||||||
|
name: values.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'add':
|
||||||
|
await createAreaHandler(areaPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'edit':
|
||||||
|
await updateAreaHandler(initialValues?.id as number, areaPayload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setValues: formikSetValues } = formik;
|
||||||
|
|
||||||
|
const deleteAreaClickHandler = () => {
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await AreaApi.delete(initialValues?.id as number);
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete Area!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
router.push('/master-data/area');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formikSetValues(formikInitialValues);
|
||||||
|
}, [formikSetValues, formikInitialValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className='w-full max-w-xl'>
|
||||||
|
<header className='flex flex-col gap-4'>
|
||||||
|
<Button
|
||||||
|
href='/master-data/area'
|
||||||
|
variant='link'
|
||||||
|
className='w-fit p-0 text-primary'
|
||||||
|
>
|
||||||
|
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
|
{type === 'add' && 'Tambah Area'}
|
||||||
|
{type === 'edit' && 'Edit Area'}
|
||||||
|
{type === 'detail' && 'Detail Area'}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onReset={formik.handleReset}
|
||||||
|
className='w-full mt-8 flex flex-col gap-6'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label='Nama'
|
||||||
|
name='name'
|
||||||
|
placeholder='Masukkan nama area'
|
||||||
|
value={formik.values.name}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.name && Boolean(formik.errors.name)}
|
||||||
|
errorMessage={formik.errors.name}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||||
|
{type !== 'add' && (
|
||||||
|
<div className='flex flex-row justify-start gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={deleteAreaClickHandler}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{type !== 'edit' && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='warning'
|
||||||
|
href={`/master-data/area/detail/edit/?areaId=${initialValues?.id}`}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:edit-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-row justify-end gap-2', {
|
||||||
|
'w-full': type === 'add',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button type='reset' color='warning' className='px-4'>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='primary'
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{areaFormErrorMessage && (
|
||||||
|
<div role='alert' className='alert alert-error'>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:error-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
<span>{areaFormErrorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{type !== 'add' && (
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data Area ini (${initialValues?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AreaForm;
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||||
|
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||||
|
|
||||||
|
import { Bank } from '@/types/api/master-data/bank';
|
||||||
|
import { BankApi } 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';
|
||||||
|
|
||||||
|
const RowOptionsMenu = ({
|
||||||
|
type = 'dropdown',
|
||||||
|
props,
|
||||||
|
deleteClickHandler,
|
||||||
|
}: {
|
||||||
|
type: 'dropdown' | 'collapse';
|
||||||
|
props: CellContext<Bank, unknown>;
|
||||||
|
deleteClickHandler: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
tabIndex={type === 'dropdown' ? 0 : undefined}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
'dropdown-content': type === 'dropdown',
|
||||||
|
'mt-2': type === 'collapse',
|
||||||
|
},
|
||||||
|
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={deleteClickHandler}
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
className='text-error hover:text-inherit'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BanksTable = () => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: { search: '', nameSort: '' },
|
||||||
|
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: banks,
|
||||||
|
isLoading,
|
||||||
|
mutate: refreshBanks,
|
||||||
|
} = useSWR(
|
||||||
|
`${BankApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
BankApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
|
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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='dropdown'
|
||||||
|
props={props}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowCollapseOptions>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await BankApi.delete(selectedBank?.id as number);
|
||||||
|
refreshBanks();
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete Bank!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
|
||||||
|
setPageSize(newVal.value as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
// track sorting
|
||||||
|
useEffect(() => {
|
||||||
|
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
|
|
||||||
|
if (!isNameSorted) {
|
||||||
|
updateFilter('nameSort', '');
|
||||||
|
} else {
|
||||||
|
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
}, [sorting]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-4'>
|
||||||
|
<div className='flex flex-col gap-2 mb-4'>
|
||||||
|
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
||||||
|
<div className='flex flex-row'>
|
||||||
|
<Button href='/master-data/bank/add' color='primary'>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah Bank
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Cari Bank'
|
||||||
|
value={tableFilterState.search}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-end'>
|
||||||
|
<SelectInput
|
||||||
|
label='Baris'
|
||||||
|
options={ROWS_OPTIONS}
|
||||||
|
value={{
|
||||||
|
label: String(tableFilterState.pageSize),
|
||||||
|
value: tableFilterState.pageSize,
|
||||||
|
}}
|
||||||
|
onChange={pageSizeChangeHandler}
|
||||||
|
className={{ wrapper: 'max-w-28' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data Bank ini (${selectedBank?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BanksTable;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
export const BankFormSchema = Yup.object({
|
||||||
|
name: Yup.string().required('Nama wajib diisi!'),
|
||||||
|
alias: Yup.string()
|
||||||
|
.max(5, 'Maksimal 5 karakter!')
|
||||||
|
.required('Alias wajib diisi!'),
|
||||||
|
account_number: Yup.string().required('Rekening wajib diisi!'),
|
||||||
|
owner: Yup.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateBankFormSchema = BankFormSchema;
|
||||||
|
|
||||||
|
export type BankFormValues = Yup.InferType<typeof BankFormSchema>;
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import TextInput from '@/components/input/TextInput';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BankFormSchema,
|
||||||
|
BankFormValues,
|
||||||
|
UpdateBankFormSchema,
|
||||||
|
} from '@/components/pages/master-data/bank/form/BankForm.schema';
|
||||||
|
import { isResponseError } from '@/lib/api-helper';
|
||||||
|
import {
|
||||||
|
CreateBankPayload,
|
||||||
|
Bank,
|
||||||
|
UpdateBankPayload,
|
||||||
|
} from '@/types/api/master-data/bank';
|
||||||
|
import { BankApi } from '@/services/api/master-data';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
interface BankFormProps {
|
||||||
|
type?: 'add' | 'edit' | 'detail';
|
||||||
|
initialValues?: Bank;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BankForm = ({ type = 'add', initialValues }: BankFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
|
const [bankFormErrorMessage, setBankFormErrorMessage] = useState('');
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const createBankHandler = useCallback(
|
||||||
|
async (payload: CreateBankPayload) => {
|
||||||
|
const createBankRes = await BankApi.create(payload);
|
||||||
|
|
||||||
|
if (isResponseError(createBankRes)) {
|
||||||
|
setBankFormErrorMessage(createBankRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(createBankRes?.message as string);
|
||||||
|
router.push('/master-data/bank');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateBankHandler = useCallback(
|
||||||
|
async (bankId: number, payload: UpdateBankPayload) => {
|
||||||
|
const updateBankRes = await BankApi.update(bankId, payload);
|
||||||
|
|
||||||
|
if (updateBankRes?.status === 'error') {
|
||||||
|
setBankFormErrorMessage(updateBankRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(updateBankRes?.message as string);
|
||||||
|
router.refresh();
|
||||||
|
router.push('/master-data/bank');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formikInitialValues = useMemo<BankFormValues>(() => {
|
||||||
|
return {
|
||||||
|
name: initialValues?.name ?? '',
|
||||||
|
alias: initialValues?.alias ?? '',
|
||||||
|
account_number: initialValues?.account_number ?? '',
|
||||||
|
owner: initialValues?.owner,
|
||||||
|
};
|
||||||
|
}, [initialValues]);
|
||||||
|
|
||||||
|
const formik = useFormik<BankFormValues>({
|
||||||
|
initialValues: formikInitialValues,
|
||||||
|
validationSchema: type === 'edit' ? UpdateBankFormSchema : BankFormSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
setBankFormErrorMessage('');
|
||||||
|
|
||||||
|
const bankPayload: CreateBankPayload = {
|
||||||
|
name: values.name,
|
||||||
|
alias: values.alias,
|
||||||
|
account_number: values.account_number.toString(),
|
||||||
|
owner: values.owner ? values.owner : '',
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'add':
|
||||||
|
await createBankHandler(bankPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'edit':
|
||||||
|
await updateBankHandler(initialValues?.id as number, bankPayload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setValues: formikSetValues } = formik;
|
||||||
|
|
||||||
|
const deleteBankClickHandler = () => {
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await BankApi.delete(initialValues?.id as number);
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete Bank!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
router.push('/master-data/bank');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formikSetValues(formikInitialValues);
|
||||||
|
}, [formikSetValues, formikInitialValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className='w-full max-w-xl'>
|
||||||
|
<header className='flex flex-col gap-4'>
|
||||||
|
<Button
|
||||||
|
href='/master-data/bank'
|
||||||
|
variant='link'
|
||||||
|
className='w-fit p-0 text-primary'
|
||||||
|
>
|
||||||
|
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
|
{type === 'add' && 'Tambah Bank'}
|
||||||
|
{type === 'edit' && 'Edit Bank'}
|
||||||
|
{type === 'detail' && 'Detail Bank'}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onReset={formik.handleReset}
|
||||||
|
className='w-full mt-8 flex flex-col gap-6'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label='Nama'
|
||||||
|
name='name'
|
||||||
|
placeholder='Masukkan nama Bank'
|
||||||
|
value={formik.values.name}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.name && Boolean(formik.errors.name)}
|
||||||
|
errorMessage={formik.errors.name}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label='Alias'
|
||||||
|
name='alias'
|
||||||
|
placeholder='Masukkan alias'
|
||||||
|
value={formik.values.alias}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.alias && Boolean(formik.errors.alias)}
|
||||||
|
errorMessage={formik.errors.alias}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
type='number'
|
||||||
|
label='No. Rekening'
|
||||||
|
name='account_number'
|
||||||
|
placeholder='Masukkan no. rekening'
|
||||||
|
value={formik.values.account_number}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={
|
||||||
|
formik.touched.account_number &&
|
||||||
|
Boolean(formik.errors.account_number)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.account_number}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Pemilik'
|
||||||
|
name='owner'
|
||||||
|
placeholder='Masukkan nama pemilik'
|
||||||
|
value={formik.values.owner}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.owner && Boolean(formik.errors.owner)}
|
||||||
|
errorMessage={formik.errors.owner}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||||
|
{type !== 'add' && (
|
||||||
|
<div className='flex flex-row justify-start gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={deleteBankClickHandler}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{type !== 'edit' && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='warning'
|
||||||
|
href={`/master-data/bank/detail/edit/?bankId=${initialValues?.id}`}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:edit-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-row justify-end gap-2', {
|
||||||
|
'w-full': type === 'add',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button type='reset' color='warning' className='px-4'>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='primary'
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bankFormErrorMessage && (
|
||||||
|
<div role='alert' className='alert alert-error'>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:error-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
<span>{bankFormErrorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{type !== 'add' && (
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data Bank ini (${initialValues?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BankForm;
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
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 { ROWS_OPTIONS } from '@/config/constant';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
import { CustomerApi } from '@/services/api/master-data';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const RowOptionsMenu = ({
|
||||||
|
type = 'dropdown',
|
||||||
|
props,
|
||||||
|
deleteClickHandler,
|
||||||
|
}: {
|
||||||
|
type: 'dropdown' | 'collapse';
|
||||||
|
props: CellContext<Customer, unknown>;
|
||||||
|
deleteClickHandler: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
tabIndex={type == 'dropdown' ? 0 : undefined}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
'dropdown-content': type === 'dropdown',
|
||||||
|
'mt-2': type === 'collapse',
|
||||||
|
},
|
||||||
|
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<Button
|
||||||
|
className='justify-start text-sm'
|
||||||
|
href={`/master-data/customer/detail/edit/?customerId=${props.row.original.id}`}
|
||||||
|
variant='ghost'
|
||||||
|
color='warning'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={deleteClickHandler}
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
className='text-error hover:text-inherit'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomersTable = () => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: { search: '', nameSort: '', picSort: '' },
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
nameSort: 'sort_name',
|
||||||
|
picSort: 'sort_pic',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch Data
|
||||||
|
const {
|
||||||
|
data: customers,
|
||||||
|
isLoading,
|
||||||
|
mutate: refreshCustomers,
|
||||||
|
} = useSWR(
|
||||||
|
`${CustomerApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
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;
|
||||||
|
|
||||||
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
|
const deleteClickHandler = () => {
|
||||||
|
setSelectedCustomer(props.row.original);
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentPageSize > 2 && (
|
||||||
|
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
|
props={props}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowDropdownOptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentPageSize <= 2 && (
|
||||||
|
<RowCollapseOptions>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
|
props={props}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowCollapseOptions>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Handler
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await CustomerApi.delete(selectedCustomer?.id as number);
|
||||||
|
refreshCustomers();
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-4'>
|
||||||
|
<div className='flex flex-col gap-2 mb-4'>
|
||||||
|
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
||||||
|
<div className='flex flex-row'>
|
||||||
|
<Button href='/master-data/customer/add' color='primary'>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah Customer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Cari Kandang'
|
||||||
|
value={tableFilterState.search}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-end'>
|
||||||
|
<SelectInput
|
||||||
|
label='Baris'
|
||||||
|
options={ROWS_OPTIONS}
|
||||||
|
value={{
|
||||||
|
label: String(tableFilterState.pageSize),
|
||||||
|
value: tableFilterState.pageSize,
|
||||||
|
}}
|
||||||
|
onChange={pageSizeChangeHandler}
|
||||||
|
className={{ wrapper: 'max-w-28' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data Customer ini (${selectedCustomer?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomersTable;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
export const CustomerFormSchema = Yup.object({
|
||||||
|
name: Yup.string().required('Nama wajib diisi!'),
|
||||||
|
|
||||||
|
picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'),
|
||||||
|
|
||||||
|
pic: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).required('PIC wajib diisi!'),
|
||||||
|
|
||||||
|
type: Yup.object({
|
||||||
|
value: Yup.string().required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).required('Tipe wajib diisi!'),
|
||||||
|
|
||||||
|
address: Yup.string().required('Alamat wajib diisi!'),
|
||||||
|
|
||||||
|
phone: Yup.string()
|
||||||
|
.matches(/^[0-9]+$/, 'Nomor telepon hanya boleh berisi angka!')
|
||||||
|
.min(10, 'Nomor telepon minimal 10 digit!')
|
||||||
|
.max(12, 'Nomor telepon maksimal 12 digit!')
|
||||||
|
.required('Nomor telepon wajib diisi!'),
|
||||||
|
|
||||||
|
email: Yup.string()
|
||||||
|
.email('Format email tidak valid!')
|
||||||
|
.required('Email wajib diisi!'),
|
||||||
|
|
||||||
|
account_number: Yup.string()
|
||||||
|
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
|
||||||
|
.required('Nomor rekening wajib diisi!'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateCustomerFormSchema = CustomerFormSchema;
|
||||||
|
|
||||||
|
export type CustomerFormValues = Yup.InferType<typeof CustomerFormSchema>;
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { CustomerApi } from '@/services/api/master-data';
|
||||||
|
import {
|
||||||
|
CreateCustomerPayload,
|
||||||
|
Customer,
|
||||||
|
UpdateCustomerPayload,
|
||||||
|
} from '@/types/api/master-data/customer';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { CustomerFormSchema, CustomerFormValues, UpdateCustomerFormSchema } from './CustomerForm.schema';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import TextInput from '@/components/input/TextInput';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import TextArea from '@/components/input/TextArea';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { UserApi } from '@/services/api/user';
|
||||||
|
import { TYPE_OPTIONS } from '@/config/constant';
|
||||||
|
|
||||||
|
interface CustomerFormProps {
|
||||||
|
formType?: 'add' | 'edit' | 'detail';
|
||||||
|
initialValues?: Customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomerForm = ({
|
||||||
|
formType = 'add',
|
||||||
|
initialValues,
|
||||||
|
}: CustomerFormProps) => {
|
||||||
|
// Setup Kebutuhan Form
|
||||||
|
const router = useRouter();
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
|
// Setup State
|
||||||
|
const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState('');
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
const [picSelectInputValue, setPicSelectInputValue] = useState('');
|
||||||
|
|
||||||
|
// Fetch Data
|
||||||
|
const picUrl = `${UserApi.basePath}?${new URLSearchParams({
|
||||||
|
search: picSelectInputValue ?? '',
|
||||||
|
})}`;
|
||||||
|
|
||||||
|
const { data: pic, isLoading: isLoadingPic } = useSWR(
|
||||||
|
picUrl,
|
||||||
|
UserApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Options data mapping
|
||||||
|
const picOptions = isResponseSuccess(pic)
|
||||||
|
? pic?.data.map((area) => ({
|
||||||
|
value: area.id,
|
||||||
|
label: area.name,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const typeOptions = TYPE_OPTIONS;
|
||||||
|
|
||||||
|
// Handler Event
|
||||||
|
const createCustomerHandler = useCallback(
|
||||||
|
async (payload: CreateCustomerPayload) => {
|
||||||
|
const createCustomerRes = await CustomerApi.create(payload);
|
||||||
|
|
||||||
|
if (isResponseError(createCustomerRes)) {
|
||||||
|
setCustomerFormErrorMessage(createCustomerRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(createCustomerRes?.message as string);
|
||||||
|
router.push('/master-data/customer');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
const updateCustomerHandler = useCallback(
|
||||||
|
async (customerId: number, payload: UpdateCustomerPayload) => {
|
||||||
|
const updateCustomerRes = await CustomerApi.update(customerId, payload);
|
||||||
|
|
||||||
|
if (isResponseError(updateCustomerRes)) {
|
||||||
|
setCustomerFormErrorMessage(updateCustomerRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(updateCustomerRes?.message as string);
|
||||||
|
router.push('/master-data/customer');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteCustomerHandler = () => {
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalDeleteclickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await CustomerApi.delete(initialValues?.id as number);
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
router.push('/master-data/customer');
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Option Handler
|
||||||
|
const picChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
formik.setFieldTouched('pic', true);
|
||||||
|
formik.setFieldValue('pic', val);
|
||||||
|
|
||||||
|
formik.setFieldTouched('picId', true);
|
||||||
|
formik.setFieldValue('picId', (val as OptionType)?.value);
|
||||||
|
};
|
||||||
|
const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
formik.setFieldTouched('type', true);
|
||||||
|
formik.setFieldValue('type', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utils Functions
|
||||||
|
const normalizeType = (type?: string | { value: string; label: string }) => {
|
||||||
|
if (!type) return TYPE_OPTIONS[0];
|
||||||
|
return typeof type === 'string' ? { value: type, label: type } : type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memo untuk simpan input sebelumnya
|
||||||
|
const formikInitialValues = useMemo<CustomerFormValues>(() => {
|
||||||
|
return {
|
||||||
|
name: initialValues?.name ?? '',
|
||||||
|
email: initialValues?.email ?? '',
|
||||||
|
phone: initialValues?.phone ?? '',
|
||||||
|
picId: initialValues?.pic?.id ?? 0,
|
||||||
|
pic: initialValues?.pic
|
||||||
|
? {
|
||||||
|
value: initialValues.pic.id,
|
||||||
|
label: initialValues.pic.name,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
value: 0,
|
||||||
|
label: '',
|
||||||
|
},
|
||||||
|
type: normalizeType(initialValues?.type),
|
||||||
|
address: initialValues?.address ?? '',
|
||||||
|
account_number: initialValues?.account_number ?? '',
|
||||||
|
};
|
||||||
|
}, [initialValues]);
|
||||||
|
|
||||||
|
// Formik
|
||||||
|
const formik = useFormik<CustomerFormValues>({
|
||||||
|
initialValues: formikInitialValues,
|
||||||
|
enableReinitialize: true,
|
||||||
|
validationSchema: formType === 'edit' ? UpdateCustomerFormSchema : CustomerFormSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
// reset error message
|
||||||
|
setCustomerFormErrorMessage('');
|
||||||
|
|
||||||
|
// create payload
|
||||||
|
const payload: CreateCustomerPayload = {
|
||||||
|
name: values.name,
|
||||||
|
email: values.email,
|
||||||
|
phone: values.phone,
|
||||||
|
pic_id: values.picId,
|
||||||
|
type: (values.type as OptionType).value as string,
|
||||||
|
address: values.address,
|
||||||
|
account_number: values.account_number,
|
||||||
|
};
|
||||||
|
|
||||||
|
// cek type form yang disubmit
|
||||||
|
switch (formType) {
|
||||||
|
case 'add':
|
||||||
|
await createCustomerHandler(payload);
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
await updateCustomerHandler(initialValues?.id as number, payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setValues: formikSetValues } = formik;
|
||||||
|
|
||||||
|
// Initialize Formik
|
||||||
|
useEffect(() => {
|
||||||
|
formikSetValues(formikInitialValues);
|
||||||
|
}, [formikSetValues, formikInitialValues]);
|
||||||
|
|
||||||
|
// Render
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className='w-full max-w-xl'>
|
||||||
|
<header className='flex flex-col gap-4'>
|
||||||
|
<Button
|
||||||
|
href='/master-data/customer'
|
||||||
|
variant='link'
|
||||||
|
className='w-fit p-0 text-primary'
|
||||||
|
>
|
||||||
|
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
|
{formType === 'add' && 'Tambah Customer'}
|
||||||
|
{formType === 'edit' && 'Ubah Customer'}
|
||||||
|
{formType === 'detail' && 'Detail Customer'}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onReset={formik.handleReset}
|
||||||
|
className='w-full mt-8 flex flex-col gap-6'
|
||||||
|
>
|
||||||
|
{/* Fields Form */}
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label='Nama'
|
||||||
|
name='name'
|
||||||
|
placeholder='Masukkan nama customer'
|
||||||
|
value={formik.values.name}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.name && Boolean(formik.errors.name)}
|
||||||
|
errorMessage={formik.errors.name}
|
||||||
|
readOnly={formType === 'detail'}
|
||||||
|
/>
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
placeholder='Pilih PIC'
|
||||||
|
label='PIC'
|
||||||
|
value={formik.values.pic ?? undefined}
|
||||||
|
onChange={picChangeHandler}
|
||||||
|
options={picOptions}
|
||||||
|
onInputChange={setPicSelectInputValue}
|
||||||
|
isLoading={isLoadingPic}
|
||||||
|
isError={formik.touched.picId && Boolean(formik.errors.picId)}
|
||||||
|
errorMessage={formik.errors.picId as string}
|
||||||
|
isDisabled={formType === 'detail'}
|
||||||
|
isClearable
|
||||||
|
isSearchable={true}
|
||||||
|
/>
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
placeholder='Pilih Tipe'
|
||||||
|
label='Tipe'
|
||||||
|
value={
|
||||||
|
typeOptions.find(
|
||||||
|
(item) => item.value === formik.values.type?.value
|
||||||
|
) ?? undefined
|
||||||
|
}
|
||||||
|
onChange={typeChangeHandler}
|
||||||
|
options={typeOptions}
|
||||||
|
isError={formik.touched.type && Boolean(formik.errors.type)}
|
||||||
|
errorMessage={formik.errors.type as string}
|
||||||
|
isDisabled={formType === 'detail'}
|
||||||
|
isClearable
|
||||||
|
isSearchable={true}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label='Email'
|
||||||
|
name='email'
|
||||||
|
placeholder='Masukkan email customer'
|
||||||
|
value={formik.values.email}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.email && Boolean(formik.errors.email)}
|
||||||
|
errorMessage={formik.errors.email}
|
||||||
|
readOnly={formType === 'detail'}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label='Nomor Telepon'
|
||||||
|
name='phone'
|
||||||
|
placeholder='Masukkan nomor telepon customer'
|
||||||
|
value={formik.values.phone}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.phone && Boolean(formik.errors.phone)}
|
||||||
|
errorMessage={formik.errors.phone}
|
||||||
|
readOnly={formType === 'detail'}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label='Nomor Rekening'
|
||||||
|
name='account_number'
|
||||||
|
placeholder='Masukkan nomor rekening customer'
|
||||||
|
value={formik.values.account_number}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={
|
||||||
|
formik.touched.account_number &&
|
||||||
|
Boolean(formik.errors.account_number)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.account_number}
|
||||||
|
readOnly={formType === 'detail'}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
required
|
||||||
|
label='Alamat'
|
||||||
|
name='address'
|
||||||
|
placeholder='Masukkan alamat customer'
|
||||||
|
value={formik.values.address}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.address && Boolean(formik.errors.address)}
|
||||||
|
errorMessage={formik.errors.address}
|
||||||
|
readOnly={formType === 'detail'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||||
|
{formType !== 'add' && (
|
||||||
|
<div className='flex flex-row justify-start gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={deleteCustomerHandler}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{formType !== 'edit' && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='warning'
|
||||||
|
href={`/master-data/customer/detail/edit/?customerId=${initialValues?.id}`}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:edit-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formType !== 'detail' && (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-row justify-end gap-2', {
|
||||||
|
'w-full': formType === 'add',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button type='reset' color='warning' className='px-4'>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='primary'
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customerFormErrorMessage && (
|
||||||
|
<div role='alert' className='alert alert-error'>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:error-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
<span>{customerFormErrorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{formType !== 'add' && (
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data Customer ini (${initialValues?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
onClick: confirmationModalDeleteclickHandler,
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomerForm;
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||||
|
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||||
|
|
||||||
|
import { Fcr } from '@/types/api/master-data/fcr';
|
||||||
|
import { FcrApi } 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';
|
||||||
|
|
||||||
|
const RowOptionsMenu = ({
|
||||||
|
type = 'dropdown',
|
||||||
|
props,
|
||||||
|
deleteClickHandler,
|
||||||
|
}: {
|
||||||
|
type: 'dropdown' | 'collapse';
|
||||||
|
props: CellContext<Fcr, unknown>;
|
||||||
|
deleteClickHandler: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
tabIndex={type === 'dropdown' ? 0 : undefined}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
'dropdown-content': type === 'dropdown',
|
||||||
|
'mt-2': type === 'collapse',
|
||||||
|
},
|
||||||
|
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
href={`/master-data/fcr/detail/?fcrId=${props.row.original.id}`}
|
||||||
|
variant='ghost'
|
||||||
|
color='primary'
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
href={`/master-data/fcr/detail/edit/?fcrId=${props.row.original.id}`}
|
||||||
|
variant='ghost'
|
||||||
|
color='warning'
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={deleteClickHandler}
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
className='text-error hover:text-inherit'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FcrsTable = () => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: { search: '', nameSort: '' },
|
||||||
|
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: fcrs,
|
||||||
|
isLoading,
|
||||||
|
mutate: refreshFcrs,
|
||||||
|
} = useSWR(
|
||||||
|
`${FcrApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
FcrApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
|
const [selectedFcr, setSelectedFcr] = useState<Fcr | undefined>(undefined);
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
|
const fcrsColumns: ColumnDef<Fcr>[] = [
|
||||||
|
{
|
||||||
|
header: '#',
|
||||||
|
cell: (props) =>
|
||||||
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
|
props.row.index +
|
||||||
|
1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Nama',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Aksi',
|
||||||
|
cell: (props) => {
|
||||||
|
const currentPageSize = props.table.getPaginationRowModel().rows.length;
|
||||||
|
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||||
|
const currentRowRelativeIndex =
|
||||||
|
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||||
|
|
||||||
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
|
const deleteClickHandler = () => {
|
||||||
|
setSelectedFcr(props.row.original);
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentPageSize > 2 && (
|
||||||
|
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
|
props={props}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowDropdownOptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentPageSize <= 2 && (
|
||||||
|
<RowCollapseOptions>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
|
props={props}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowCollapseOptions>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await FcrApi.delete(selectedFcr?.id as number);
|
||||||
|
refreshFcrs();
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete FCR!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
|
||||||
|
setPageSize(newVal.value as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
// track sorting
|
||||||
|
useEffect(() => {
|
||||||
|
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
|
|
||||||
|
if (!isNameSorted) {
|
||||||
|
updateFilter('nameSort', '');
|
||||||
|
} else {
|
||||||
|
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
}, [sorting]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-4'>
|
||||||
|
<div className='flex flex-col gap-2 mb-4'>
|
||||||
|
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
||||||
|
<div className='flex flex-row'>
|
||||||
|
<Button href='/master-data/fcr/add' color='primary'>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah FCR
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Cari FCR'
|
||||||
|
value={tableFilterState.search}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-end'>
|
||||||
|
<SelectInput
|
||||||
|
label='Baris'
|
||||||
|
options={ROWS_OPTIONS}
|
||||||
|
value={{
|
||||||
|
label: String(tableFilterState.pageSize),
|
||||||
|
value: tableFilterState.pageSize,
|
||||||
|
}}
|
||||||
|
onChange={pageSizeChangeHandler}
|
||||||
|
className={{ wrapper: 'max-w-28' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<Fcr>
|
||||||
|
data={isResponseSuccess(fcrs) ? fcrs?.data : []}
|
||||||
|
columns={fcrsColumns}
|
||||||
|
pageSize={tableFilterState.pageSize}
|
||||||
|
page={isResponseSuccess(fcrs) ? fcrs?.meta?.page : 0}
|
||||||
|
totalItems={isResponseSuccess(fcrs) ? fcrs?.meta?.total_results : 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
isLoading={isLoading}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
|
className={{
|
||||||
|
containerClassName: cn({
|
||||||
|
'mb-20': isResponseSuccess(fcrs) && fcrs?.data?.length === 0,
|
||||||
|
}),
|
||||||
|
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||||
|
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
||||||
|
headerRowClassName: 'border-b border-b-gray-200',
|
||||||
|
headerColumnClassName:
|
||||||
|
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
|
||||||
|
bodyRowClassName: 'border-b border-b-gray-200',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data FCR ini (${selectedFcr?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FcrsTable;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
const FcrStandardSchema: Yup.ObjectSchema<{
|
||||||
|
weight: number | string;
|
||||||
|
fcr_number: number | string;
|
||||||
|
mortality: number | string;
|
||||||
|
}> = Yup.object({
|
||||||
|
weight: Yup.number().nullable().required('Bobot wajib diisi!'),
|
||||||
|
fcr_number: Yup.number()
|
||||||
|
.nullable()
|
||||||
|
.typeError('FCR harus angka!')
|
||||||
|
.required('FCR harus diisi!'),
|
||||||
|
mortality: Yup.number().nullable().required('Mortalitas wajib diisi!'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FcrFormSchema = Yup.object({
|
||||||
|
name: Yup.string().required('Nama wajib diisi!'),
|
||||||
|
fcrStandards: Yup.array()
|
||||||
|
.of(FcrStandardSchema)
|
||||||
|
.min(1, 'Minimal 1 FCR Standard diisi1')
|
||||||
|
.required('FCR wajib diisi!'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateFcrFormSchema = FcrFormSchema;
|
||||||
|
|
||||||
|
export type FcrFormValues = Yup.InferType<typeof FcrFormSchema>;
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import TextInput from '@/components/input/TextInput';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FcrFormSchema,
|
||||||
|
FcrFormValues,
|
||||||
|
UpdateFcrFormSchema,
|
||||||
|
} from '@/components/pages/master-data/fcr/form/FcrForm.schema';
|
||||||
|
import { isResponseError } from '@/lib/api-helper';
|
||||||
|
import {
|
||||||
|
CreateFcrPayload,
|
||||||
|
Fcr,
|
||||||
|
FcrWithStandards,
|
||||||
|
UpdateFcrPayload,
|
||||||
|
} from '@/types/api/master-data/fcr';
|
||||||
|
import { FcrApi } from '@/services/api/master-data';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
interface FcrFormProps {
|
||||||
|
type?: 'add' | 'edit' | 'detail';
|
||||||
|
initialValues?: FcrWithStandards;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FcrForm = ({ type = 'add', initialValues }: FcrFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
|
const [fcrFormErrorMessage, setFcrFormErrorMessage] = useState('');
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const createFcrHandler = useCallback(
|
||||||
|
async (payload: CreateFcrPayload) => {
|
||||||
|
const createFcrRes = await FcrApi.create(payload);
|
||||||
|
|
||||||
|
if (isResponseError(createFcrRes)) {
|
||||||
|
setFcrFormErrorMessage(createFcrRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(createFcrRes?.message as string);
|
||||||
|
router.push('/master-data/fcr');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateFcrHandler = useCallback(
|
||||||
|
async (fcrId: number, payload: UpdateFcrPayload) => {
|
||||||
|
const updateFcrRes = await FcrApi.update(fcrId, payload);
|
||||||
|
|
||||||
|
if (updateFcrRes?.status === 'error') {
|
||||||
|
setFcrFormErrorMessage(updateFcrRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(updateFcrRes?.message as string);
|
||||||
|
router.refresh();
|
||||||
|
router.push('/master-data/fcr');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formikInitialValues = useMemo<FcrFormValues>(() => {
|
||||||
|
return {
|
||||||
|
name: initialValues?.name ?? '',
|
||||||
|
fcrStandards: initialValues?.fcr_standards
|
||||||
|
? initialValues?.fcr_standards
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
weight: '',
|
||||||
|
fcr_number: '',
|
||||||
|
mortality: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [initialValues]);
|
||||||
|
|
||||||
|
const formik = useFormik<FcrFormValues>({
|
||||||
|
initialValues: formikInitialValues,
|
||||||
|
validationSchema: type === 'edit' ? UpdateFcrFormSchema : FcrFormSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
setFcrFormErrorMessage('');
|
||||||
|
|
||||||
|
const fcrPayload: CreateFcrPayload = {
|
||||||
|
name: values.name,
|
||||||
|
fcr_standards: values.fcrStandards as CreateFcrPayload['fcr_standards'],
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'add':
|
||||||
|
await createFcrHandler(fcrPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'edit':
|
||||||
|
await updateFcrHandler(initialValues?.id as number, fcrPayload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setValues: formikSetValues } = formik;
|
||||||
|
|
||||||
|
const addFcrStandard = () =>
|
||||||
|
formik.setFieldValue('fcrStandards', [
|
||||||
|
...formik.values.fcrStandards,
|
||||||
|
{
|
||||||
|
weight: '',
|
||||||
|
fcr_number: '',
|
||||||
|
mortality: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeFcrStandard = (i: number) =>
|
||||||
|
formik.setFieldValue(
|
||||||
|
'fcrStandards',
|
||||||
|
formik.values.fcrStandards.filter((_, idx) => idx !== i)
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteFcrClickHandler = () => {
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await FcrApi.delete(initialValues?.id as number);
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete FCR!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
router.push('/master-data/fcr');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRepeaterInputError = (
|
||||||
|
column: keyof CreateFcrPayload['fcr_standards'][0],
|
||||||
|
idx: number
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
formik.touched.fcrStandards?.[idx]?.[column] &&
|
||||||
|
Boolean(
|
||||||
|
formik.errors.fcrStandards?.[idx] instanceof Object &&
|
||||||
|
formik.errors.fcrStandards?.[idx]?.[column]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formikSetValues(formikInitialValues);
|
||||||
|
}, [formikSetValues, formikInitialValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className='w-full max-w-5xl'>
|
||||||
|
<header className='flex flex-col gap-4'>
|
||||||
|
<Button
|
||||||
|
href='/master-data/fcr'
|
||||||
|
variant='link'
|
||||||
|
className='w-fit p-0 text-primary'
|
||||||
|
>
|
||||||
|
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
|
{type === 'add' && 'Tambah FCR'}
|
||||||
|
{type === 'edit' && 'Edit FCR'}
|
||||||
|
{type === 'detail' && 'Detail FCR'}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onReset={formik.handleReset}
|
||||||
|
className='w-full mt-8 flex flex-col gap-6'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label='Nama'
|
||||||
|
name='name'
|
||||||
|
placeholder='Masukkan nama FCR'
|
||||||
|
value={formik.values.name}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={formik.touched.name && Boolean(formik.errors.name)}
|
||||||
|
errorMessage={formik.errors.name}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className='overflow-x-auto'>
|
||||||
|
<table className='table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bobot</th>
|
||||||
|
<th>FCR</th>
|
||||||
|
<th>Mortalitas</th>
|
||||||
|
{type !== 'detail' && <th>Aksi</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{formik.values.fcrStandards.map((fcrStandard, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
type='number'
|
||||||
|
name={`fcrStandards[${idx}].weight`}
|
||||||
|
placeholder='Masukkan bobot'
|
||||||
|
value={fcrStandard.weight}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isRepeaterInputError('weight', idx)}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full min-w-24',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
type='number'
|
||||||
|
name={`fcrStandards[${idx}].fcr_number`}
|
||||||
|
placeholder='Masukkan FCR'
|
||||||
|
value={fcrStandard.fcr_number}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isRepeaterInputError('fcr_number', idx)}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full min-w-24',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
type='number'
|
||||||
|
name={`fcrStandards[${idx}].mortality`}
|
||||||
|
placeholder='Masukkan mortalitas'
|
||||||
|
value={fcrStandard.mortality}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isRepeaterInputError('mortality', idx)}
|
||||||
|
readOnly={type === 'detail'}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full min-w-24',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={() => removeFcrStandard(idx)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='success'
|
||||||
|
onClick={addFcrStandard}
|
||||||
|
className='w-fit mx-auto'
|
||||||
|
>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah FCR
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||||
|
{type !== 'add' && (
|
||||||
|
<div className='flex flex-row justify-start gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={deleteFcrClickHandler}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{type !== 'edit' && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='warning'
|
||||||
|
href={`/master-data/fcr/detail/edit/?fcrId=${initialValues?.id}`}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:edit-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-row justify-end gap-2', {
|
||||||
|
'w-full': type === 'add',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button type='reset' color='warning' className='px-4'>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='primary'
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fcrFormErrorMessage && (
|
||||||
|
<div role='alert' className='alert alert-error'>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:error-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
<span>{fcrFormErrorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{type !== 'add' && (
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data FCR ini (${initialValues?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FcrForm;
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import {
|
||||||
|
CellContext,
|
||||||
|
ColumnDef,
|
||||||
|
ColumnSort,
|
||||||
|
SortingState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||||
|
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||||
|
|
||||||
|
import { Kandang } from '@/types/api/master-data/kandang';
|
||||||
|
import { KandangApi } 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';
|
||||||
|
|
||||||
|
const RowOptionsMenu = ({
|
||||||
|
type = 'dropdown',
|
||||||
|
props,
|
||||||
|
deleteClickHandler,
|
||||||
|
}: {
|
||||||
|
type: 'dropdown' | 'collapse';
|
||||||
|
props: CellContext<Kandang, unknown>;
|
||||||
|
deleteClickHandler: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
tabIndex={type === 'dropdown' ? 0 : undefined}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
'dropdown-content': type === 'dropdown',
|
||||||
|
'mt-2': type === 'collapse',
|
||||||
|
},
|
||||||
|
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={deleteClickHandler}
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
className='text-error hover:text-inherit'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const KandangsTable = () => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: { search: '', nameSort: '', locationSort: '', picSort: '' },
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
nameSort: 'sort_name',
|
||||||
|
locationSort: 'sort_location',
|
||||||
|
picSort: ' sort_pic',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: kandangs,
|
||||||
|
isLoading,
|
||||||
|
mutate: refreshKandangs,
|
||||||
|
} = useSWR(
|
||||||
|
`${KandangApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
KandangApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
|
const [selectedKandang, setSelectedKandang] = useState<Kandang | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
|
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: '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;
|
||||||
|
|
||||||
|
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='dropdown'
|
||||||
|
props={props}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowCollapseOptions>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await KandangApi.delete(selectedKandang?.id as number);
|
||||||
|
refreshKandangs();
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete Kandang!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
|
||||||
|
setPageSize(newVal.value as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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='flex flex-row'>
|
||||||
|
<Button href='/master-data/kandang/add' color='primary'>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah Kandang
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Cari Kandang'
|
||||||
|
value={tableFilterState.search}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-end'>
|
||||||
|
<SelectInput
|
||||||
|
label='Baris'
|
||||||
|
options={ROWS_OPTIONS}
|
||||||
|
value={{
|
||||||
|
label: String(tableFilterState.pageSize),
|
||||||
|
value: tableFilterState.pageSize,
|
||||||
|
}}
|
||||||
|
onChange={pageSizeChangeHandler}
|
||||||
|
className={{ wrapper: 'max-w-28' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin menghapus data Kandang ini (${selectedKandang?.name})?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KandangsTable;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user