Merge branch 'feat/FE/US-285/TASK-326-327-slicing-and-integration-marketing-closing-report' into 'feat/FE/US-285/marketing-closing-report'

[FEAT/FE][US#285/TASK#326-327] Add Feature Marketing Closing Report (Sales/Penjualan)

See merge request mbugroup/lti-web-client!70
This commit is contained in:
Rivaldi A N S
2025-12-06 10:25:38 +00:00
18 changed files with 627 additions and 63 deletions
+3
View File
@@ -42,3 +42,6 @@ next-env.d.ts
# idea
.idea
# claude
.claude
+21 -3
View File
@@ -15,8 +15,24 @@ stages:
script:
- echo "Installing dependencies..."
- npm ci --no-audit --no-fund
- echo "Build env used:"
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_URL"
- echo "NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL"
- echo "Building Next.js static export..."
- npx next build
- |
mkdir -p out
cat <<EOF > out/build-info.json
{
"commit": "$CI_COMMIT_SHORT_SHA",
"pipeline": "$CI_PIPELINE_ID",
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_URL",
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL"
}
EOF
artifacts:
name: 'out-$CI_COMMIT_SHORT_SHA'
paths:
@@ -106,8 +122,11 @@ build:dev:
environment:
name: development
variables:
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id'
# NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id'
# NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id'
NEXT_PUBLIC_LTI_URL: 'https://dev-lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
deploy:dev:
<<: *deploy_template
@@ -142,5 +161,4 @@ deploy:dev:
# CLOUDFRONT_DISTRIBUTION_ID: "ddfd"
# environment:
# name: production
# url: https://royalgoldcapital.com
+51 -41
View File
@@ -15,7 +15,7 @@
"clsx": "^2.1.1",
"formik": "^2.4.6",
"moment": "^2.30.1",
"next": "15.5.3",
"next": "^15.5.7",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
@@ -1082,9 +1082,9 @@
}
},
"node_modules/@next/env": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz",
"integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1098,9 +1098,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz",
"integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
"cpu": [
"arm64"
],
@@ -1114,9 +1114,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz",
"integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
"cpu": [
"x64"
],
@@ -1130,9 +1130,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz",
"integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
"cpu": [
"arm64"
],
@@ -1146,9 +1146,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz",
"integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
"cpu": [
"arm64"
],
@@ -1162,9 +1162,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz",
"integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
"cpu": [
"x64"
],
@@ -1178,9 +1178,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz",
"integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
"cpu": [
"x64"
],
@@ -1194,9 +1194,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz",
"integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
"cpu": [
"arm64"
],
@@ -1210,9 +1210,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz",
"integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
"cpu": [
"x64"
],
@@ -1855,6 +1855,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1924,6 +1925,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -2447,6 +2449,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3060,7 +3063,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/daisyui": {
"version": "5.3.10",
@@ -3516,6 +3520,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3689,6 +3694,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5654,12 +5660,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz",
"integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==",
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.3",
"@next/env": "15.5.7",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -5672,14 +5678,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.3",
"@next/swc-darwin-x64": "15.5.3",
"@next/swc-linux-arm64-gnu": "15.5.3",
"@next/swc-linux-arm64-musl": "15.5.3",
"@next/swc-linux-x64-gnu": "15.5.3",
"@next/swc-linux-x64-musl": "15.5.3",
"@next/swc-win32-arm64-msvc": "15.5.3",
"@next/swc-win32-x64-msvc": "15.5.3",
"@next/swc-darwin-arm64": "15.5.7",
"@next/swc-darwin-x64": "15.5.7",
"@next/swc-linux-arm64-gnu": "15.5.7",
"@next/swc-linux-arm64-musl": "15.5.7",
"@next/swc-linux-x64-gnu": "15.5.7",
"@next/swc-linux-x64-musl": "15.5.7",
"@next/swc-win32-arm64-msvc": "15.5.7",
"@next/swc-win32-x64-msvc": "15.5.7",
"sharp": "^0.34.3"
},
"peerDependencies": {
@@ -6167,6 +6173,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6197,6 +6204,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -7083,6 +7091,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7250,6 +7259,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+1 -1
View File
@@ -18,7 +18,7 @@
"clsx": "^2.1.1",
"formik": "^2.4.6",
"moment": "^2.30.1",
"next": "15.5.3",
"next": "^15.5.7",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable';
import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
const ClosingDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const closingId = searchParams.get('closingId');
const { data: closing, isLoading: isLoadingClosing } = useSWR(
closingId,
(id: string) => {
const numericId = parseInt(id, 10);
if (isNaN(numericId) || numericId <= 0) {
throw new Error('Invalid closing ID');
}
return ClosingApi.getPenjualan(numericId);
}
);
if (!closingId) {
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 (!isLoadingClosing && (!closing || isResponseError(closing))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoadingClosing && (
<div className='w-full flex flex-row justify-center items-center'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoadingClosing && isResponseSuccess(closing) && (
<SalesReportTable type='detail' initialValues={closing.data} />
)}
</div>
);
};
export default ClosingDetailPage;
+1 -1
View File
@@ -4,7 +4,7 @@ import { HTMLAttributes, ReactNode, useState } from 'react';
import { cn } from '@/lib/helper';
import Image from 'next/image';
import Collapse from './Collapse';
import Collapse from '@/components/Collapse';
import { Icon } from '@iconify/react';
export interface CardProps
+40
View File
@@ -31,6 +31,9 @@ interface TableClassNames {
tableBodyClassName?: string;
bodyRowClassName?: string;
bodyColumnClassName?: string;
tableFooterClassName?: string;
footerRowClassName?: string;
footerColumnClassName?: string;
paginationClassName?: string;
}
@@ -52,6 +55,9 @@ export interface TableProps<TData extends object> {
rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
renderFooter?: boolean;
footerContent?: ReactNode;
footerData?: TData[];
}
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -84,6 +90,9 @@ const Table = <TData extends object>({
tableBodyClassName: '',
bodyRowClassName: '',
bodyColumnClassName: '',
tableFooterClassName: '',
footerRowClassName: '',
footerColumnClassName: '',
paginationClassName: '',
},
emptyContent = emptyContentDefaultValue,
@@ -93,6 +102,9 @@ const Table = <TData extends object>({
rowSelection,
setRowSelection,
enableRowSelection,
renderFooter = false,
footerContent,
footerData = [],
}: TableProps<TData>) => {
const isServerSideTable =
totalItems !== undefined &&
@@ -160,6 +172,14 @@ const Table = <TData extends object>({
const table = useReactTable(tableOptions);
const { setPageSize } = table;
const footerTableOptions: TableOptions<TData> = {
columns,
data: footerData,
getCoreRowModel: getCoreRowModel(),
};
const footerTable = useReactTable(footerTableOptions);
const prevPageClickHandler = () => {
table.previousPage();
@@ -262,6 +282,26 @@ const Table = <TData extends object>({
</tr>
))}
</tbody>
<tfoot className={cn(className.tableFooterClassName)}>
{renderFooter &&
(footerData && footerData.length > 0
? footerTable.getRowModel().rows.map((row) => (
<tr key={row.id} className={className.footerRowClassName}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={className.footerColumnClassName}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
: footerContent)}
</tfoot>
</table>
</div>
@@ -0,0 +1,374 @@
'use client';
import React, { useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Badge from '@/components/Badge';
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
import { BaseClosingSales, BaseSales } from '@/types/api/closing/closing';
import { Product } from '@/types/api/master-data/product';
import { Customer } from '@/types/api/master-data/customer';
import { Kandang } from '@/types/api/master-data/kandang';
interface SalesReportTableProps {
type?: 'detail';
initialValues?: BaseClosingSales;
}
interface FooterSalesRow extends BaseSales {
_isFooter: true;
}
const SalesReportTable = ({
type = 'detail',
initialValues,
}: SalesReportTableProps) => {
const salesData: BaseSales[] = useMemo(() => {
return initialValues?.sales || [];
}, [initialValues]);
const totals = useMemo(() => {
if (salesData.length === 0) {
return {
totalQuantity: 0,
totalWeight: 0,
avgWeight: 0,
avgPricePartner: 0,
totalPartner: 0,
};
}
const totalQuantity = salesData.reduce(
(sum, item) => sum + (item.qty || 0),
0
);
const totalWeight = salesData.reduce(
(sum, item) => sum + (item.weight || 0),
0
);
const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0;
const validPriceItems = salesData.filter(
(item) => item.price != null && item.price > 0
);
const avgPricePartner =
validPriceItems.length > 0
? validPriceItems.reduce((sum, item) => sum + item.price, 0) /
validPriceItems.length
: 0;
const totalPartner = salesData.reduce(
(sum, item) => sum + (item.total_price || 0),
0
);
return {
totalQuantity,
totalWeight,
avgWeight,
avgPricePartner,
totalPartner,
};
}, [salesData]);
const footerData = useMemo((): FooterSalesRow[] => {
if (salesData.length === 0) return [];
const footerRow: FooterSalesRow = {
id: -999,
realization_date: 'Total Penjualan',
age: 0,
do_number: '',
product: {} as Product,
customer: {} as Customer,
qty: totals.totalQuantity,
weight: totals.totalWeight,
avg_weight: totals.avgWeight,
price: totals.avgPricePartner,
total_price: totals.totalPartner,
kandang: {} as Kandang,
payment_status: '',
_isFooter: true,
};
return [footerRow];
}, [salesData, totals]);
const salesColumns: ColumnDef<BaseSales>[] = useMemo(
() => [
{
id: 'realization_date',
accessorKey: 'realization_date',
header: 'Tanggal Realisasi',
cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) {
return (
<div className='font-semibold text-gray-900 col-span-5'>
{props.row.original.realization_date}
</div>
);
}
const date = props.row.original.realization_date;
return date ? formatDate(date, 'DD MMM YYYY') : '-';
},
},
{
id: 'age',
accessorKey: 'age',
header: 'Umur',
cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
return isFooter ? null : props.getValue() || '-';
},
},
{
id: 'do_number',
accessorKey: 'do_number',
header: 'No. DO',
cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
return isFooter ? null : props.getValue() || '-';
},
},
{
id: 'product',
accessorKey: 'product',
header: 'Produk',
cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) return null;
const product = props.getValue() as Product;
return product?.name || '-';
},
},
{
id: 'customer',
accessorKey: 'customer',
header: 'Customer',
cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) return null;
const customer = props.getValue() as Customer;
return customer?.name || '-';
},
},
{
id: 'qty',
accessorKey: 'qty',
header: 'Kuantitas',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter ? 'text-left font-semibold text-gray-900' : 'text-left'
}
>
{formatNumber(value)}
</div>
);
},
},
{
id: 'weight',
accessorKey: 'weight',
header: 'Kg',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter ? 'text-left font-semibold text-gray-900' : 'text-left'
}
>
{formatNumber(value)}
</div>
);
},
},
{
id: 'avg_weight',
accessorKey: 'avg_weight',
header: 'AVG (Kg)',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter ? 'text-left font-semibold text-gray-900' : 'text-left'
}
>
{formatNumber(value)}
</div>
);
},
},
{
id: 'price_partner',
accessorKey: 'price',
header: 'Harga Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter
? 'text-right font-semibold text-gray-900'
: 'text-right'
}
>
{formatCurrency(value)}
</div>
);
},
},
{
id: 'total_mitra',
accessorKey: 'total_price',
header: 'Total Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter
? 'text-right font-semibold text-gray-900'
: 'text-right'
}
>
{formatCurrency(value)}
</div>
);
},
},
{
id: 'price_act',
accessorKey: 'price',
header: 'Harga Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter
? 'text-right font-semibold text-gray-900'
: 'text-right'
}
>
{formatCurrency(value)}
</div>
);
},
},
{
id: 'total_act',
accessorKey: 'total_price',
header: 'Total Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter
? 'text-right font-semibold text-gray-900'
: 'text-right'
}
>
{formatCurrency(value)}
</div>
);
},
},
{
id: 'kandang',
accessorKey: 'kandang',
header: 'Kandang',
cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) return null;
const kandang = props.getValue() as Kandang;
return kandang?.name || '-';
},
},
{
id: 'payment_status',
accessorKey: 'payment_status',
header: 'Status Pembayaran',
cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) return null;
const status = props.getValue() as string;
const getStatusColor = (status: string) => {
if (!status) return 'neutral';
switch (status.toLowerCase()) {
case 'paid':
return 'success';
case 'tempo':
return 'warning';
default:
return 'neutral';
}
};
return (
<Badge variant='soft' size='sm' color={getStatusColor(status)}>
{status || '-'}
</Badge>
);
},
},
],
[]
);
return (
<>
<section className='w-full'>
<div className='p-4'>
<h2 className='text-xl font-semibold mb-4'>Penjualan</h2>
<Card
className={{
wrapper: 'w-full bg-base-100',
body: 'p-0',
}}
>
<Table
data={salesData}
columns={salesColumns}
footerData={footerData}
renderFooter={salesData.length > 0}
className={{
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end whitespace-nowrap',
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',
}}
/>
</Card>
</div>
</section>
</>
);
};
export default SalesReportTable;
@@ -370,7 +370,7 @@ const RecordingTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [approvalNotes, setApprovalNotes] = useState('');
const [, setApprovalNotes] = useState('');
const singleDeleteModal = useModal();
const approveModal = useModal();
@@ -48,7 +48,7 @@ import {
getRecordingLayingFormInitialValues,
UpdateRecordingGrowingFormSchema,
UpdateRecordingLayingFormSchema,
} from './RecordingForm.schema';
} from '@/components/pages/production/recording/form/RecordingForm.schema';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper';
@@ -2924,8 +2924,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}, 1000);
}
}
} catch (error) {
console.error('Error creating recording:', error);
} catch {
toast.error(
'Gagal membuat recording. Silakan coba lagi.'
);
@@ -28,7 +28,7 @@ import {
RecordingGradingFormValues,
UpdateRecordingGradingFormSchema,
getRecordingGradingFormInitialValues,
} from '../../form/RecordingForm.schema';
} from '@/components/pages/production/recording/form/RecordingForm.schema';
import { cn, formatDate } from '@/lib/helper';
import toast from 'react-hot-toast';
@@ -173,8 +173,7 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
deleteModal.closeModal();
toast.success(res?.message || 'Successfully delete Grading!');
router.push('/production/recording');
} catch (err) {
console.error(err);
} catch {
setGradingFormErrorMessage('Failed to delete Grading');
} finally {
setIsDeleteLoading(false);
@@ -18,7 +18,7 @@ import {
PurchaseRequestAcceptApprovalFormDefaultValues,
PurchaseRequestAcceptApprovalFormInitialValues,
PurchaseRequestAcceptApprovalFormSchema,
} from './PurchaseOrderForm.schema';
} from '@/components/pages/purchase/form/order/PurchaseOrderForm.schema';
import { isResponseError } from '@/lib/api-helper';
import { PurchaseApi } from '@/services/api/purchase';
import {
@@ -21,7 +21,7 @@ import {
PurchaseRequestStaffApprovalFormInitialValues,
PurchaseRequestStaffApprovalFormSchema,
PurchaseStaffApprovalItemSchema,
} from './PurchaseOrderForm.schema';
} from '@/components/pages/purchase/form/order/PurchaseOrderForm.schema';
import { isResponseError } from '@/lib/api-helper';
import { formatNumber } from '@/lib/helper';
import { PurchaseApi } from '@/services/api/purchase';
@@ -241,9 +241,8 @@ const PurchaseOrderStaffApprovalForm = ({
);
formik.setFieldValue('items', updatedPurchaseItems);
}
} catch (error) {
} catch {
toast.error('Terjadi kesalahan saat menghapus item pembelian');
console.error('Delete item error:', error);
}
}, [
initialValues?.id,
@@ -22,13 +22,12 @@ import {
PurchaseRequestFormValues,
getPurchaseRequestFormInitialValues,
UpdatePurchaseRequestFormSchema,
} from './PurchaseRequestForm.schema';
} from '@/components/pages/purchase/form/request/PurchaseRequestForm.schema';
import {
SupplierApi,
AreaApi,
LocationApi,
WarehouseApi,
ProductApi,
} from '@/services/api/master-data';
import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
@@ -12,6 +12,7 @@ import {
pdf,
} from '@react-pdf/renderer';
import { Icon } from '@iconify/react';
import toast from 'react-hot-toast';
import Button from '@/components/Button';
import { Purchase } from '@/types/api/purchase/purchase';
@@ -251,7 +252,7 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
const handleDownloadPDF = async () => {
if (!purchaseData) {
alert('No purchase order data available');
toast.error('No purchase order data available');
return;
}
@@ -502,9 +503,8 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
} catch {
toast.error('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
}
+28
View File
@@ -0,0 +1,28 @@
import { BaseApiService } from './base';
import { BaseApiResponse } from '@/types/api/api-general';
import { ClosingSales } from '@/types/api/closing/closing';
export class ClosingApiService extends BaseApiService<
ClosingSales,
unknown,
unknown
> {
constructor(basePath: string) {
super(basePath);
}
async getPenjualan(
id: number
): Promise<BaseApiResponse<ClosingSales> | undefined> {
try {
const getPenjualanPath = `${id}/penjualan`;
return await this.customRequest<BaseApiResponse<ClosingSales>>(
getPenjualanPath
);
} catch {
return undefined;
}
}
}
export const ClosingApi = new ClosingApiService('/closings');
+29
View File
@@ -0,0 +1,29 @@
import { BaseMetadata } from '@/types/api/api-general';
import { Product } from '@type/api/master-data/product';
import { Customer } from '@type/api/master-data/customer';
import { Kandang } from '@type/api/master-data/kandang';
export type BaseSales = {
id: number;
realization_date: string;
age: number;
do_number: string;
product: Product;
customer: Customer;
qty: number;
weight: number;
avg_weight: number;
price: number;
total_price: number;
kandang: Kandang;
payment_status: string;
};
export type BaseClosingSales = {
project_type: string;
flock_id: number;
period: number;
sales: BaseSales[];
};
export type ClosingSales = BaseMetadata & BaseClosingSales;