Merge branch 'feat/FE/US-334/TASK-344-345-slicing-and-integrate-expedition-hpp-report-table' into 'feat/FE/US-334/expedition-hpp-report'

[FEAT/FE][US#334/TASK-344-345] Slicing and Integrate API Expedition HPP Report Table

See merge request mbugroup/lti-web-client!105
This commit is contained in:
Rivaldi A N S
2025-12-19 03:33:05 +00:00
8 changed files with 281 additions and 124 deletions
+11 -1
View File
@@ -24,6 +24,11 @@ const ClosingDetailPage = () => {
() => ClosingApi.getPenjualan(Number(closingId)) () => ClosingApi.getPenjualan(Number(closingId))
); );
const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR(
closingId ? `hpp-ekspedisi-${closingId}` : null,
() => ClosingApi.getHppEkspedisi(Number(closingId))
);
if (!closingId) { if (!closingId) {
router.back(); router.back();
@@ -39,7 +44,7 @@ const ClosingDetailPage = () => {
return; return;
} }
const isLoading = isLoadingClosing || isLoadingSales; const isLoading = isLoadingClosing || isLoadingSales || isLoadingHppEkspedisi;
return ( return (
<div className='w-full p-4 flex flex-row justify-center'> <div className='w-full p-4 flex flex-row justify-center'>
@@ -50,6 +55,11 @@ const ClosingDetailPage = () => {
id={Number(closingId)} id={Number(closingId)}
initialValue={closing.data} initialValue={closing.data}
salesData={isResponseSuccess(salesData) ? salesData.data : undefined} salesData={isResponseSuccess(salesData) ? salesData.data : undefined}
hppExpeditionData={
isResponseSuccess(hppEkspedisiData)
? hppEkspedisiData.data
: undefined
}
/> />
)} )}
</div> </div>
+114
View File
@@ -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 -4
View File
@@ -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>
-116
View File
@@ -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;
@@ -10,22 +10,26 @@ import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGe
import { import {
ClosingGeneralInformation, ClosingGeneralInformation,
BaseClosingSales, BaseClosingSales,
ClosingHppExpedition,
} from '@/types/api/closing'; } from '@/types/api/closing';
import ClosingSapronakTabContent from './ClosingSapronakTabContent'; import ClosingSapronakTabContent from './ClosingSapronakTabContent';
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent'; import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent'; import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent';
import SalesReportTable from './sale/SalesReportTable'; import SalesReportTable from './sale/SalesReportTable';
import HppExpeditionReportTable from './hpp-ekspedisi/HppExpeditionReportTable';
interface ClosingDetailProps { interface ClosingDetailProps {
id: number; id: number;
initialValue?: ClosingGeneralInformation; initialValue?: ClosingGeneralInformation;
salesData?: BaseClosingSales; salesData?: BaseClosingSales;
hppExpeditionData?: ClosingHppExpedition;
} }
const ClosingDetail: React.FC<ClosingDetailProps> = ({ const ClosingDetail: React.FC<ClosingDetailProps> = ({
id, id,
initialValue, initialValue,
salesData, salesData,
hppExpeditionData,
}) => { }) => {
const [activeTab, setActiveTab] = useState<string>('sapronak'); const [activeTab, setActiveTab] = useState<string>('sapronak');
@@ -54,7 +58,7 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
{ {
id: 'hppEkspedisi', id: 'hppEkspedisi',
label: 'HPP Ekspedisi', label: 'HPP Ekspedisi',
content: 'HPP Ekspedisi', content: <HppExpeditionReportTable initialValues={hppExpeditionData} />,
}, },
{ {
id: 'dataProduksi', id: 'dataProduksi',
@@ -0,0 +1,110 @@
'use client';
import React, { useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table';
import Card from '@/components/Card';
import { formatCurrency } from '@/lib/helper';
import { BaseHppExpedition, BaseExpeditionCost } from '@/types/api/closing';
interface HppExpeditionReportTableProps {
type?: 'detail';
initialValues?: BaseHppExpedition;
}
const HppExpeditionReportTable = ({
type = 'detail',
initialValues,
}: HppExpeditionReportTableProps) => {
const costOfRevenueExpeditionData: BaseExpeditionCost[] = useMemo(() => {
return initialValues?.expedition_costs || [];
}, [initialValues]);
const totals = useMemo(() => {
const totalHpp = initialValues?.total_hpp_amount || 0;
return {
totalHpp,
};
}, [initialValues]);
const costOfRevenueExpeditionColumns: ColumnDef<BaseExpeditionCost>[] =
useMemo(
() => [
{
id: 'id',
accessorKey: 'id',
header: 'No',
cell: (props) => {
return <div>{props.row.index + 1}</div>;
},
footer: () => (
<div className='font-semibold text-gray-900'>
Total HPP Ekspedisi
</div>
),
},
{
id: 'expedition_vendor_name',
accessorKey: 'expedition_vendor_name',
header: 'Nama Ekspedisi',
cell: (props) => props.getValue() || '-',
},
{
id: 'hpp_amount',
accessorKey: 'hpp_amount',
header: 'HPP Ekspedisi',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalHpp)}
</div>
),
},
],
[totals]
);
return (
<>
<section className='w-full'>
<div className='p-4'>
<h2 className='text-xl font-semibold mb-4'>HPP Ekspedisi</h2>
<Card
className={{
wrapper: 'w-full bg-base-100',
body: 'p-0',
}}
>
<Table
data={costOfRevenueExpeditionData}
columns={costOfRevenueExpeditionColumns}
renderFooter={costOfRevenueExpeditionData.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 HppExpeditionReportTable;
+20 -1
View File
@@ -8,7 +8,9 @@ import {
ClosingOutgoingSapronak, ClosingOutgoingSapronak,
ClosingOverhead, ClosingOverhead,
ClosingSapronakCalculation, ClosingSapronakCalculation,
ClosingHppExpedition,
} from '@/types/api/closing'; } from '@/types/api/closing';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { import {
dummyGetAllFetcher, dummyGetAllFetcher,
@@ -19,7 +21,6 @@ import {
dummyGetPerhitunganSapronak, dummyGetPerhitunganSapronak,
dummyGetOverhead, dummyGetOverhead,
} from '@/dummy/closing.dummy'; } from '@/dummy/closing.dummy';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { ClosingSales } from '@/types/api/closing'; import { ClosingSales } from '@/types/api/closing';
export class ClosingApiService extends BaseApiService<Closing, null, null> { export class ClosingApiService extends BaseApiService<Closing, null, null> {
@@ -194,6 +195,24 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
return undefined; return undefined;
} }
} }
async getHppEkspedisi(
id: number
): Promise<BaseApiResponse<ClosingHppExpedition> | undefined> {
try {
const getHppEkspedisiPath = `${this.basePath}/${id}/expedition-hpp`;
const getHppEkspedisiRes =
await httpClient<BaseApiResponse<ClosingHppExpedition>>(
getHppEkspedisiPath
);
return getHppEkspedisiRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<ClosingHppExpedition>>(error)) {
return error.response?.data;
}
return undefined;
}
}
} }
export const ClosingApi = new ClosingApiService('/closings'); export const ClosingApi = new ClosingApiService('/closings');
+14 -1
View File
@@ -78,6 +78,7 @@ export type ClosingIncomingSapronak = {
}; };
export type ClosingOutgoingSapronak = ClosingIncomingSapronak; export type ClosingOutgoingSapronak = ClosingIncomingSapronak;
export type ClosingSales = BaseMetadata & BaseClosingSales;
// ====== PERHITUNGAN SAPRONAK ====== // ====== PERHITUNGAN SAPRONAK ======
@@ -141,4 +142,16 @@ export type OverheadTotal = {
actual_total_amount: number; actual_total_amount: number;
cost_per_bird: number; cost_per_bird: number;
}; };
export type ClosingSales = BaseMetadata & BaseClosingSales;
export type BaseExpeditionCost = {
id: number;
expedition_vendor_name: string;
hpp_amount: number;
};
export type BaseHppExpedition = {
expedition_costs: BaseExpeditionCost[];
total_hpp_amount: number;
};
export type ClosingHppExpedition = BaseMetadata & BaseHppExpedition;