mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-24 15:25:46 +00:00
Merge branch 'feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page' into 'feat/FE/US-339/purchase-report'
[FEAT/FE][US#339/TASK-361-362-363-367] Slicing and Integrate API Purchase Report Page See merge request mbugroup/lti-web-client!101
This commit is contained in:
Generated
+32
-9
@@ -15,7 +15,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "15.5.7",
|
"next": "15.5.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"swr": "^2.3.6",
|
"swr": "^2.3.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"use-debounce": "^10.0.6",
|
"use-debounce": "^10.0.6",
|
||||||
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"yup": "^1.7.0",
|
"yup": "^1.7.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
@@ -1082,9 +1083,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
|
||||||
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@@ -1855,6 +1856,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -1924,6 +1926,7 @@
|
|||||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.2",
|
"@typescript-eslint/scope-manager": "8.46.2",
|
||||||
"@typescript-eslint/types": "8.46.2",
|
"@typescript-eslint/types": "8.46.2",
|
||||||
@@ -2447,6 +2450,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3060,7 +3064,8 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/daisyui": {
|
"node_modules/daisyui": {
|
||||||
"version": "5.5.8",
|
"version": "5.5.8",
|
||||||
@@ -3516,6 +3521,7 @@
|
|||||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3689,6 +3695,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -5654,12 +5661,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
|
||||||
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.7",
|
"@next/env": "15.5.9",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
@@ -6167,6 +6174,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -6197,6 +6205,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
@@ -7083,6 +7092,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -7250,6 +7260,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -7525,6 +7536,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.20.3",
|
||||||
|
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
|
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "1.10.2",
|
"version": "1.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||||
|
|||||||
+2
-1
@@ -18,7 +18,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "15.5.7",
|
"next": "15.5.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"swr": "^2.3.6",
|
"swr": "^2.3.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"use-debounce": "^10.0.6",
|
"use-debounce": "^10.0.6",
|
||||||
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"yup": "^1.7.0",
|
"yup": "^1.7.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
|
import LogisticStockTabs from '@/components/pages/report/logistic-stock/LogisticStockTabs';
|
||||||
|
|
||||||
|
const LogisticStock = () => {
|
||||||
|
return <LogisticStockTabs />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogisticStock;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { ReactNode, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
export interface DropdownProps {
|
||||||
|
trigger: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
trigger?: string;
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
align?: 'start' | 'center' | 'end';
|
||||||
|
direction?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
hover?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
close?: boolean;
|
||||||
|
controlled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown = ({
|
||||||
|
trigger,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
align,
|
||||||
|
direction,
|
||||||
|
hover,
|
||||||
|
defaultOpen = false,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
controlled = false,
|
||||||
|
}: DropdownProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
if (!controlled) {
|
||||||
|
const newState = !isOpen;
|
||||||
|
setIsOpen(newState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWrapperClasses = () => {
|
||||||
|
const openState = controlled ? open : isOpen;
|
||||||
|
|
||||||
|
return cn(
|
||||||
|
'dropdown',
|
||||||
|
{
|
||||||
|
'dropdown-start': align === 'start',
|
||||||
|
'dropdown-center': align === 'center',
|
||||||
|
'dropdown-end': align === 'end',
|
||||||
|
'dropdown-top': direction === 'top',
|
||||||
|
'dropdown-bottom': direction === 'bottom',
|
||||||
|
'dropdown-left': direction === 'left',
|
||||||
|
'dropdown-right': direction === 'right',
|
||||||
|
'dropdown-hover': hover,
|
||||||
|
'dropdown-open': openState && !close,
|
||||||
|
'dropdown-close': close,
|
||||||
|
},
|
||||||
|
className?.wrapper
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTriggerClasses = () => {
|
||||||
|
return cn(className?.trigger);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContentClasses = () => {
|
||||||
|
return cn(
|
||||||
|
'dropdown-content z-[9999] shadow-sm bg-base-100 rounded-box',
|
||||||
|
className?.content
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (controlled) {
|
||||||
|
return (
|
||||||
|
<div className={getWrapperClasses()}>
|
||||||
|
{trigger}
|
||||||
|
{open && !close && (
|
||||||
|
<div tabIndex={-1} className={getContentClasses()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} className={getWrapperClasses()}>
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role='button'
|
||||||
|
className={getTriggerClasses()}
|
||||||
|
onClick={toggleDropdown}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleDropdown();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</div>
|
||||||
|
{!close && (
|
||||||
|
<div tabIndex={-1} className={getContentClasses()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dropdown;
|
||||||
@@ -7,7 +7,7 @@ import { Icon } from '@iconify/react';
|
|||||||
import Menu from '@/components/menu/Menu';
|
import Menu from '@/components/menu/Menu';
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
import MenuItem from '@/components/menu/MenuItem';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Dropdown from '@/components/dropdown/Dropdown';
|
import Dropdown from '@/components/Dropdown';
|
||||||
|
|
||||||
import { useAuth } from '@/services/hooks/useAuth';
|
import { useAuth } from '@/services/hooks/useAuth';
|
||||||
import { AuthApi } from '@/services/api/auth';
|
import { AuthApi } from '@/services/api/auth';
|
||||||
@@ -54,7 +54,8 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
|
|||||||
|
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
position='bottom-end'
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
trigger={
|
trigger={
|
||||||
<div className='btn btn-ghost btn-circle avatar'>
|
<div className='btn btn-ghost btn-circle avatar'>
|
||||||
<div className='w-10 rounded-full border flex justify-center items-center'>
|
<div className='w-10 rounded-full border flex justify-center items-center'>
|
||||||
@@ -62,9 +63,11 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
contentClassName='w-52 mt-3'
|
className={{
|
||||||
|
content: 'w-52 mt-3',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Menu className='p-2 bg-base-100 shadow rounded-box menu-sm'>
|
<Menu>
|
||||||
<MenuItem title='Logout' onClick={logoutClickHandler} />
|
<MenuItem title='Logout' onClick={logoutClickHandler} />
|
||||||
</Menu>
|
</Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ReactNode, useRef, useEffect, useState } from 'react';
|
|
||||||
import { cn } from '@/lib/helper';
|
|
||||||
|
|
||||||
interface DropdownProps {
|
|
||||||
trigger: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
position?:
|
|
||||||
| 'top'
|
|
||||||
| 'bottom'
|
|
||||||
| 'left'
|
|
||||||
| 'right'
|
|
||||||
| 'top-start'
|
|
||||||
| 'top-end'
|
|
||||||
| 'bottom-start'
|
|
||||||
| 'bottom-end'
|
|
||||||
| 'left-start'
|
|
||||||
| 'left-end'
|
|
||||||
| 'right-start'
|
|
||||||
| 'right-end';
|
|
||||||
align?: 'start' | 'center' | 'end';
|
|
||||||
hover?: boolean;
|
|
||||||
className?: string;
|
|
||||||
contentClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Dropdown = ({
|
|
||||||
trigger,
|
|
||||||
children,
|
|
||||||
position = 'bottom',
|
|
||||||
align = 'start',
|
|
||||||
hover = false,
|
|
||||||
className,
|
|
||||||
contentClassName,
|
|
||||||
}: DropdownProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Handle click outside to close dropdown
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// Build position classes
|
|
||||||
const getPositionClasses = () => {
|
|
||||||
const classes: string[] = [];
|
|
||||||
|
|
||||||
// Handle combined positions like 'top-start'
|
|
||||||
if (position.includes('-')) {
|
|
||||||
const [pos, al] = position.split('-');
|
|
||||||
classes.push(`dropdown-${pos}`);
|
|
||||||
classes.push(`dropdown-${al}`);
|
|
||||||
} else {
|
|
||||||
classes.push(`dropdown-${position}`);
|
|
||||||
if (align !== 'start') {
|
|
||||||
classes.push(`dropdown-${align}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return classes.join(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
// alert('clicked');
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={dropdownRef}
|
|
||||||
className={cn(
|
|
||||||
'dropdown',
|
|
||||||
getPositionClasses(),
|
|
||||||
hover && 'dropdown-hover',
|
|
||||||
isOpen && 'dropdown-open',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Trigger Button */}
|
|
||||||
<div onClick={handleToggle} className='cursor-pointer'>
|
|
||||||
{trigger}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dropdown Content - Only render when open */}
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
tabIndex={-1}
|
|
||||||
className={cn('dropdown-content z-[10]', contentClassName)}
|
|
||||||
onClick={() => setIsOpen(false)} // Close on item click
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Dropdown;
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Tabs from '@/components/Tabs';
|
||||||
|
import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab';
|
||||||
|
|
||||||
|
const LogisticStockTabs = () => {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Rekapitulasi Pembelian Per Supplier',
|
||||||
|
content: <PurchasesPerSupplierTab />,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// id: '2',
|
||||||
|
// label: 'Rekapitulasi Pemakaian Barang',
|
||||||
|
// content: 'Rekapitulasi Pemakaian Barang Tab',
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// id: '3',
|
||||||
|
// label: 'Rekapitulasi Stock Persediaan Barang',
|
||||||
|
// content: 'Rekapitulasi Stock Persediaan Barang Tab',
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<Tabs tabs={tabs} variant='lifted' />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogisticStockTabs;
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
Document,
|
||||||
|
StyleSheet,
|
||||||
|
Font,
|
||||||
|
pdf,
|
||||||
|
} from '@react-pdf/renderer';
|
||||||
|
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
|
||||||
|
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||||
|
|
||||||
|
Font.register({
|
||||||
|
family: 'Helvetica',
|
||||||
|
src: 'helvetica',
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfStyles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
titleSection: {
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
mainTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 5,
|
||||||
|
color: '#1f74bf',
|
||||||
|
},
|
||||||
|
supplierTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#1f74bf',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#000000',
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
tableHeader: {
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
},
|
||||||
|
tableCell: {
|
||||||
|
flex: 1,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: '#000000',
|
||||||
|
borderRightStyle: 'solid',
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
textAlign: 'left',
|
||||||
|
},
|
||||||
|
tableCellNo: {
|
||||||
|
flex: 1,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: '#000000',
|
||||||
|
borderRightStyle: 'solid',
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
tableCellLast: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
tableCellHeader: {
|
||||||
|
flex: 1,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: '#000000',
|
||||||
|
borderRightStyle: 'solid',
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#000000',
|
||||||
|
borderBottomStyle: 'solid',
|
||||||
|
paddingVertical: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
tableCellHeaderRight: {
|
||||||
|
flex: 1,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: '#000000',
|
||||||
|
borderRightStyle: 'solid',
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
textAlign: 'right',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#000000',
|
||||||
|
borderBottomStyle: 'solid',
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
tableCellHeaderLast: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#000000',
|
||||||
|
borderBottomStyle: 'solid',
|
||||||
|
paddingVertical: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
tableCellRight: {
|
||||||
|
flex: 1,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: '#000000',
|
||||||
|
borderRightStyle: 'solid',
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
tableCellCenter: {
|
||||||
|
flex: 1,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: '#000000',
|
||||||
|
borderRightStyle: 'solid',
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
tableCellCenterLast: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
tableBorderBottom: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#000000',
|
||||||
|
borderBottomStyle: 'solid',
|
||||||
|
},
|
||||||
|
supplierSection: {
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
supplierSectionBreak: {
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
backgroundColor: '#1f74bf',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
padding: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: 7,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
parameterBadge: {
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
color: '#333333',
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
parameterContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PurchasesPerSupplierExportParams {
|
||||||
|
data: LogisticPurchasePerSupplierReport[];
|
||||||
|
params: {
|
||||||
|
area_name?: string;
|
||||||
|
supplier_name?: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_category_name?: string;
|
||||||
|
received_date?: string;
|
||||||
|
po_date?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
filter_by?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const getParameterText = (
|
||||||
|
params: PurchasesPerSupplierExportParams['params']
|
||||||
|
) => {
|
||||||
|
const paramsText = [];
|
||||||
|
|
||||||
|
if (params.supplier_name) {
|
||||||
|
paramsText.push(`Supplier: ${params.supplier_name}`);
|
||||||
|
} else {
|
||||||
|
paramsText.push('Semua Supplier');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.start_date && params.end_date) {
|
||||||
|
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
|
||||||
|
const endDate = formatDate(params.end_date, 'DD MMM YYYY');
|
||||||
|
paramsText.push(`Periode: ${startDate} - ${endDate}`);
|
||||||
|
} else if (params.start_date) {
|
||||||
|
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
|
||||||
|
paramsText.push(`Tanggal: ${startDate}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDate = formatDate(new Date(), 'DD MMM YYYY HH:mm');
|
||||||
|
paramsText.push(`Dicetak: ${currentDate}`);
|
||||||
|
|
||||||
|
return paramsText;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPDFDocument = (
|
||||||
|
supplierReports: LogisticPurchasePerSupplierReport[],
|
||||||
|
params: PurchasesPerSupplierExportParams['params']
|
||||||
|
) => (
|
||||||
|
<Document>
|
||||||
|
<Page size='A3' orientation='landscape' style={pdfStyles.page}>
|
||||||
|
{/* Title and Parameters */}
|
||||||
|
<View style={pdfStyles.titleSection}>
|
||||||
|
<Text style={pdfStyles.mainTitle}>
|
||||||
|
Laporan > Rekapitulasi Pembelian Per Supplier
|
||||||
|
</Text>
|
||||||
|
<View style={pdfStyles.parameterContainer}>
|
||||||
|
<View style={pdfStyles.parameterBadge}>
|
||||||
|
<Text>
|
||||||
|
Jenis Tanggal:{' '}
|
||||||
|
{params.filter_by === 'received_date'
|
||||||
|
? 'Tanggal Terima'
|
||||||
|
: 'Tanggal PO'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{getParameterText(params).map((param, index) => (
|
||||||
|
<View key={index} style={pdfStyles.parameterBadge}>
|
||||||
|
<Text>{param}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Supplier Sections */}
|
||||||
|
{supplierReports.map(
|
||||||
|
(
|
||||||
|
supplierReport: LogisticPurchasePerSupplierReport,
|
||||||
|
supplierIndex: number
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={supplierReport.supplier.id}
|
||||||
|
style={[
|
||||||
|
pdfStyles.supplierSection,
|
||||||
|
supplierIndex < supplierReports.length - 1
|
||||||
|
? pdfStyles.supplierSectionBreak
|
||||||
|
: {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={pdfStyles.supplierTitle}>
|
||||||
|
{supplierReport.supplier.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={pdfStyles.table}>
|
||||||
|
{/* Table Header */}
|
||||||
|
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
|
||||||
|
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
|
||||||
|
<Text>No</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCellHeader}>
|
||||||
|
<Text>Tanggal Terima</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCellHeader}>
|
||||||
|
<Text>Tanggal PO</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCellHeader}>
|
||||||
|
<Text>Referensi</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCellHeader}>
|
||||||
|
<Text>Produk</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCellHeader}>
|
||||||
|
<Text>Tujuan</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
|
||||||
|
<Text>Qty</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
|
||||||
|
<Text>Harga Beli</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
|
||||||
|
<Text>Nilai Pembelian</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
|
||||||
|
<Text>Biaya Transport</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
|
||||||
|
<Text>Total</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
|
||||||
|
<Text>Armada</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCellHeaderLast}>
|
||||||
|
<Text>Surat Jalan</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
{supplierReport.rows.map(
|
||||||
|
(
|
||||||
|
item: LogisticPurchasePerSupplierReport['rows'][number],
|
||||||
|
index: number
|
||||||
|
) => (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
pdfStyles.tableRow,
|
||||||
|
index < supplierReport.rows.length - 1
|
||||||
|
? pdfStyles.tableBorderBottom
|
||||||
|
: {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
|
||||||
|
<Text>{index + 1}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCell}>
|
||||||
|
<Text>
|
||||||
|
{formatDate(item.receive_date, 'DD-MMM-YYYY')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCell}>
|
||||||
|
<Text>{formatDate(item.po_date, 'DD-MMM-YYYY')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCell}>
|
||||||
|
<Text>{item.po_number || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCell}>
|
||||||
|
<Text>{item.product?.name || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCell}>
|
||||||
|
<Text>{item.warehouse?.name || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
|
||||||
|
<Text>{formatNumber(item.qty || 0)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||||
|
<Text>{formatCurrency(item.unit_price || 0)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
|
||||||
|
<Text>{formatCurrency(item.purchase_value || 0)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||||
|
<Text>
|
||||||
|
{formatCurrency(item.transport_unit_price || 0)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
|
||||||
|
<Text>{formatCurrency(item.total_amount || 0)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
|
||||||
|
<View style={pdfStyles.badge}>
|
||||||
|
<Text>{item.expedition || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.tableCellLast}>
|
||||||
|
<Text>{item.delivery_number || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const generatePurchasesPerSupplierPDF = async (
|
||||||
|
data: LogisticPurchasePerSupplierReport[],
|
||||||
|
params: PurchasesPerSupplierExportParams['params']
|
||||||
|
): Promise<void> => {
|
||||||
|
const PDFDocument = createPDFDocument(data, params);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await pdf(PDFDocument).toBlob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `laporan-pembelian-per-supplier-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.pdf`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,932 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { ChangeEventHandler } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import Card from '@/components/Card';
|
||||||
|
import SelectInput, {
|
||||||
|
useSelect,
|
||||||
|
OptionType,
|
||||||
|
} from '@/components/input/SelectInput';
|
||||||
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
import { AreaApi } from '@/services/api/master-data';
|
||||||
|
import { SupplierApi } from '@/services/api/master-data';
|
||||||
|
import { ProductApi } from '@/services/api/master-data';
|
||||||
|
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||||
|
import { LogisticApi } from '@/services/api/report/logistic-stock';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||||
|
import {
|
||||||
|
LogisticPurchasePerSupplierReport,
|
||||||
|
LogisticPurchasePerSupplierSummary,
|
||||||
|
} from '@/types/api/report/logistic-stock';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import Pagination from '@/components/Pagination';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Dropdown from '@/components/Dropdown';
|
||||||
|
import MenuItem from '@/components/menu/MenuItem';
|
||||||
|
import Menu from '@/components/menu/Menu';
|
||||||
|
import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExport';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
const PurchasesPerSupplierTab = () => {
|
||||||
|
// ===== STATE MANAGEMENT =====
|
||||||
|
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||||
|
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||||
|
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||||
|
|
||||||
|
// ===== PAGINATION STATE =====
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
// ===== SUBMISSION STATE =====
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
|
||||||
|
// ===== TABLE FILTER STATE =====
|
||||||
|
const { state: tableFilterState, updateFilter } = useTableFilter({
|
||||||
|
initial: {
|
||||||
|
area_id: [] as string[],
|
||||||
|
supplier_id: [] as string[],
|
||||||
|
product_id: [] as string[],
|
||||||
|
product_category_id: [] as string[],
|
||||||
|
received_date: '',
|
||||||
|
po_date: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
sort_by: '',
|
||||||
|
filter_by: 'received_date',
|
||||||
|
},
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
|
||||||
|
AreaApi.basePath,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } =
|
||||||
|
useSelect(SupplierApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const { options: productOptions, isLoadingOptions: isLoadingProducts } =
|
||||||
|
useSelect(ProductApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const {
|
||||||
|
options: productCategoryOptions,
|
||||||
|
isLoadingOptions: isLoadingProductCategories,
|
||||||
|
} = useSelect(ProductCategoryApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const dataTypeOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'received_date', label: 'Tanggal Terima' },
|
||||||
|
{ value: 'po_date', label: 'Tanggal PO' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortByOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'ASC', label: 'Ascending' },
|
||||||
|
{ value: 'DESC', label: 'Descending' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const areaChangeHandler = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const arr = Array.isArray(val) ? val : val ? [val] : [];
|
||||||
|
updateFilter(
|
||||||
|
'area_id',
|
||||||
|
arr.map((v) => String((v as OptionType).value))
|
||||||
|
);
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const supplierChangeHandler = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const arr = Array.isArray(val) ? val : val ? [val] : [];
|
||||||
|
updateFilter(
|
||||||
|
'supplier_id',
|
||||||
|
arr.map((v) => String((v as OptionType).value))
|
||||||
|
);
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const productChangeHandler = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const arr = Array.isArray(val) ? val : val ? [val] : [];
|
||||||
|
updateFilter(
|
||||||
|
'product_id',
|
||||||
|
arr.map((v) => String((v as OptionType).value))
|
||||||
|
);
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const productCategoryChangeHandler = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const arr = Array.isArray(val) ? val : val ? [val] : [];
|
||||||
|
updateFilter(
|
||||||
|
'product_category_id',
|
||||||
|
arr.map((v) => String((v as OptionType).value))
|
||||||
|
);
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataTypeChangeHandler = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
const filterValue =
|
||||||
|
(newVal?.value as 'received_date' | 'po_date') || 'received_date';
|
||||||
|
updateFilter('filter_by', filterValue);
|
||||||
|
updateFilter('received_date', '');
|
||||||
|
updateFilter('po_date', '');
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortByHandler = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
const sortValue = (newVal?.value as 'ASC' | 'DESC') || 'ASC';
|
||||||
|
updateFilter('sort_by', sortValue);
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startDateChangeHandler = useCallback<
|
||||||
|
ChangeEventHandler<HTMLInputElement>
|
||||||
|
>(
|
||||||
|
(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
updateFilter('start_date', val || '');
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const endDateChangeHandler = useCallback<
|
||||||
|
ChangeEventHandler<HTMLInputElement>
|
||||||
|
>(
|
||||||
|
(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
updateFilter('end_date', val || '');
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
updateFilter('area_id', []);
|
||||||
|
updateFilter('supplier_id', []);
|
||||||
|
updateFilter('product_id', []);
|
||||||
|
updateFilter('product_category_id', []);
|
||||||
|
updateFilter('received_date', '');
|
||||||
|
updateFilter('po_date', '');
|
||||||
|
updateFilter('start_date', '');
|
||||||
|
updateFilter('end_date', '');
|
||||||
|
updateFilter('sort_by', '');
|
||||||
|
updateFilter('filter_by', 'received_date');
|
||||||
|
setIsSubmitted(false);
|
||||||
|
}, [updateFilter]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ===== DATA FETCHING =====
|
||||||
|
const { data: purchasePerSupplier, isLoading } = useSWR(
|
||||||
|
isSubmitted
|
||||||
|
? () => {
|
||||||
|
const params = {
|
||||||
|
area_id:
|
||||||
|
tableFilterState.area_id.length > 0
|
||||||
|
? tableFilterState.area_id.join(',')
|
||||||
|
: undefined,
|
||||||
|
supplier_id:
|
||||||
|
tableFilterState.supplier_id.length > 0
|
||||||
|
? tableFilterState.supplier_id.join(',')
|
||||||
|
: undefined,
|
||||||
|
product_id:
|
||||||
|
tableFilterState.product_id.length > 0
|
||||||
|
? tableFilterState.product_id.join(',')
|
||||||
|
: undefined,
|
||||||
|
product_category_id:
|
||||||
|
tableFilterState.product_category_id.length > 0
|
||||||
|
? tableFilterState.product_category_id.join(',')
|
||||||
|
: undefined,
|
||||||
|
received_date:
|
||||||
|
tableFilterState.filter_by === 'received_date'
|
||||||
|
? tableFilterState.start_date || undefined
|
||||||
|
: undefined,
|
||||||
|
po_date:
|
||||||
|
tableFilterState.filter_by === 'po_date'
|
||||||
|
? tableFilterState.start_date || undefined
|
||||||
|
: undefined,
|
||||||
|
start_date: tableFilterState.start_date || undefined,
|
||||||
|
end_date: tableFilterState.end_date || undefined,
|
||||||
|
sort_by: tableFilterState.sort_by || undefined,
|
||||||
|
filter_by: tableFilterState.filter_by || undefined,
|
||||||
|
page: currentPage,
|
||||||
|
limit: pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ['logistic-purchase-report', params];
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
([, params]) =>
|
||||||
|
LogisticApi.getLogisticPurchasePerSupplierReport(
|
||||||
|
params.area_id,
|
||||||
|
params.supplier_id,
|
||||||
|
params.product_id,
|
||||||
|
params.product_category_id,
|
||||||
|
params.received_date,
|
||||||
|
params.po_date,
|
||||||
|
params.start_date,
|
||||||
|
params.end_date,
|
||||||
|
params.sort_by,
|
||||||
|
params.filter_by,
|
||||||
|
params.page,
|
||||||
|
params.limit
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: LogisticPurchasePerSupplierReport[] = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(purchasePerSupplier)
|
||||||
|
? (purchasePerSupplier?.data as unknown as LogisticPurchasePerSupplierReport[]) ||
|
||||||
|
[]
|
||||||
|
: [],
|
||||||
|
[purchasePerSupplier]
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta =
|
||||||
|
isResponseSuccess(purchasePerSupplier) && purchasePerSupplier?.meta
|
||||||
|
? purchasePerSupplier.meta
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// ===== EXPORT DATA FETCHER =====
|
||||||
|
const logisticPurchasePerSupplierExport = useCallback(async (): Promise<
|
||||||
|
LogisticPurchasePerSupplierReport[] | null
|
||||||
|
> => {
|
||||||
|
const params = {
|
||||||
|
area_id:
|
||||||
|
tableFilterState.area_id.length > 0
|
||||||
|
? tableFilterState.area_id.join(',')
|
||||||
|
: undefined,
|
||||||
|
supplier_id:
|
||||||
|
tableFilterState.supplier_id.length > 0
|
||||||
|
? tableFilterState.supplier_id.join(',')
|
||||||
|
: undefined,
|
||||||
|
product_id:
|
||||||
|
tableFilterState.product_id.length > 0
|
||||||
|
? tableFilterState.product_id.join(',')
|
||||||
|
: undefined,
|
||||||
|
product_category_id:
|
||||||
|
tableFilterState.product_category_id.length > 0
|
||||||
|
? tableFilterState.product_category_id.join(',')
|
||||||
|
: undefined,
|
||||||
|
received_date:
|
||||||
|
tableFilterState.filter_by === 'received_date'
|
||||||
|
? tableFilterState.start_date || undefined
|
||||||
|
: undefined,
|
||||||
|
po_date:
|
||||||
|
tableFilterState.filter_by === 'po_date'
|
||||||
|
? tableFilterState.start_date || undefined
|
||||||
|
: undefined,
|
||||||
|
start_date: tableFilterState.start_date || undefined,
|
||||||
|
end_date: tableFilterState.end_date || undefined,
|
||||||
|
sort_by: tableFilterState.sort_by || undefined,
|
||||||
|
filter_by: tableFilterState.filter_by || undefined,
|
||||||
|
limit: 100,
|
||||||
|
page: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await LogisticApi.getLogisticPurchasePerSupplierReport(
|
||||||
|
params.area_id,
|
||||||
|
params.supplier_id,
|
||||||
|
params.product_id,
|
||||||
|
params.product_category_id,
|
||||||
|
params.received_date,
|
||||||
|
params.po_date,
|
||||||
|
params.start_date,
|
||||||
|
params.end_date,
|
||||||
|
params.sort_by,
|
||||||
|
params.filter_by,
|
||||||
|
params.page,
|
||||||
|
params.limit
|
||||||
|
);
|
||||||
|
|
||||||
|
return isResponseSuccess(response)
|
||||||
|
? (response.data as unknown as LogisticPurchasePerSupplierReport[])
|
||||||
|
: null;
|
||||||
|
}, [tableFilterState]);
|
||||||
|
|
||||||
|
// ===== EXPORT HANDLERS =====
|
||||||
|
const handleExportExcel = useCallback(async () => {
|
||||||
|
setIsExcelExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allDataForExport = await logisticPurchasePerSupplierExport();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!allDataForExport ||
|
||||||
|
!Array.isArray(allDataForExport) ||
|
||||||
|
allDataForExport.length === 0
|
||||||
|
) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
allDataForExport.forEach((supplierReport) => {
|
||||||
|
const supplierData = supplierReport.rows;
|
||||||
|
const supplierName =
|
||||||
|
supplierReport.supplier?.name || 'Unknown Supplier';
|
||||||
|
|
||||||
|
const excelData: { [key: string]: string | number }[] =
|
||||||
|
supplierData.map((item, index) => ({
|
||||||
|
No: index + 1,
|
||||||
|
'Tanggal Terima': item.receive_date
|
||||||
|
? formatDate(item.receive_date, 'DD MMM YYYY')
|
||||||
|
: '',
|
||||||
|
'Tanggal PO': item.po_date
|
||||||
|
? formatDate(item.po_date, 'DD MMM YYYY')
|
||||||
|
: '',
|
||||||
|
'No. Referensi': item.po_number || '',
|
||||||
|
'Nama Produk': item.product?.name || '',
|
||||||
|
Tujuan: item.warehouse?.name || '',
|
||||||
|
QTY: item.qty || 0,
|
||||||
|
'Harga Beli (Rp)': item.unit_price || 0,
|
||||||
|
'Value Harga Beli (Rp)': item.purchase_value || 0,
|
||||||
|
'Transport (Rp)': item.transport_unit_price || 0,
|
||||||
|
'Value Transport (Rp)': item.transport_value || 0,
|
||||||
|
'Jumlah (Rp)': item.total_amount || 0,
|
||||||
|
Ekspedisi: item.expedition || '',
|
||||||
|
'Surat Jalan': item.delivery_number || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (supplierReport.summary) {
|
||||||
|
excelData.push({
|
||||||
|
No: 'Total',
|
||||||
|
'Tanggal Terima': '',
|
||||||
|
'Tanggal PO': '',
|
||||||
|
'No. Referensi': '',
|
||||||
|
'Nama Produk': '',
|
||||||
|
Tujuan: '',
|
||||||
|
QTY: supplierReport.summary.total_qty || 0,
|
||||||
|
'Harga Beli (Rp)': '',
|
||||||
|
'Value Harga Beli (Rp)':
|
||||||
|
supplierReport.summary.total_purchase_value || 0,
|
||||||
|
'Transport (Rp)': '',
|
||||||
|
'Value Transport (Rp)':
|
||||||
|
supplierReport.summary.total_transport_value || 0,
|
||||||
|
'Jumlah (Rp)': supplierReport.summary.total_amount || 0,
|
||||||
|
Ekspedisi: '',
|
||||||
|
'Surat Jalan': '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
||||||
|
|
||||||
|
const colWidths = [
|
||||||
|
{ wch: 5 }, // No
|
||||||
|
{ wch: 15 }, // Tanggal Terima
|
||||||
|
{ wch: 15 }, // Tanggal PO
|
||||||
|
{ wch: 15 }, // No. Referensi
|
||||||
|
{ wch: 30 }, // Nama Produk
|
||||||
|
{ wch: 20 }, // Tujuan
|
||||||
|
{ wch: 10 }, // QTY
|
||||||
|
{ wch: 18 }, // Harga Beli
|
||||||
|
{ wch: 20 }, // Value Harga Beli
|
||||||
|
{ wch: 15 }, // Transport
|
||||||
|
{ wch: 20 }, // Value Transport
|
||||||
|
{ wch: 18 }, // Jumlah
|
||||||
|
{ wch: 15 }, // Ekspedisi
|
||||||
|
{ wch: 15 }, // Surat Jalan
|
||||||
|
];
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
const sheetName =
|
||||||
|
supplierName.length > 31
|
||||||
|
? supplierName.substring(0, 31)
|
||||||
|
: supplierName;
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `laporan-pembelian-per-supplier-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
|
||||||
|
|
||||||
|
XLSX.writeFile(workbook, filename);
|
||||||
|
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsExcelExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
logisticPurchasePerSupplierExport,
|
||||||
|
tableFilterState,
|
||||||
|
areaOptions,
|
||||||
|
supplierOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleExportPdf = useCallback(async () => {
|
||||||
|
setIsPdfExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allDataForExport = await logisticPurchasePerSupplierExport();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!allDataForExport ||
|
||||||
|
!Array.isArray(allDataForExport) ||
|
||||||
|
allDataForExport.length === 0
|
||||||
|
) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaName =
|
||||||
|
tableFilterState.area_id.length > 0
|
||||||
|
? tableFilterState.area_id
|
||||||
|
.map(
|
||||||
|
(id) =>
|
||||||
|
areaOptions.find((opt) => opt.value === Number(id))?.label
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || 'Semua Area'
|
||||||
|
: 'Semua Area';
|
||||||
|
|
||||||
|
const supplierName =
|
||||||
|
tableFilterState.supplier_id.length > 0
|
||||||
|
? tableFilterState.supplier_id
|
||||||
|
.map(
|
||||||
|
(id) =>
|
||||||
|
supplierOptions.find((opt) => opt.value === Number(id))?.label
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || 'Semua Supplier'
|
||||||
|
: 'Semua Supplier';
|
||||||
|
|
||||||
|
const productName =
|
||||||
|
tableFilterState.product_id.length > 0
|
||||||
|
? tableFilterState.product_id
|
||||||
|
.map(
|
||||||
|
(id) =>
|
||||||
|
productOptions.find((opt) => opt.value === Number(id))?.label
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || 'Semua Produk'
|
||||||
|
: 'Semua Produk';
|
||||||
|
|
||||||
|
const productCategoryName =
|
||||||
|
tableFilterState.product_category_id.length > 0
|
||||||
|
? tableFilterState.product_category_id
|
||||||
|
.map(
|
||||||
|
(id) =>
|
||||||
|
productCategoryOptions.find((opt) => opt.value === Number(id))
|
||||||
|
?.label
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || 'Semua Kategori Produk'
|
||||||
|
: 'Semua Kategori Produk';
|
||||||
|
|
||||||
|
const exportParams = {
|
||||||
|
area_name: areaName,
|
||||||
|
supplier_name: supplierName,
|
||||||
|
product_name: productName,
|
||||||
|
product_category_name: productCategoryName,
|
||||||
|
filter_by: tableFilterState.filter_by || 'received_date',
|
||||||
|
start_date: tableFilterState.start_date || '',
|
||||||
|
end_date: tableFilterState.end_date || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await generatePurchasesPerSupplierPDF(allDataForExport, exportParams);
|
||||||
|
toast.success('PDF berhasil dibuat dan diunduh.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsPdfExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
logisticPurchasePerSupplierExport,
|
||||||
|
tableFilterState,
|
||||||
|
areaOptions,
|
||||||
|
supplierOptions,
|
||||||
|
productOptions,
|
||||||
|
productCategoryOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ===== PAGINATION HANDLERS =====
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowChange = (pageSize: number) => {
|
||||||
|
setPageSize(pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (meta && currentPage < meta.total_pages) {
|
||||||
|
setCurrentPage(currentPage + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevPage = () => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
setCurrentPage(currentPage - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTableColumns = (
|
||||||
|
summary: LogisticPurchasePerSupplierSummary
|
||||||
|
): ColumnDef<LogisticPurchasePerSupplierReport['rows'][0]>[] => {
|
||||||
|
const tableColumns: ColumnDef<
|
||||||
|
LogisticPurchasePerSupplierReport['rows'][0]
|
||||||
|
>[] = [
|
||||||
|
{
|
||||||
|
id: 'no',
|
||||||
|
header: 'No',
|
||||||
|
cell: (props) => props.row.index + 1,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>Total</div>,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'received_date',
|
||||||
|
header: 'Tanggal Terima',
|
||||||
|
accessorKey: 'receive_date',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.receive_date;
|
||||||
|
return formatDate(value, 'DD MMM YYYY');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'po_date',
|
||||||
|
header: 'Tanggal PO',
|
||||||
|
accessorKey: 'po_date',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.po_date;
|
||||||
|
return formatDate(value, 'DD MMM YYYY');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'po_number',
|
||||||
|
header: 'No. Referensi',
|
||||||
|
accessorKey: 'po_number',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.po_number;
|
||||||
|
return value || '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'product_name',
|
||||||
|
header: 'Nama Produk',
|
||||||
|
accessorKey: 'product.name',
|
||||||
|
cell: (props) => {
|
||||||
|
const product = props.row.original.product;
|
||||||
|
return product?.name || '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'destination_warehouse',
|
||||||
|
header: 'Tujuan',
|
||||||
|
accessorKey: 'warehouse.name',
|
||||||
|
cell: (props) => {
|
||||||
|
const warehouse = props.row.original.warehouse;
|
||||||
|
return warehouse?.name || '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qty',
|
||||||
|
header: 'QTY',
|
||||||
|
accessorKey: 'qty',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.qty;
|
||||||
|
return <div className='text-right'>{formatNumber(value)}</div>;
|
||||||
|
},
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{formatNumber(summary.total_qty) || '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'price',
|
||||||
|
header: 'Harga Beli (Rp)',
|
||||||
|
accessorKey: 'unit_price',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.unit_price;
|
||||||
|
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||||
|
},
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{formatCurrency(summary.total_unit_price) || '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'purchase_amount',
|
||||||
|
header: 'Value Harga Beli (Rp)',
|
||||||
|
accessorKey: 'purchase_value',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.purchase_value;
|
||||||
|
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||||
|
},
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{formatCurrency(summary.total_purchase_value) || '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'transport',
|
||||||
|
header: 'Transport (Rp)',
|
||||||
|
accessorKey: 'transport_unit_price',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.transport_unit_price;
|
||||||
|
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||||
|
},
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{formatCurrency(summary.total_transport_unit_price) || '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'value_transport',
|
||||||
|
header: 'Value Transport (Rp)',
|
||||||
|
accessorKey: 'transport_value',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.transport_value;
|
||||||
|
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||||
|
},
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{formatCurrency(summary.total_transport_value) || '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
header: 'Jumlah (Rp)',
|
||||||
|
accessorKey: 'total_amount',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.total_amount;
|
||||||
|
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||||
|
},
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{formatCurrency(summary.total_amount) || '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'expedition_vendor_name',
|
||||||
|
header: 'Ekspedisi',
|
||||||
|
accessorKey: 'expedition',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.expedition;
|
||||||
|
return value || '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'travel_number',
|
||||||
|
header: 'Surat Jalan',
|
||||||
|
accessorKey: 'delivery_number',
|
||||||
|
cell: (props) => {
|
||||||
|
const value = props.row.original.delivery_number;
|
||||||
|
return value || '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return tableColumns;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-0 sm:p-4'>
|
||||||
|
<Card
|
||||||
|
subtitle='Laporan > Rekapitulasi Pembelian Per Supplier'
|
||||||
|
className={{ wrapper: 'w-full', body: 'p-1!' }}
|
||||||
|
>
|
||||||
|
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
|
||||||
|
<Button color='primary' onClick={handleSubmit}>
|
||||||
|
Cari
|
||||||
|
</Button>
|
||||||
|
<Button color='warning' onClick={resetFilters}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<Button color='success' isLoading={isAnyExportLoading}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
align='end'
|
||||||
|
>
|
||||||
|
<Menu className='w-32'>
|
||||||
|
<MenuItem title='Excel' onClick={handleExportExcel} />
|
||||||
|
<MenuItem title='PDF' onClick={handleExportPdf} />
|
||||||
|
</Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
|
||||||
|
<SelectInput
|
||||||
|
label='Area'
|
||||||
|
placeholder='Pilih Area'
|
||||||
|
isMulti
|
||||||
|
options={areaOptions}
|
||||||
|
value={areaOptions.filter((opt) =>
|
||||||
|
(tableFilterState.area_id || [])
|
||||||
|
.map(String)
|
||||||
|
.includes(String(opt.value))
|
||||||
|
)}
|
||||||
|
onChange={areaChangeHandler}
|
||||||
|
isLoading={isLoadingAreas}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
<SelectInput
|
||||||
|
label='Supplier'
|
||||||
|
placeholder='Pilih Supplier'
|
||||||
|
isMulti
|
||||||
|
options={supplierOptions}
|
||||||
|
value={supplierOptions.filter((opt) =>
|
||||||
|
(tableFilterState.supplier_id || [])
|
||||||
|
.map(String)
|
||||||
|
.includes(String(opt.value))
|
||||||
|
)}
|
||||||
|
onChange={supplierChangeHandler}
|
||||||
|
isLoading={isLoadingSuppliers}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
<SelectInput
|
||||||
|
label='Produk'
|
||||||
|
placeholder='Pilih Produk'
|
||||||
|
isMulti
|
||||||
|
options={productOptions}
|
||||||
|
value={productOptions.filter((opt) =>
|
||||||
|
(tableFilterState.product_id || [])
|
||||||
|
.map(String)
|
||||||
|
.includes(String(opt.value))
|
||||||
|
)}
|
||||||
|
onChange={productChangeHandler}
|
||||||
|
isLoading={isLoadingProducts}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
|
||||||
|
<SelectInput
|
||||||
|
label='Kategori Produk'
|
||||||
|
placeholder='Pilih Kategori Produk'
|
||||||
|
isMulti
|
||||||
|
options={productCategoryOptions}
|
||||||
|
value={productCategoryOptions.filter((opt) =>
|
||||||
|
(tableFilterState.product_category_id || [])
|
||||||
|
.map(String)
|
||||||
|
.includes(String(opt.value))
|
||||||
|
)}
|
||||||
|
onChange={productCategoryChangeHandler}
|
||||||
|
isLoading={isLoadingProductCategories}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
<div className='md:flex md:flex-row grid grid-cols-1 gap-4'>
|
||||||
|
<SelectInput
|
||||||
|
label='Filter Berdasarkan'
|
||||||
|
placeholder='Pilih Filter Berdasarkan'
|
||||||
|
options={dataTypeOptions}
|
||||||
|
value={
|
||||||
|
dataTypeOptions?.find(
|
||||||
|
(option) => option.value === tableFilterState.filter_by
|
||||||
|
) || null
|
||||||
|
}
|
||||||
|
onChange={dataTypeChangeHandler}
|
||||||
|
isLoading={false}
|
||||||
|
isClearable={false}
|
||||||
|
/>
|
||||||
|
<SelectInput
|
||||||
|
label='Urutkan Berdasarkan'
|
||||||
|
placeholder='Urutkan Berdasarkan'
|
||||||
|
options={sortByOptions}
|
||||||
|
value={
|
||||||
|
sortByOptions?.find(
|
||||||
|
(option) => option.value === tableFilterState.sort_by
|
||||||
|
) || null
|
||||||
|
}
|
||||||
|
onChange={sortByHandler}
|
||||||
|
isLoading={false}
|
||||||
|
isClearable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='md:flex md:flex-row grid grid-cols-1 gap-4'>
|
||||||
|
<DateInput
|
||||||
|
label='Tanggal Awal'
|
||||||
|
name='start_date'
|
||||||
|
placeholder='Pilih Tanggal Awal'
|
||||||
|
value={tableFilterState.start_date}
|
||||||
|
onChange={startDateChangeHandler}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label='Tanggal Akhir'
|
||||||
|
name='end_date'
|
||||||
|
placeholder='Pilih Tanggal Akhir'
|
||||||
|
value={tableFilterState.end_date}
|
||||||
|
onChange={endDateChangeHandler}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSubmitted ? (
|
||||||
|
<div className='mt-6 text-center text-gray-500'>
|
||||||
|
Silakan pilih filter dan klik tombol Submit untuk menampilkan data.
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className='mt-6 text-center text-gray-500'>
|
||||||
|
Tidak ada data yang dapat ditampilkan...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((supplierReport) => {
|
||||||
|
const summary = supplierReport.summary || {
|
||||||
|
total_qty: 0,
|
||||||
|
total_unit_price: 0,
|
||||||
|
total_purchase_value: 0,
|
||||||
|
total_transport_unit_price: 0,
|
||||||
|
total_transport_value: 0,
|
||||||
|
total_amount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPurchase = summary.total_amount;
|
||||||
|
const tableColumns = getTableColumns(summary);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={supplierReport.supplier.id}
|
||||||
|
title={supplierReport.supplier.name}
|
||||||
|
subtitle={`Total Pembelian: ${formatCurrency(totalPurchase)}`}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
variant='bordered'
|
||||||
|
collapsible={true}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
data={supplierReport.rows}
|
||||||
|
columns={tableColumns}
|
||||||
|
pageSize={10}
|
||||||
|
renderFooter={supplierReport.rows.length > 0}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full',
|
||||||
|
tableWrapperClassName: 'overflow-x-auto mt-4',
|
||||||
|
tableClassName: 'w-full table-auto text-sm',
|
||||||
|
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
|
||||||
|
headerColumnClassName:
|
||||||
|
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
|
||||||
|
bodyRowClassName:
|
||||||
|
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
|
tableFooterClassName:
|
||||||
|
'bg-gray-100 font-semibold border border-gray-200',
|
||||||
|
footerRowClassName: 'border-t-2 border-gray-300',
|
||||||
|
footerColumnClassName:
|
||||||
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
|
paginationClassName: 'hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
{meta && data.length > 0 && (
|
||||||
|
<div className='mt-6'>
|
||||||
|
<Pagination
|
||||||
|
currentPage={meta.page}
|
||||||
|
totalItems={meta.total_results}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onRowChange={handleRowChange}
|
||||||
|
onNextPage={handleNextPage}
|
||||||
|
onPrevPage={handlePrevPage}
|
||||||
|
rowOptions={[10, 25, 50, 100]}
|
||||||
|
itemsPerPage={meta.limit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchasesPerSupplierTab;
|
||||||
@@ -45,6 +45,17 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
|||||||
link: '/closing',
|
link: '/closing',
|
||||||
icon: 'heroicons-outline:presentation-chart-bar',
|
icon: 'heroicons-outline:presentation-chart-bar',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Laporan',
|
||||||
|
link: '/report',
|
||||||
|
icon: 'mdi:chart-box-outline',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
text: 'Logistik & Persediaan',
|
||||||
|
link: '/report/logistic-stock',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Persediaan',
|
text: 'Persediaan',
|
||||||
link: '/inventory',
|
link: '/inventory',
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { BaseApiService } from '@/services/api/base';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
|
||||||
|
|
||||||
|
export class LogisticApiService extends BaseApiService<
|
||||||
|
LogisticPurchasePerSupplierReport,
|
||||||
|
unknown,
|
||||||
|
unknown
|
||||||
|
> {
|
||||||
|
constructor(basePath: string) {
|
||||||
|
super(basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogisticPurchasePerSupplierReport(
|
||||||
|
area_id?: string,
|
||||||
|
supplier_id?: string,
|
||||||
|
product_id?: string,
|
||||||
|
product_category_id?: string,
|
||||||
|
received_date?: string,
|
||||||
|
po_date?: string,
|
||||||
|
start_date?: string,
|
||||||
|
end_date?: string,
|
||||||
|
sort_by?: string,
|
||||||
|
filter_by?: string,
|
||||||
|
page?: number,
|
||||||
|
limit?: number
|
||||||
|
): Promise<BaseApiResponse<LogisticPurchasePerSupplierReport> | undefined> {
|
||||||
|
return await this.customRequest<
|
||||||
|
BaseApiResponse<LogisticPurchasePerSupplierReport>
|
||||||
|
>(`purchase-supplier`, {
|
||||||
|
method: 'GET',
|
||||||
|
params: {
|
||||||
|
area_id: area_id,
|
||||||
|
supplier_id: supplier_id,
|
||||||
|
product_id: product_id,
|
||||||
|
product_category_id: product_category_id,
|
||||||
|
received_date: received_date,
|
||||||
|
po_date: po_date,
|
||||||
|
start_date: start_date,
|
||||||
|
end_date: end_date,
|
||||||
|
sort_by: sort_by,
|
||||||
|
filter_by: filter_by,
|
||||||
|
page: page,
|
||||||
|
limit: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogisticApi = new LogisticApiService('reports');
|
||||||
|
|
||||||
|
// export const LogisticApi = new LogisticApiService(
|
||||||
|
// 'http://localhost:4010/api/reports/logistics'
|
||||||
|
// );
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
|
import { Supplier } from '@/types/api/supplier/supplier';
|
||||||
|
import { Product } from '@/types/api/product/product';
|
||||||
|
import { Warehouse } from '@/types/api/warehouse/warehouse';
|
||||||
|
|
||||||
|
export type LogisticPurchasePerSupplierReportRow = {
|
||||||
|
receive_date: string;
|
||||||
|
po_date: string;
|
||||||
|
po_number: string;
|
||||||
|
product: Product;
|
||||||
|
warehouse: Warehouse;
|
||||||
|
qty: number;
|
||||||
|
unit_price: number;
|
||||||
|
purchase_value: number;
|
||||||
|
transport_unit_price: number;
|
||||||
|
transport_value: number;
|
||||||
|
total_amount: number;
|
||||||
|
expedition: string;
|
||||||
|
delivery_number: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogisticPurchasePerSupplierSummary = {
|
||||||
|
total_qty: number;
|
||||||
|
total_unit_price: number;
|
||||||
|
total_purchase_value: number;
|
||||||
|
total_transport_unit_price: number;
|
||||||
|
total_transport_value: number;
|
||||||
|
total_amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogisticPurchasePerSupplierReport = BaseMetadata & {
|
||||||
|
supplier: Supplier;
|
||||||
|
rows: LogisticPurchasePerSupplierReportRow[];
|
||||||
|
summary: LogisticPurchasePerSupplierSummary;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user