diff --git a/.gitignore b/.gitignore index d86875dd..e47b8ec3 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ next-env.d.ts # idea .idea + +# claude +.claude diff --git a/package-lock.json b/package-lock.json index 01bff9ef..f960d1c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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.5.8", @@ -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", @@ -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" diff --git a/src/app/closing/detail/page.tsx b/src/app/closing/detail/page.tsx index 6225b8dd..487533be 100644 --- a/src/app/closing/detail/page.tsx +++ b/src/app/closing/detail/page.tsx @@ -4,6 +4,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import useSWR from 'swr'; import ClosingDetail from '@/components/pages/closing/ClosingDetail'; +import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable'; import { ClosingApi } from '@/services/api/closing'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; @@ -19,6 +20,11 @@ const ClosingDetailPage = () => { (id: number) => ClosingApi.getGeneralInfo(id) ); + const { data: salesReport, isLoading: isLoadingSalesReport } = useSWR( + closingId, + (id: number) => ClosingApi.getPenjualan(id) + ); + if (!closingId) { router.back(); @@ -43,6 +49,9 @@ const ClosingDetailPage = () => { {!isLoadingClosing && isResponseSuccess(closing) && ( )} + {!isLoadingSalesReport && isResponseSuccess(salesReport) && ( + + )} ); }; diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 970c5bc1..9feb33e2 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -14,6 +14,7 @@ import { SortingState, OnChangeFn, Row, + HeaderContext, } from '@tanstack/react-table'; import { rankItem } from '@tanstack/match-sorter-utils'; import { Icon } from '@iconify/react'; @@ -31,6 +32,9 @@ interface TableClassNames { tableBodyClassName?: string; bodyRowClassName?: string; bodyColumnClassName?: string; + tableFooterClassName?: string; + footerRowClassName?: string; + footerColumnClassName?: string; paginationClassName?: string; } @@ -53,6 +57,7 @@ export interface TableProps { rowSelection?: Record; setRowSelection?: OnChangeFn>; enableRowSelection?: boolean | ((row: Row) => boolean); + renderFooter?: boolean; withCheckbox?: boolean; rowOptions?: number[]; } @@ -67,18 +72,22 @@ const emptyContentDefaultValue = ( ); -const TABLE_DEFAULT_STYLING = { +export const TABLE_DEFAULT_STYLING = { containerClassName: 'w-full mb-20', tableWrapperClassName: 'overflow-x-auto border border-solid border-base-content/10 rounded-lg', tableClassName: 'font-inter w-full table-auto text-sm font-medium', tableHeaderClassName: '', headerRowClassName: '', - headerColumnClassName: 'px-4 py-3 text-base-content/50', + headerColumnClassName: + 'px-4 py-3 border-base-content/10 text-base-content/50', tableBodyClassName: '', - bodyRowClassName: 'border-t border-t-base-content/10', + bodyRowClassName: 'border-t border-base-content/10', bodyColumnClassName: 'px-4 py-3 text-base-content', paginationClassName: '', + tableFooterClassName: 'font-semibold border-base-content/10', + footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10', + footerColumnClassName: 'p-4 text-base-content whitespace-nowrap', }; const Table = ({ @@ -100,6 +109,7 @@ const Table = ({ rowSelection, setRowSelection, enableRowSelection, + renderFooter = false, withCheckbox = false, rowOptions = [10, 20, 50, 100], }: TableProps) => { @@ -214,58 +224,82 @@ const Table = ({ key={headerGroup.id} className={tableClassNames.headerRowClassName} > - {headerGroup.headers.map((header) => ( - -
- {flexRender( - header.column.columnDef.header, - header.getContext() + {headerGroup.headers.map((header) => { + const columnRelativeDepth = + header.depth - header.column.depth; + if ( + !header.isPlaceholder && + columnRelativeDepth > 1 && + header.id === header.column.id + ) { + return null; + } + let rowSpan = 1; + if (header.isPlaceholder) { + const leafs = header.getLeafHeaders(); + rowSpan = leafs[leafs.length - 1].depth - header.depth; + } + return ( + 1, + }, + tableClassNames.headerColumnClassName )} + > +
1, + })} + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} - {header.column.getCanSort() && ( -
- - -
- )} -
- - ))} + {header.column.getCanSort() && ( +
+ + +
+ )} +
+ + ); + })} ))} @@ -290,6 +324,28 @@ const Table = ({ ))} + + {renderFooter && ( + + {table.getAllLeafColumns().map((column) => ( + + {column.columnDef.footer && + flexRender(column.columnDef.footer, { + column, + header: column.columnDef, + table, + } as HeaderContext)} + + ))} + + )} + diff --git a/src/components/pages/closing/sale/SalesReportTable.tsx b/src/components/pages/closing/sale/SalesReportTable.tsx new file mode 100644 index 00000000..e509eb7d --- /dev/null +++ b/src/components/pages/closing/sale/SalesReportTable.tsx @@ -0,0 +1,285 @@ +'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'; +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; +} + +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 salesColumns: ColumnDef[] = useMemo( + () => [ + { + id: 'realization_date', + accessorKey: 'realization_date', + header: 'Tanggal Realisasi', + cell: (props) => { + const date = props.row.original.realization_date; + return date ? formatDate(date, 'DD MMM YYYY') : '-'; + }, + footer: () => ( +
Total Penjualan
+ ), + }, + { + id: 'age', + accessorKey: 'age', + header: 'Umur', + cell: (props) => props.getValue() || '-', + }, + { + id: 'do_number', + accessorKey: 'do_number', + header: 'No. DO', + cell: (props) => props.getValue() || '-', + }, + { + id: 'product', + accessorKey: 'product', + header: 'Produk', + cell: (props) => { + const product = props.getValue() as Product; + return product?.name || '-'; + }, + }, + { + id: 'customer', + accessorKey: 'customer', + header: 'Customer', + cell: (props) => { + const customer = props.getValue() as Customer; + return customer?.name || '-'; + }, + }, + { + id: 'jumlah', + header: 'Jumlah', + columns: [ + { + id: 'qty', + accessorKey: 'qty', + header: 'Kuantitas', + cell: (props) => { + const value = props.getValue() as number; + return
{formatNumber(value)}
; + }, + footer: () => ( +
+ {formatNumber(totals.totalQuantity)} +
+ ), + }, + { + id: 'weight', + accessorKey: 'weight', + header: 'Kg', + cell: (props) => { + const value = props.getValue() as number; + return
{formatNumber(value)}
; + }, + footer: () => ( +
+ {formatNumber(totals.totalWeight)} +
+ ), + }, + ], + }, + { + id: 'avg_weight', + accessorKey: 'avg_weight', + header: 'AVG (Kg)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatNumber(value)}
; + }, + footer: () => ( +
+ {formatNumber(totals.avgWeight)} +
+ ), + }, + { + id: 'price_partner', + accessorKey: 'price', + header: 'Harga Mitra (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + footer: () => ( +
+ {formatCurrency(totals.avgPricePartner)} +
+ ), + }, + { + id: 'total_mitra', + accessorKey: 'total_price', + header: 'Total Mitra (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + footer: () => ( +
+ {formatCurrency(totals.totalPartner)} +
+ ), + }, + { + id: 'price_act', + accessorKey: 'price', + header: 'Harga Act (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + }, + { + id: 'total_act', + accessorKey: 'total_price', + header: 'Total Act (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + }, + { + id: 'kandang', + accessorKey: 'kandang', + header: 'Kandang', + cell: (props) => { + const kandang = props.getValue() as Kandang; + return kandang?.name || '-'; + }, + }, + { + id: 'payment_status', + accessorKey: 'payment_status', + header: 'Status Pembayaran', + cell: (props) => { + 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 ( + + {status || '-'} + + ); + }, + }, + ], + [] + ); + + return ( + <> +
+
+

Penjualan

+ + 0} + className={{ + tableWrapperClassName: 'overflow-x-auto', + tableClassName: 'w-full table-auto text-sm', + headerColumnClassName: + 'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0', + bodyRowClassName: + 'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r 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', + }} + /> + + + + + ); +}; + +export default SalesReportTable; diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 6cf254e7..4a413bc4 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -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(); diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 52297258..43ffc98b 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -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.' ); diff --git a/src/components/pages/production/recording/grading/form/GradingForm.tsx b/src/components/pages/production/recording/grading/form/GradingForm.tsx index 1e91c78d..417c6356 100644 --- a/src/components/pages/production/recording/grading/form/GradingForm.tsx +++ b/src/components/pages/production/recording/grading/form/GradingForm.tsx @@ -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); diff --git a/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx index 69c3fd13..791e2592 100644 --- a/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx +++ b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx @@ -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, diff --git a/src/components/pages/purchase/form/request/PurchaseRequestForm.tsx b/src/components/pages/purchase/form/request/PurchaseRequestForm.tsx index c8684b2a..396ce7bb 100644 --- a/src/components/pages/purchase/form/request/PurchaseRequestForm.tsx +++ b/src/components/pages/purchase/form/request/PurchaseRequestForm.tsx @@ -28,7 +28,6 @@ import { 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'; diff --git a/src/components/pages/purchase/order/PurchaseOrderInvoice.tsx b/src/components/pages/purchase/order/PurchaseOrderInvoice.tsx index d7497d7e..36aea9c7 100644 --- a/src/components/pages/purchase/order/PurchaseOrderInvoice.tsx +++ b/src/components/pages/purchase/order/PurchaseOrderInvoice.tsx @@ -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); } diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index 041108d0..fe2c2d50 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -9,12 +9,29 @@ import { } from '@/types/api/closing'; import { httpClient, httpClientFetcher } from '@/services/http/client'; import { BaseApiResponse } from '@/types/api/api-general'; +import { ClosingSales } from '@/types/api/closing'; export class ClosingApiService extends BaseApiService { constructor(basePath: string) { super(basePath); } + async getPenjualan( + id: number + ): Promise | undefined> { + try { + const getPenjualanPath = `${id}/penjualan`; + return await this.customRequest>( + getPenjualanPath + ); + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } + async getAllIncomingSapronakFetcher( endpoint: string ): Promise> { @@ -51,4 +68,4 @@ export class ClosingApiService extends BaseApiService { } } -export const ClosingApi = new ClosingApiService('/closing'); +export const ClosingApi = new ClosingApiService('/closings'); diff --git a/src/types/api/closing.d.ts b/src/types/api/closing.d.ts index 3f7ba816..95b2f57f 100644 --- a/src/types/api/closing.d.ts +++ b/src/types/api/closing.d.ts @@ -1,9 +1,34 @@ import { Area } from '@/types/api/master-data/area'; import { Fcr } from '@/types/api/master-data/fcr'; import { Flock } from '@/types/api/master-data/flock'; -import { Kandang } from '@/types/api/master-data/kandang'; import { Location } from '@/types/api/master-data/location'; -import { BaseApproval, BaseMetadata } from '@/types/api/api-general'; +import { Kandang } from '@/types/api/master-data/kandang'; +import { Product } from '@type/api/master-data/product'; +import { Customer } from '@type/api/master-data/customer'; +import { BaseMetadata } from '@/types/api/api-general'; + +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 BaseClosing = { id: number; @@ -53,3 +78,4 @@ export type ClosingIncomingSapronak = { }; export type ClosingOutgoingSapronak = ClosingIncomingSapronak; +export type ClosingSales = BaseMetadata & BaseClosingSales;