mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Merge branch 'development' into 'staging'
Development See merge request mbugroup/lti-web-client!328
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
import ClosingDetail from '@/components/pages/closing/ClosingDetail';
|
import ClosingDetail from '@/components/pages/closing/ClosingDetailTabs';
|
||||||
|
|
||||||
import { ClosingApi } from '@/services/api/closing';
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
@@ -34,33 +34,6 @@ const ClosingDetailPage = () => {
|
|||||||
() => ProjectFlockKandangApi.getSingle(Number(kandangId))
|
() => ProjectFlockKandangApi.getSingle(Number(kandangId))
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: salesData, isLoading: isLoadingSales } = useSWR(
|
|
||||||
kandangId
|
|
||||||
? `sales-${closingId}-${kandangId}`
|
|
||||||
: closingId
|
|
||||||
? `sales-${closingId}`
|
|
||||||
: null,
|
|
||||||
() =>
|
|
||||||
kandangId
|
|
||||||
? ClosingApi.getPenjualanByKandang(Number(closingId), Number(kandangId))
|
|
||||||
: ClosingApi.getPenjualan(Number(closingId))
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR(
|
|
||||||
kandangId
|
|
||||||
? `hpp-ekspedisi-${closingId}-${kandangId}`
|
|
||||||
: closingId
|
|
||||||
? `hpp-ekspedisi-${closingId}`
|
|
||||||
: null,
|
|
||||||
() =>
|
|
||||||
kandangId
|
|
||||||
? ClosingApi.getHppEkspedisiByKandang(
|
|
||||||
Number(closingId),
|
|
||||||
Number(kandangId)
|
|
||||||
)
|
|
||||||
: ClosingApi.getHppEkspedisi(Number(closingId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!closingId) {
|
if (!closingId) {
|
||||||
router.back();
|
router.back();
|
||||||
|
|
||||||
@@ -76,12 +49,7 @@ const ClosingDetailPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLoading =
|
const isLoading = isLoadingClosing || isLoadingProject || isLoadingKandang;
|
||||||
isLoadingClosing ||
|
|
||||||
isLoadingSales ||
|
|
||||||
isLoadingHppEkspedisi ||
|
|
||||||
isLoadingProject ||
|
|
||||||
isLoadingKandang;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full p-4 flex flex-row justify-center'>
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
@@ -91,12 +59,6 @@ const ClosingDetailPage = () => {
|
|||||||
<ClosingDetail
|
<ClosingDetail
|
||||||
id={Number(closingId)}
|
id={Number(closingId)}
|
||||||
initialValue={closing.data}
|
initialValue={closing.data}
|
||||||
salesData={isResponseSuccess(salesData) ? salesData.data : undefined}
|
|
||||||
hppExpeditionData={
|
|
||||||
isResponseSuccess(hppEkspedisiData)
|
|
||||||
? hppEkspedisiData.data
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
projectData={
|
projectData={
|
||||||
isResponseSuccess(projectData) ? projectData.data : undefined
|
isResponseSuccess(projectData) ? projectData.data : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import ClosingsTable from '@/components/pages/closing/ClosingsTable';
|
|||||||
|
|
||||||
const Closing = () => {
|
const Closing = () => {
|
||||||
return (
|
return (
|
||||||
<section className='w-full p-4'>
|
<section className='w-full p-3'>
|
||||||
<ClosingsTable />
|
<ClosingsTable />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable';
|
import ReportExpenseTabs from '@/components/pages/report/expense/ReportExpenseTabs';
|
||||||
|
|
||||||
const ReportExpense = () => {
|
const ReportExpense = () => {
|
||||||
return (
|
return <ReportExpenseTabs />;
|
||||||
<div className='w-full p-4'>
|
|
||||||
<ReportExpenseTable />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ReportExpense;
|
export default ReportExpense;
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import MarketingReportContent from '@/components/pages/report/marketing/MarketingReportContent';
|
import MarketingReportContent from '@/components/pages/report/marketing/MarketingTabs';
|
||||||
|
|
||||||
const MarketingReportPage = () => {
|
const MarketingReportPage = () => {
|
||||||
return (
|
return <MarketingReportContent />;
|
||||||
<section className='w-full p-4'>
|
|
||||||
<MarketingReportContent />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MarketingReportPage;
|
export default MarketingReportPage;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import ProductionResultContent from '@/components/pages/report/production-result/ProductionResultContent';
|
import ProductionResultTabs from '@/components/pages/report/production-result/ProductionResultTabs';
|
||||||
|
|
||||||
const ProductionResultReportPage = () => {
|
const ProductionResultReportPage = () => {
|
||||||
return (
|
return (
|
||||||
<section className='w-full max-w-full'>
|
<section className='w-full max-w-full'>
|
||||||
<ProductionResultContent />
|
<ProductionResultTabs />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ const DUMMY_SKELETON_DATA = Array.from({ length: 10 }, (_, index) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const emptyContentDefaultValue = (
|
const emptyContentDefaultValue = (
|
||||||
<div className='w-full p-5 text-center'>
|
<div className='w-full text-center py-4'>
|
||||||
<span className='text-lg opacity-50'>
|
<span className='text-sm opacity-50'>
|
||||||
Tidak ada data yang dapat ditampilkan...
|
Tidak ada data yang dapat ditampilkan...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,6 +452,20 @@ const Table = <TData extends object>({
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{(data.length === 0 || table.getRowModel().rows.length === 0) &&
|
||||||
|
!isLoading && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={
|
||||||
|
table.getAllLeafColumns().length + (withCheckbox ? 1 : 0)
|
||||||
|
}
|
||||||
|
className='p-0'
|
||||||
|
>
|
||||||
|
{emptyContent}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot
|
<tfoot
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -489,10 +503,6 @@ const Table = <TData extends object>({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(data.length === 0 || table.getRowModel().rows.length === 0) &&
|
|
||||||
!isLoading &&
|
|
||||||
emptyContent}
|
|
||||||
|
|
||||||
{data.length > 0 &&
|
{data.length > 0 &&
|
||||||
table.getRowModel().rows.length > 0 &&
|
table.getRowModel().rows.length > 0 &&
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
|
|||||||
@@ -284,23 +284,22 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
|||||||
isDisabled && !readOnly,
|
isDisabled && !readOnly,
|
||||||
'bg-transparent! cursor-not-allowed!': readOnly,
|
'bg-transparent! cursor-not-allowed!': readOnly,
|
||||||
'cursor-pointer!': !readOnly && !isDisabled,
|
'cursor-pointer!': !readOnly && !isDisabled,
|
||||||
'border-red-500! ring-2 ring-red-200': isError,
|
'border-error!': isError,
|
||||||
|
'ring-2 ring-error/20': isError,
|
||||||
'border-indigo-500 ring-2 ring-indigo-200':
|
'border-indigo-500 ring-2 ring-indigo-200':
|
||||||
isFocused && !startAdornment,
|
isFocused && !startAdornment && !isError,
|
||||||
'border-base-content/10!': !isError && !isFocused,
|
'border-base-content/10!': !isError && !isFocused,
|
||||||
'rounded-l-none!': inputPrefix && !startAdornment,
|
'rounded-l-none!': inputPrefix && !startAdornment,
|
||||||
'rounded-r-none!': inputSuffix && !startAdornment,
|
'rounded-r-none!': inputSuffix && !startAdornment,
|
||||||
}),
|
}),
|
||||||
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
||||||
placeholder: () =>
|
placeholder: () =>
|
||||||
cn({
|
cn('text-gray-400 text-sm leading-tight', {
|
||||||
'text-gray-400 text-sm leading-tight': !isError,
|
'text-error!': isError,
|
||||||
'text-red-300!': isError,
|
|
||||||
}),
|
}),
|
||||||
singleValue: () =>
|
singleValue: () =>
|
||||||
cn({
|
cn('m-0! text-gray-900 text-sm leading-tight', {
|
||||||
'm-0! text-gray-900 text-sm leading-tight': !isError,
|
'text-error!': isError && !readOnly,
|
||||||
'text-error!': isError,
|
|
||||||
'text-gray-900!': readOnly,
|
'text-gray-900!': readOnly,
|
||||||
}),
|
}),
|
||||||
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
||||||
@@ -404,32 +403,26 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
|||||||
className={cn('w-full', className?.select)}
|
className={cn('w-full', className?.select)}
|
||||||
classNames={{
|
classNames={{
|
||||||
control: ({ isFocused, isDisabled }) =>
|
control: ({ isFocused, isDisabled }) =>
|
||||||
cn(
|
cn('w-full border transition-shadow rounded-lg!', {
|
||||||
'w-full border transition-shadow',
|
'bg-base-100!': !isDisabled && !readOnly,
|
||||||
// Gunakan rounded-lg untuk semua kasus
|
'bg-base-200! text-gray-400 cursor-not-allowed':
|
||||||
'rounded-lg!',
|
isDisabled && !readOnly,
|
||||||
{
|
'bg-transparent! cursor-not-allowed!': readOnly,
|
||||||
'bg-base-100!': !isDisabled && !readOnly,
|
'cursor-pointer!': !readOnly && !isDisabled,
|
||||||
'bg-base-200! text-gray-400 cursor-not-allowed':
|
'border-error!': isError,
|
||||||
isDisabled && !readOnly,
|
'ring-2 ring-error/20': isError,
|
||||||
'bg-transparent! cursor-not-allowed!': readOnly,
|
'border-indigo-500 ring-2 ring-indigo-200':
|
||||||
'cursor-pointer!': !readOnly && !isDisabled,
|
isFocused && !startAdornment && !isError,
|
||||||
'border-red-500! ring-2 ring-red-200': isError,
|
'border-base-content/10!': !isError && !isFocused,
|
||||||
'border-indigo-500 ring-2 ring-indigo-200':
|
}),
|
||||||
isFocused && !startAdornment,
|
|
||||||
'border-base-content/10!': !isError && !isFocused,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
||||||
placeholder: () =>
|
placeholder: () =>
|
||||||
cn({
|
cn('text-gray-400 text-sm leading-tight', {
|
||||||
'text-gray-400 text-sm leading-tight': !isError,
|
'text-error!': isError,
|
||||||
'text-red-300!': isError,
|
|
||||||
}),
|
}),
|
||||||
singleValue: () =>
|
singleValue: () =>
|
||||||
cn({
|
cn('m-0! text-gray-900 text-sm leading-tight', {
|
||||||
'm-0! text-gray-900 text-sm leading-tight': !isError,
|
'text-error!': isError && !readOnly,
|
||||||
'text-error!': isError,
|
|
||||||
'text-gray-900!': readOnly,
|
'text-gray-900!': readOnly,
|
||||||
}),
|
}),
|
||||||
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
||||||
|
|||||||
+29
-31
@@ -5,28 +5,23 @@ import { useMemo, useState } from 'react';
|
|||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Tabs from '@/components/Tabs';
|
import Tabs from '@/components/Tabs';
|
||||||
import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable';
|
import ClosingGeneralInformationTable from '@/components/pages/closing/table/ClosingGeneralInformationTable';
|
||||||
import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent';
|
import SapronakClosingTab from '@/components/pages/closing/tab/SapronakClosingTab';
|
||||||
import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent';
|
import ProductionDataClosingTab from '@/components/pages/closing/tab/ProductionDataClosingTab';
|
||||||
|
|
||||||
import {
|
import { ClosingGeneralInformation } from '@/types/api/closing';
|
||||||
ClosingGeneralInformation,
|
import SapronakCalculationClosingTab from '@/components/pages/closing/tab/SapronakCalculationClosingTab';
|
||||||
BaseClosingSales,
|
import OverheadClosingTab from '@/components/pages/closing/tab/OverheadClosingTab';
|
||||||
ClosingHppExpedition,
|
import FinanceClosingTab from '@/components/pages/closing/tab/FinanceClosingTab';
|
||||||
} from '@/types/api/closing';
|
import SalesClosingTab from '@/components/pages/closing/tab/SalesClosingTab';
|
||||||
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
|
import HppExpeditionClosingTab from '@/components/pages/closing/tab/HppExpeditionClosingTab';
|
||||||
import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent';
|
|
||||||
import ClosingFinanceTabContent from '@/components/pages/closing/ClosingFinanceTabContent';
|
|
||||||
import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable';
|
|
||||||
import HppExpeditionReportTable from './hpp-ekspedisi/HppExpeditionReportTable';
|
|
||||||
import ClosingKandangList from '@/components/pages/closing/ClosingKandangList';
|
import ClosingKandangList from '@/components/pages/closing/ClosingKandangList';
|
||||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||||
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
||||||
|
import { useClosingTabStore } from '@/stores/closing/closing-tab.store';
|
||||||
interface ClosingDetailProps {
|
interface ClosingDetailProps {
|
||||||
id: number;
|
id: number;
|
||||||
initialValue?: ClosingGeneralInformation;
|
initialValue?: ClosingGeneralInformation;
|
||||||
salesData?: BaseClosingSales;
|
|
||||||
hppExpeditionData?: ClosingHppExpedition;
|
|
||||||
projectData?: ProjectFlock;
|
projectData?: ProjectFlock;
|
||||||
kandangData?: ProjectFlockKandang;
|
kandangData?: ProjectFlockKandang;
|
||||||
}
|
}
|
||||||
@@ -34,25 +29,24 @@ interface ClosingDetailProps {
|
|||||||
const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
||||||
id,
|
id,
|
||||||
initialValue,
|
initialValue,
|
||||||
salesData,
|
|
||||||
hppExpeditionData,
|
|
||||||
projectData,
|
projectData,
|
||||||
kandangData,
|
kandangData,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState<string>('sapronak');
|
const [activeTabId, setActiveTabId] = useState<string>('sapronak');
|
||||||
|
const tabActions = useClosingTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
const closingDetailTabs = useMemo(() => {
|
const closingDetailTabs = useMemo(() => {
|
||||||
const validTabs = [
|
const validTabs = [
|
||||||
{
|
{
|
||||||
id: 'sapronak',
|
id: 'sapronak',
|
||||||
label: 'Sapronak',
|
label: 'Sapronak',
|
||||||
content: <ClosingSapronakTabContent projectFlockId={id} />,
|
content: <SapronakClosingTab projectFlockId={id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'perhitunganSapronak',
|
id: 'perhitunganSapronak',
|
||||||
label: 'Perhitungan Sapronak',
|
label: 'Perhitungan Sapronak',
|
||||||
content: (
|
content: (
|
||||||
<ClosingSapronakCalculationTabContent
|
<SapronakCalculationClosingTab
|
||||||
closingGeneralInformation={initialValue}
|
closingGeneralInformation={initialValue}
|
||||||
projectFlockId={id}
|
projectFlockId={id}
|
||||||
/>
|
/>
|
||||||
@@ -61,13 +55,13 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
|||||||
{
|
{
|
||||||
id: 'penjualan',
|
id: 'penjualan',
|
||||||
label: 'Penjualan',
|
label: 'Penjualan',
|
||||||
content: <SalesReportTable initialValues={salesData} />,
|
content: <SalesClosingTab projectFlockId={id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'overhead',
|
id: 'overhead',
|
||||||
label: 'Overhead',
|
label: 'Overhead',
|
||||||
content: (
|
content: (
|
||||||
<ClosingOverheadTabContent
|
<OverheadClosingTab
|
||||||
projectFlockId={id}
|
projectFlockId={id}
|
||||||
generalInformation={initialValue}
|
generalInformation={initialValue}
|
||||||
kandangData={kandangData}
|
kandangData={kandangData}
|
||||||
@@ -77,26 +71,26 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
|||||||
{
|
{
|
||||||
id: 'hppEkspedisi',
|
id: 'hppEkspedisi',
|
||||||
label: 'HPP Ekspedisi',
|
label: 'HPP Ekspedisi',
|
||||||
content: <HppExpeditionReportTable initialValues={hppExpeditionData} />,
|
content: <HppExpeditionClosingTab projectFlockId={id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dataProduksi',
|
id: 'dataProduksi',
|
||||||
label: 'Data Produksi',
|
label: 'Data Produksi',
|
||||||
content: <ClosingProductionDataTabContent projectFlockId={id} />,
|
content: <ProductionDataClosingTab projectFlockId={id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'keuangan',
|
id: 'keuangan',
|
||||||
label: 'Keuangan',
|
label: 'Keuangan',
|
||||||
content: <ClosingFinanceTabContent projectFlockId={id} />,
|
content: <FinanceClosingTab projectFlockId={id} />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return validTabs;
|
return validTabs;
|
||||||
}, [initialValue]);
|
}, [initialValue, kandangData, id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className='w-full max-w-7xl pb-16'>
|
<section className='w-full'>
|
||||||
<header className='flex flex-col gap-4'>
|
<header className='flex flex-col gap-4'>
|
||||||
<Button
|
<Button
|
||||||
href={
|
href={
|
||||||
@@ -126,13 +120,17 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
activeTabId={activeTab}
|
activeTabId={activeTabId}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTabId}
|
||||||
tabs={closingDetailTabs}
|
tabs={closingDetailTabs}
|
||||||
variant='lifted'
|
variant='boxed'
|
||||||
className={{
|
className={{
|
||||||
wrapper: 'w-full mt-4',
|
tabHeaderWrapper:
|
||||||
|
'relative justify-between items-center py-3 before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10 after:absolute after:bottom-0 after:left-0 after:right-0 after:-mx-4 after:border-b after:border-base-content/10',
|
||||||
|
tab: 'w-fit',
|
||||||
|
content: 'p-0 m-0',
|
||||||
}}
|
}}
|
||||||
|
sideContent={tabActions[activeTabId] || null}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import ClosingFinanceTable from '@/components/pages/closing/ClosingFinanceTable';
|
|
||||||
|
|
||||||
const ClosingFinanceTabContent = ({
|
|
||||||
projectFlockId,
|
|
||||||
}: {
|
|
||||||
projectFlockId: number;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
{projectFlockId && (
|
|
||||||
<ClosingFinanceTable projectFlockId={projectFlockId} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ClosingFinanceTabContent;
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import Card from '@/components/Card';
|
|
||||||
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { formatCurrency, formatTitleCase } from '@/lib/helper';
|
|
||||||
import { ClosingApi } from '@/services/api/closing';
|
|
||||||
import { HppItem, ProfitLossItem } from '@/types/api/closing';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
|
|
||||||
const ClosingFinanceTable = ({
|
|
||||||
projectFlockId,
|
|
||||||
}: {
|
|
||||||
projectFlockId: number;
|
|
||||||
}) => {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const kandangId = searchParams.get('kandangId');
|
|
||||||
|
|
||||||
const { data: finance, isLoading } = useSWR(
|
|
||||||
`/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
|
|
||||||
() =>
|
|
||||||
ClosingApi.getFinance(
|
|
||||||
projectFlockId,
|
|
||||||
kandangId ? Number(kandangId) : undefined
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const hppTableData: HppItem[] = useMemo(() => {
|
|
||||||
if (isResponseSuccess(finance)) {
|
|
||||||
const customItems = {
|
|
||||||
label: 'HPP dan Pengeluaran',
|
|
||||||
code: 'custom_row',
|
|
||||||
} as HppItem;
|
|
||||||
const purchases = finance.data.hpp.items.filter(
|
|
||||||
(item) => item.category === 'purchase'
|
|
||||||
);
|
|
||||||
const totalBudgeting = {
|
|
||||||
label: 'HPP dan Bahan Baku',
|
|
||||||
code: 'custom_row',
|
|
||||||
} as HppItem;
|
|
||||||
const overheads = finance.data.hpp.items.filter(
|
|
||||||
(item) => item.category === 'overhead'
|
|
||||||
);
|
|
||||||
return [customItems, ...purchases, totalBudgeting, ...overheads];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [finance]);
|
|
||||||
|
|
||||||
const profitLossTableData: ProfitLossItem[] = useMemo(() => {
|
|
||||||
if (isResponseSuccess(finance)) {
|
|
||||||
const incomes = finance.data.profit_loss.items.filter(
|
|
||||||
(item) => item.type === 'income'
|
|
||||||
);
|
|
||||||
const purchases = finance.data.profit_loss.items.filter(
|
|
||||||
(item) => item.type === 'purchase'
|
|
||||||
);
|
|
||||||
const overheads = finance.data.profit_loss.items.filter(
|
|
||||||
(item) => item.type === 'overhead'
|
|
||||||
);
|
|
||||||
const grossProfit = {
|
|
||||||
label: 'LABA RUGI BRUTO',
|
|
||||||
code: 'custom_row',
|
|
||||||
type: 'gross_profit',
|
|
||||||
rp_per_bird:
|
|
||||||
finance.data.profit_loss.summary.gross_profit.rp_per_bird ?? 0,
|
|
||||||
rp_per_kg: finance.data.profit_loss.summary.gross_profit.rp_per_kg ?? 0,
|
|
||||||
amount: finance.data.profit_loss.summary.gross_profit.amount ?? 0,
|
|
||||||
} as ProfitLossItem;
|
|
||||||
const subtotal = {
|
|
||||||
label: 'Subtotal',
|
|
||||||
code: 'custom_row',
|
|
||||||
type: 'subtotal',
|
|
||||||
rp_per_bird:
|
|
||||||
finance.data.profit_loss.summary.sub_total.rp_per_bird ?? 0,
|
|
||||||
rp_per_kg: finance.data.profit_loss.summary.sub_total.rp_per_kg ?? 0,
|
|
||||||
amount: finance.data.profit_loss.summary.sub_total.amount ?? 0,
|
|
||||||
} as ProfitLossItem;
|
|
||||||
return [...incomes, ...purchases, grossProfit, ...overheads, subtotal];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [finance]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<>
|
|
||||||
<Card
|
|
||||||
variant='bordered'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='grid grid-cols-2 gap-6'>
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<div>Laba Rugi Brutto</div>
|
|
||||||
<div className='text-lg font-bold'>
|
|
||||||
{isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.profit_loss.summary.gross_profit.amount
|
|
||||||
)
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<div>Laba Rugi Netto</div>
|
|
||||||
<div className='text-lg font-bold'>
|
|
||||||
{isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.profit_loss.summary.net_profit.amount
|
|
||||||
)
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card
|
|
||||||
title='HPP Purchases'
|
|
||||||
variant='bordered'
|
|
||||||
collapsible
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='mt-6 p-0 mb-0'>
|
|
||||||
<Table<HppItem>
|
|
||||||
data={hppTableData}
|
|
||||||
isLoading={isLoading}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'No.',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item, index) => {
|
|
||||||
if (item.code === 'custom_row') return '-';
|
|
||||||
const dataRowsBefore = hppTableData
|
|
||||||
.slice(0, index)
|
|
||||||
.filter((row) => row.code !== 'custom_row').length;
|
|
||||||
return dataRowsBefore + 1;
|
|
||||||
},
|
|
||||||
footer: (props) => {
|
|
||||||
return 'HPP';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Jenis',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) => formatTitleCase(item.label || '-'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Budgeting',
|
|
||||||
enableSorting: false,
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: 'Rp/Ekor',
|
|
||||||
id: 'budgeting_rp_per_bird',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) =>
|
|
||||||
formatCurrency(item.budgeting?.rp_per_bird || 0),
|
|
||||||
footer: (props) => {
|
|
||||||
return props.column.id === 'budgeting_rp_per_bird' &&
|
|
||||||
isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.hpp.summary?.budgeting
|
|
||||||
?.rp_per_bird || 0
|
|
||||||
)
|
|
||||||
: '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Rp/Kg',
|
|
||||||
id: 'budgeting_rp_per_kg',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) =>
|
|
||||||
formatCurrency(item.budgeting?.rp_per_kg || 0),
|
|
||||||
footer: (props) => {
|
|
||||||
return props.column.id === 'budgeting_rp_per_kg' &&
|
|
||||||
isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.hpp.summary?.budgeting?.rp_per_kg ||
|
|
||||||
0
|
|
||||||
)
|
|
||||||
: '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Jumlah (Rp)',
|
|
||||||
id: 'budgeting_amount',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) =>
|
|
||||||
formatCurrency(item.budgeting?.amount || 0),
|
|
||||||
footer: (props) => {
|
|
||||||
return props.column.id === 'budgeting_amount' &&
|
|
||||||
isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.hpp.summary?.budgeting?.amount || 0
|
|
||||||
)
|
|
||||||
: '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Realization',
|
|
||||||
enableSorting: false,
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: 'Rp/Ekor',
|
|
||||||
id: 'realization_rp_per_bird',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) =>
|
|
||||||
formatCurrency(item.realization?.rp_per_bird || 0),
|
|
||||||
footer: (props) => {
|
|
||||||
return props.column.id === 'realization_rp_per_bird' &&
|
|
||||||
isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.hpp.summary?.realization
|
|
||||||
?.rp_per_bird || 0
|
|
||||||
)
|
|
||||||
: '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Rp/Kg',
|
|
||||||
id: 'realization_rp_per_kg',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) =>
|
|
||||||
formatCurrency(item.realization?.rp_per_kg || 0),
|
|
||||||
footer: (props) => {
|
|
||||||
return props.column.id === 'realization_rp_per_kg' &&
|
|
||||||
isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.hpp.summary?.realization
|
|
||||||
?.rp_per_kg || 0
|
|
||||||
)
|
|
||||||
: '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Jumlah (Rp)',
|
|
||||||
id: 'realization_amount',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) =>
|
|
||||||
formatCurrency(item.realization?.amount || 0),
|
|
||||||
footer: (props) => {
|
|
||||||
return props.column.id === 'realization_amount' &&
|
|
||||||
isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.hpp.summary?.realization?.amount || 0
|
|
||||||
)
|
|
||||||
: '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
renderCustomRow={(row) => {
|
|
||||||
const rowData = row.original;
|
|
||||||
if (rowData.code === 'custom_row') {
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={row.id}
|
|
||||||
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
|
||||||
></td>
|
|
||||||
<td
|
|
||||||
colSpan={7}
|
|
||||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
|
||||||
>
|
|
||||||
<div className='font-bold'>
|
|
||||||
{formatTitleCase(rowData.label ?? '-')}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}}
|
|
||||||
renderFooter={isResponseSuccess(finance)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card
|
|
||||||
title='Profit/Loss'
|
|
||||||
variant='bordered'
|
|
||||||
collapsible
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='mt-6 p-0 mb-0'>
|
|
||||||
<Table<ProfitLossItem>
|
|
||||||
data={profitLossTableData}
|
|
||||||
isLoading={isLoading}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Jenis',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) => item.label,
|
|
||||||
cell: (item) => (
|
|
||||||
<div className=''>
|
|
||||||
{formatTitleCase(item.row.original.label || '-')}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
footer: () => (
|
|
||||||
<div className='font-bold uppercase'>LABA RUGI NETTO</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Rp/Ekor',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
|
|
||||||
footer: () => (
|
|
||||||
<div className='font-bold'>
|
|
||||||
{isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.profit_loss.summary.net_profit
|
|
||||||
.rp_per_bird || 0
|
|
||||||
)
|
|
||||||
: formatCurrency(0)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Rp/Kg',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
|
|
||||||
footer: () => (
|
|
||||||
<div className='font-bold'>
|
|
||||||
{isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.profit_loss.summary.net_profit
|
|
||||||
.rp_per_kg || 0
|
|
||||||
)
|
|
||||||
: formatCurrency(0)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Jumlah (Rp)',
|
|
||||||
enableSorting: false,
|
|
||||||
accessorFn: (item) => formatCurrency(item.amount || 0),
|
|
||||||
footer: () => (
|
|
||||||
<div className='font-bold'>
|
|
||||||
{isResponseSuccess(finance)
|
|
||||||
? formatCurrency(
|
|
||||||
finance.data.profit_loss.summary.net_profit
|
|
||||||
.amount || 0
|
|
||||||
)
|
|
||||||
: formatCurrency(0)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
renderCustomRow={(row) => {
|
|
||||||
const rowData = row.original;
|
|
||||||
if (rowData.code === 'custom_row') {
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={row.id}
|
|
||||||
className={TABLE_DEFAULT_STYLING.footerRowClassName}
|
|
||||||
>
|
|
||||||
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
|
|
||||||
<div className='font-bold ps-6 uppercase'>
|
|
||||||
{formatTitleCase(rowData.label ?? '-')}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
|
|
||||||
<div className='font-bold'>
|
|
||||||
{formatCurrency(rowData.rp_per_bird ?? 0)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
|
|
||||||
<div className='font-bold'>
|
|
||||||
{formatCurrency(rowData.rp_per_kg ?? 0)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
|
|
||||||
<div className='font-bold'>
|
|
||||||
{formatCurrency(rowData.amount ?? 0)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}}
|
|
||||||
className={{
|
|
||||||
paginationClassName: 'hidden',
|
|
||||||
}}
|
|
||||||
renderFooter={isResponseSuccess(finance)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ClosingFinanceTable;
|
|
||||||
@@ -10,18 +10,18 @@ const ClosingKandangList = ({
|
|||||||
projectData?: ProjectFlock;
|
projectData?: ProjectFlock;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='w-full my-4 @container'>
|
<div className='w-full py-3 @container relative before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10'>
|
||||||
<div className='flex flex-col @sm:flex-row gap-4'>
|
<div className='flex flex-col @sm:flex-row gap-4'>
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
<div className='overflow-x-auto'>
|
<div className='overflow-x-auto'>
|
||||||
<h1 className='font-bold my-4'>Kandang</h1>
|
<h1 className='font-bold mb-3'>Kandang</h1>
|
||||||
<div className='flex flex-wrap gap-2 mb-4'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{projectData?.kandangs?.map((kandang) => (
|
{projectData?.kandangs?.map((kandang) => (
|
||||||
<Button
|
<Button
|
||||||
key={kandang.id}
|
key={kandang.id}
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
className='px-3 py-2.5 w-fit text-sm rounded-lg shadow-sm'
|
||||||
href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`}
|
href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`}
|
||||||
className='min-w-32'
|
|
||||||
>
|
>
|
||||||
{kandang.name}
|
{kandang.name}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import { ClosingApi } from '@/services/api/closing';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { formatNumber } from '@/lib/helper';
|
|
||||||
|
|
||||||
interface ClosingProductionDataTabContentProps {
|
|
||||||
projectFlockId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ClosingProductionDataTabContent = ({
|
|
||||||
projectFlockId,
|
|
||||||
}: ClosingProductionDataTabContentProps) => {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const kandangId = searchParams.get('kandangId');
|
|
||||||
|
|
||||||
const { data: productionData, isLoading } = useSWR(
|
|
||||||
`${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`,
|
|
||||||
() => ClosingApi.getProductionData(projectFlockId, Number(kandangId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className='w-full flex justify-center py-8'>
|
|
||||||
<span className='loading loading-spinner loading-lg' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!productionData || !isResponseSuccess(productionData)) {
|
|
||||||
return (
|
|
||||||
<div className='w-full text-center py-8 text-gray-500'>
|
|
||||||
Gagal memuat data produksi.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { purchase, sales, performance } = productionData.data;
|
|
||||||
|
|
||||||
// Helper for consistent row styling
|
|
||||||
const DataRow = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
unit = '',
|
|
||||||
valueClassName = 'font-bold text-gray-800',
|
|
||||||
unitClassName = 'text-gray-500 w-12 text-right',
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
unit?: string;
|
|
||||||
valueClassName?: string;
|
|
||||||
unitClassName?: string;
|
|
||||||
}) => (
|
|
||||||
<div className='flex justify-between items-center py-1'>
|
|
||||||
<span className='text-gray-500 text-sm font-medium w-1/2'>{label}</span>
|
|
||||||
<div className='flex gap-2 w-1/2 justify-end items-center'>
|
|
||||||
<span className={valueClassName}>{value}</span>
|
|
||||||
{unit && <span className={unitClassName}>{unit}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full rounded-xl p-8 shadow-sm'>
|
|
||||||
<h2 className='text-lg font-bold mb-8 text-gray-800'>Data Produksi</h2>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-x-24 gap-y-12 relative'>
|
|
||||||
{/* Left Column */}
|
|
||||||
<div className='space-y-10'>
|
|
||||||
{/* Purchase Section */}
|
|
||||||
<section>
|
|
||||||
<h3 className='font-bold text-gray-700 mb-4 text-base'>
|
|
||||||
Pembelian
|
|
||||||
</h3>
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<DataRow
|
|
||||||
label='Populasi Awal'
|
|
||||||
value={formatNumber(purchase.initial_population)}
|
|
||||||
unit='Ekor'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Claim Culling'
|
|
||||||
value={formatNumber(purchase.claim_culling)}
|
|
||||||
unit='Ekor'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Populasi Akhir'
|
|
||||||
value={formatNumber(purchase.final_population)}
|
|
||||||
unit='Ekor'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Pakan Masuk'
|
|
||||||
value={formatNumber(purchase.feed_in)}
|
|
||||||
unit='Kg'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Pakan Terpakai'
|
|
||||||
value={formatNumber(purchase.feed_used)}
|
|
||||||
unit='Kg'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Sales Section */}
|
|
||||||
<section>
|
|
||||||
<h3 className='font-bold text-gray-700 mb-4 text-base'>
|
|
||||||
Penjualan
|
|
||||||
</h3>
|
|
||||||
<div className='space-y-4'>
|
|
||||||
{/* Chicken Sales */}
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<DataRow
|
|
||||||
label='Penjualan (Ekor)'
|
|
||||||
value={formatNumber(sales.chicken.sales_population)}
|
|
||||||
unit='Ekor'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Penjualan (Kg)'
|
|
||||||
value={formatNumber(sales.chicken.sales_weight)}
|
|
||||||
unit='Kg'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Bobot Rata-Rata'
|
|
||||||
value={formatNumber(sales.chicken.avg_weight)}
|
|
||||||
unit='Kg/Ekor'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Harga Jual Rata-Rata'
|
|
||||||
value={formatNumber(sales.chicken.avg_selling_price)}
|
|
||||||
unit='Rupiah'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Egg Sales (if available) */}
|
|
||||||
{sales.egg && (
|
|
||||||
<>
|
|
||||||
<div className='h-px bg-gray-100 my-2' />
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<DataRow
|
|
||||||
label='Telur (Butir)'
|
|
||||||
value={formatNumber(sales.egg.egg_pieces)}
|
|
||||||
unit='Butir'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Telur (Kg)'
|
|
||||||
value={formatNumber(sales.egg.egg_mass)}
|
|
||||||
unit='Kg'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Berat Telur Rata-Rata'
|
|
||||||
value={formatNumber(sales.egg.avg_egg_weight)}
|
|
||||||
unit='Kg'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Harga Jual Telur Rata-Rata'
|
|
||||||
value={formatNumber(sales.egg.avg_selling_price)}
|
|
||||||
unit='Rupiah'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider Line (Absolute centered) */}
|
|
||||||
<div className='hidden lg:block absolute left-1/2 top-0 bottom-0 w-px bg-gray-200 -translate-x-1/2' />
|
|
||||||
|
|
||||||
{/* Right Column */}
|
|
||||||
<div className='space-y-10 flex flex-col h-full'>
|
|
||||||
{/* Performance Section */}
|
|
||||||
<section>
|
|
||||||
<h3 className='font-bold text-gray-700 mb-4 text-base'>
|
|
||||||
Performance
|
|
||||||
</h3>
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<DataRow
|
|
||||||
label='Deplesi'
|
|
||||||
value={formatNumber(performance.depletion)}
|
|
||||||
unit='Ekor'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Umur'
|
|
||||||
value={formatNumber(performance.age_day)}
|
|
||||||
unit='Hari'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Mortalitas Std'
|
|
||||||
value={formatNumber(performance.mor_std)}
|
|
||||||
unitClassName='hidden'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Mortalitas Act'
|
|
||||||
value={formatNumber(performance.mor_act)}
|
|
||||||
unitClassName='hidden'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='DEFF Mortalitas'
|
|
||||||
value={formatNumber(performance.mor_diff)}
|
|
||||||
unitClassName='hidden'
|
|
||||||
/>
|
|
||||||
{/* <DataRow
|
|
||||||
label='AWG Std'
|
|
||||||
value={formatNumber(performance.awg_std)}
|
|
||||||
unit='Gr/Hari'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='AWG Act'
|
|
||||||
value={formatNumber(performance.awg_act)}
|
|
||||||
unit='Gr/Hari'
|
|
||||||
/> */}
|
|
||||||
<DataRow
|
|
||||||
label='Feed Intake Std'
|
|
||||||
value={formatNumber(performance.feed_intake_std)}
|
|
||||||
unitClassName='hidden'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Feed Intake Act'
|
|
||||||
value={formatNumber(performance.feed_intake)}
|
|
||||||
unitClassName='hidden'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='FCR Std'
|
|
||||||
value={formatNumber(performance.fcr_std)}
|
|
||||||
unitClassName='hidden'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='FCR Act'
|
|
||||||
value={formatNumber(performance.fcr_act)}
|
|
||||||
unitClassName='hidden'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='DEFF FCR'
|
|
||||||
value={formatNumber(performance.fcr_diff)}
|
|
||||||
unitClassName='hidden'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Laying Specific Fields */}
|
|
||||||
{performance.hen_day_act !== undefined && (
|
|
||||||
<>
|
|
||||||
<DataRow
|
|
||||||
label='Hen Day Std'
|
|
||||||
value={formatNumber(performance.hen_day_std!)}
|
|
||||||
unit='%'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Hen Day Act'
|
|
||||||
value={formatNumber(performance.hen_day_act)}
|
|
||||||
unit='%'
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{performance.egg_mass !== undefined && (
|
|
||||||
<>
|
|
||||||
<DataRow
|
|
||||||
label='Egg Mass Std'
|
|
||||||
value={formatNumber(performance.egg_mass_std!)}
|
|
||||||
unit='Kg'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Egg Mass Act'
|
|
||||||
value={formatNumber(performance.egg_mass)}
|
|
||||||
unit='Kg'
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{performance.egg_weight !== undefined && (
|
|
||||||
<>
|
|
||||||
<DataRow
|
|
||||||
label='Egg Weight Std'
|
|
||||||
value={formatNumber(performance.egg_weight_std!)}
|
|
||||||
unit='Gr'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Egg Weight Act'
|
|
||||||
value={formatNumber(performance.egg_weight)}
|
|
||||||
unit='Gr'
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{performance.hen_housed_act !== undefined && (
|
|
||||||
<>
|
|
||||||
<DataRow
|
|
||||||
label='Hen Housed Std'
|
|
||||||
value={formatNumber(performance.hen_housed_std!)}
|
|
||||||
unit='%'
|
|
||||||
/>
|
|
||||||
<DataRow
|
|
||||||
label='Hen Housed Act'
|
|
||||||
value={formatNumber(performance.hen_housed_act)}
|
|
||||||
unit='%'
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ClosingProductionDataTabContent;
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
|
|
||||||
import Table from '@/components/Table';
|
|
||||||
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
|
||||||
import {
|
|
||||||
RowSapronakCalculation,
|
|
||||||
TotalSapronakCalculation,
|
|
||||||
} from '@/types/api/closing';
|
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import { ClosingApi } from '@/services/api/closing';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { ClosingGeneralInformation } from '@/types/api/closing';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
interface ClosingSapronakCalculationTableProps {
|
|
||||||
projectFlockId: number;
|
|
||||||
closingGeneralInformation?: ClosingGeneralInformation;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ClosingSapronakCalculationTable = ({
|
|
||||||
projectFlockId,
|
|
||||||
closingGeneralInformation,
|
|
||||||
}: ClosingSapronakCalculationTableProps) => {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const kandangId = searchParams.get('kandangId');
|
|
||||||
|
|
||||||
const { data: sapronakCalculation, isLoading } = useSWR(
|
|
||||||
`/closing/sapronak-calculation/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
|
|
||||||
() => ClosingApi.getPerhitunganSapronak(projectFlockId, Number(kandangId)),
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper function to create columns with footer support
|
|
||||||
const createColumns = (
|
|
||||||
total?: TotalSapronakCalculation
|
|
||||||
): ColumnDef<RowSapronakCalculation>[] => [
|
|
||||||
{
|
|
||||||
header: 'Tanggal',
|
|
||||||
accessorKey: 'date',
|
|
||||||
cell: (props) =>
|
|
||||||
props.row.original.date
|
|
||||||
? formatDate(props.row.original.date, 'DD MMM YYYY')
|
|
||||||
: '-',
|
|
||||||
footer: 'Total',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'No. Referensi',
|
|
||||||
accessorKey: 'reference_number',
|
|
||||||
cell: (props) => (props.row.original.reference_number as string) || '-',
|
|
||||||
footer: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'QTY Masuk',
|
|
||||||
accessorKey: 'qty_in',
|
|
||||||
cell: (props) =>
|
|
||||||
props.row.original.qty_in
|
|
||||||
? formatNumber(props.row.original.qty_in as number)
|
|
||||||
: '0',
|
|
||||||
footer: total
|
|
||||||
? () => (
|
|
||||||
<div className='font-semibold text-gray-900'>
|
|
||||||
{total?.qty_in ? formatNumber(total?.qty_in) : '0'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'QTY Keluar',
|
|
||||||
accessorKey: 'qty_out',
|
|
||||||
cell: (props) =>
|
|
||||||
props.row.original.qty_out
|
|
||||||
? formatNumber(props.row.original.qty_out as number)
|
|
||||||
: '0',
|
|
||||||
footer: total
|
|
||||||
? () => (
|
|
||||||
<div className='font-semibold text-gray-900'>
|
|
||||||
{total?.qty_out ? formatNumber(total?.qty_out) : '0'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'QTY Pakai',
|
|
||||||
accessorKey: 'qty_used',
|
|
||||||
cell: (props) =>
|
|
||||||
props.row.original.qty_used
|
|
||||||
? formatNumber(props.row.original.qty_used as number)
|
|
||||||
: '0',
|
|
||||||
footer: total
|
|
||||||
? () => (
|
|
||||||
<div className='font-semibold text-gray-900'>
|
|
||||||
{total?.qty_used ? formatNumber(total?.qty_used) : '0'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Uraian',
|
|
||||||
accessorKey: 'description',
|
|
||||||
cell: (props) => (props.row.original.description as string) || '-',
|
|
||||||
footer: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Kategori Produk',
|
|
||||||
accessorKey: 'product_category',
|
|
||||||
cell: (props) => (props.row.original.product_category as string) || '-',
|
|
||||||
footer: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Harga Beli/Qty (Rp)',
|
|
||||||
accessorKey: 'unit_price',
|
|
||||||
cell: (props) =>
|
|
||||||
props.row.original.unit_price
|
|
||||||
? formatCurrency(props.row.original.unit_price as number)
|
|
||||||
: '-',
|
|
||||||
footer: total
|
|
||||||
? () => (
|
|
||||||
<div className='font-semibold text-gray-900'>
|
|
||||||
{total?.avg_unit_price
|
|
||||||
? formatCurrency(total?.avg_unit_price)
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Total Harga (Rp)',
|
|
||||||
accessorKey: 'total_amount',
|
|
||||||
cell: (props) =>
|
|
||||||
props.row.original.total_amount
|
|
||||||
? formatCurrency(props.row.original.total_amount as number)
|
|
||||||
: '-',
|
|
||||||
footer: total
|
|
||||||
? () => (
|
|
||||||
<div className='font-semibold text-gray-900'>
|
|
||||||
{total?.total_amount ? formatCurrency(total?.total_amount) : '-'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Keterangan',
|
|
||||||
accessorKey: 'notes',
|
|
||||||
cell: (props) => (props.row.original.notes as string) || '-',
|
|
||||||
footer: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Memoize columns untuk setiap kategori
|
|
||||||
const docColumns = useMemo(
|
|
||||||
() =>
|
|
||||||
isResponseSuccess(sapronakCalculation)
|
|
||||||
? createColumns(sapronakCalculation.data?.doc?.total)
|
|
||||||
: createColumns(),
|
|
||||||
[sapronakCalculation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const ovkColumns = useMemo(
|
|
||||||
() =>
|
|
||||||
isResponseSuccess(sapronakCalculation)
|
|
||||||
? createColumns(sapronakCalculation.data?.ovk?.total)
|
|
||||||
: createColumns(),
|
|
||||||
[sapronakCalculation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pakanColumns = useMemo(
|
|
||||||
() =>
|
|
||||||
isResponseSuccess(sapronakCalculation)
|
|
||||||
? createColumns(sapronakCalculation.data?.pakan?.total)
|
|
||||||
: createColumns(),
|
|
||||||
[sapronakCalculation]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
{/* Table DOC jika kategori Project Flock Growing */}
|
|
||||||
<Card
|
|
||||||
title={
|
|
||||||
closingGeneralInformation?.project_type == 'GROWING'
|
|
||||||
? 'DOC'
|
|
||||||
: 'Pullet'
|
|
||||||
}
|
|
||||||
collapsible
|
|
||||||
defaultCollapsed={false}
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
body: 'p-4 shadow',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table<RowSapronakCalculation>
|
|
||||||
data={
|
|
||||||
isResponseSuccess(sapronakCalculation)
|
|
||||||
? (sapronakCalculation.data?.doc?.rows ?? [])
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
columns={docColumns}
|
|
||||||
className={{
|
|
||||||
containerClassName: 'my-4',
|
|
||||||
}}
|
|
||||||
renderFooter={
|
|
||||||
isResponseSuccess(sapronakCalculation) &&
|
|
||||||
sapronakCalculation.data?.doc?.rows.length > 0
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
title='OVK'
|
|
||||||
variant='bordered'
|
|
||||||
collapsible
|
|
||||||
defaultCollapsed={true}
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table<RowSapronakCalculation>
|
|
||||||
data={
|
|
||||||
isResponseSuccess(sapronakCalculation)
|
|
||||||
? (sapronakCalculation.data?.ovk?.rows ?? [])
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
columns={ovkColumns}
|
|
||||||
className={{
|
|
||||||
containerClassName: 'my-4',
|
|
||||||
}}
|
|
||||||
renderFooter={
|
|
||||||
isResponseSuccess(sapronakCalculation) &&
|
|
||||||
sapronakCalculation.data?.ovk?.rows.length > 0
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
title='Pakan'
|
|
||||||
variant='bordered'
|
|
||||||
collapsible
|
|
||||||
defaultCollapsed={true}
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table<RowSapronakCalculation>
|
|
||||||
data={
|
|
||||||
isResponseSuccess(sapronakCalculation)
|
|
||||||
? (sapronakCalculation.data?.pakan?.rows ?? [])
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
columns={pakanColumns}
|
|
||||||
className={{
|
|
||||||
containerClassName: 'my-4',
|
|
||||||
}}
|
|
||||||
renderFooter={
|
|
||||||
isResponseSuccess(sapronakCalculation) &&
|
|
||||||
sapronakCalculation.data?.pakan?.rows.length > 0
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ClosingSapronakCalculationTable;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
|
|
||||||
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
|
|
||||||
import ClosingIncomingSapronaksSummaryTable from '@/components/pages/closing/ClosingIncomingSapronaksSummaryTable';
|
|
||||||
import ClosingOutgoingSapronaksSummaryTable from './ClosingOutgoingSapronaksSummaryTable';
|
|
||||||
|
|
||||||
interface ClosingSapronakTableProps {
|
|
||||||
projectFlockId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ClosingSapronakTabContent = ({
|
|
||||||
projectFlockId,
|
|
||||||
}: ClosingSapronakTableProps) => {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
{projectFlockId && (
|
|
||||||
<>
|
|
||||||
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
|
|
||||||
|
|
||||||
<ClosingIncomingSapronaksSummaryTable
|
|
||||||
projectFlockId={projectFlockId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
|
|
||||||
|
|
||||||
<ClosingOutgoingSapronaksSummaryTable
|
|
||||||
projectFlockId={projectFlockId}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ClosingSapronakTabContent;
|
|
||||||
@@ -1,68 +1,116 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
import { ChangeEventHandler, useEffect, useState, useMemo } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import SelectInput, {
|
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
OptionType,
|
import PopoverButton from '@/components/popover/PopoverButton';
|
||||||
useSelect,
|
import PopoverContent from '@/components/popover/PopoverContent';
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
|
||||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
|
||||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
import { cn, formatDate } from '@/lib/helper';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
import { LocationApi } from '@/services/api/master-data';
|
import { LocationApi } from '@/services/api/master-data';
|
||||||
import { Location } from '@/types/api/master-data/location';
|
import { Location } from '@/types/api/master-data/location';
|
||||||
import { ClosingApi } from '@/services/api/closing';
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
import { Closing } from '@/types/api/closing';
|
import { Closing } from '@/types/api/closing';
|
||||||
|
import { Color } from '@/types/theme';
|
||||||
const PROJECT_STATUS_OPTIONS = [
|
import {
|
||||||
{
|
ClosingFilterSchema,
|
||||||
value: 1,
|
ClosingFilterType,
|
||||||
label: 'Pengajuan',
|
} from '@/components/pages/closing/filter/ClosingFilter';
|
||||||
},
|
import ClosingTableSkeleton from '@/components/pages/closing/skeleton/ClosingTableSkeleton';
|
||||||
{
|
|
||||||
value: 2,
|
|
||||||
label: 'Aktif',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
type = 'dropdown',
|
|
||||||
props,
|
props,
|
||||||
|
popoverPosition = 'bottom',
|
||||||
|
detailClickHandler,
|
||||||
}: {
|
}: {
|
||||||
type: 'dropdown' | 'collapse';
|
|
||||||
props: CellContext<Closing, unknown>;
|
props: CellContext<Closing, unknown>;
|
||||||
|
popoverPosition: 'bottom' | 'top';
|
||||||
|
detailClickHandler: (id: number) => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
const popoverId = `closing#${props.row.original.id}`;
|
||||||
|
const popoverAnchorName = `--anchor-closing#${props.row.original.id}`;
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
document.getElementById(popoverId)?.hidePopover();
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailClickHandlerWrapper = () => {
|
||||||
|
detailClickHandler(props.row.original.id);
|
||||||
|
closePopover();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RowOptionsMenuWrapper type={type}>
|
<div className='relative'>
|
||||||
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
|
<PopoverButton
|
||||||
<RequirePermission permissions='lti.closing.detail'>
|
tabIndex={0}
|
||||||
<Button
|
variant='ghost'
|
||||||
href={`/closing/detail/?closingId=${props.row.original.id}`}
|
color='none'
|
||||||
variant='ghost'
|
popoverTarget={popoverId}
|
||||||
color='primary'
|
anchorName={popoverAnchorName}
|
||||||
className='justify-start text-sm'
|
>
|
||||||
>
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
</PopoverButton>
|
||||||
Detail
|
|
||||||
</Button>
|
<PopoverContent
|
||||||
</RequirePermission>
|
id={popoverId}
|
||||||
</div>
|
anchorName={popoverAnchorName}
|
||||||
</RowOptionsMenuWrapper>
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
|
<RequirePermission permissions='lti.closing.detail'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={detailClickHandlerWrapper}
|
||||||
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClosingsTable = () => {
|
const ClosingsTable = () => {
|
||||||
|
// ===== ROUTER =====
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ===== STATUS BADGE COLOR HELPER =====
|
||||||
|
const getProjectStatusBadgeColor = (status: string): Color => {
|
||||||
|
const normalizedValue = status.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedValue === 'aktif') {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedValue === 'pengajuan') {
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -72,36 +120,67 @@ const ClosingsTable = () => {
|
|||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: {
|
initial: {
|
||||||
search: '',
|
search: '',
|
||||||
nameSort: '',
|
// nameSort: '',
|
||||||
transactionDate: '',
|
// transactionDate: '',
|
||||||
realizationDate: '',
|
// realizationDate: '',
|
||||||
locationId: '',
|
location_id: '',
|
||||||
projectStatus: '',
|
project_status: '',
|
||||||
userId: '',
|
// userId: '',
|
||||||
},
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
nameSort: 'sort_name',
|
// nameSort: 'sort_name',
|
||||||
transactionDate: 'transaction_date',
|
// transactionDate: 'transaction_date',
|
||||||
realizationDate: 'realization_date',
|
// realizationDate: 'realization_date',
|
||||||
locationId: 'location_id',
|
// locationId: 'location_id',
|
||||||
projectStatus: 'project_status',
|
// projectStatus: 'project_status',
|
||||||
userId: 'user_id',
|
// userId: 'user_id',
|
||||||
|
search: 'search',
|
||||||
|
location_id: 'location_id',
|
||||||
|
project_status: 'project_status',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<ClosingFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
location_id: null,
|
||||||
|
project_status: null,
|
||||||
|
},
|
||||||
|
validationSchema: ClosingFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('location_id', values.location_id || '');
|
||||||
|
updateFilter('project_status', values.project_status || '');
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('location_id', '');
|
||||||
|
updateFilter('project_status', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== DATA FETCHING =====
|
||||||
const { data: closings, isLoading: isLoadingClosings } = useSWR(
|
const { data: closings, isLoading: isLoadingClosings } = useSWR(
|
||||||
`${ClosingApi.basePath}${getTableFilterQueryString()}`,
|
`${ClosingApi.basePath}${getTableFilterQueryString()}`,
|
||||||
ClosingApi.getAllFetcher
|
ClosingApi.getAllFetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(closings) ? (closings?.data as Closing[]) || [] : [],
|
||||||
|
[closings]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== PAGINATION & STATE =====
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// ===== TABLE COLUMNS =====
|
||||||
const closingsColumns: ColumnDef<Closing>[] = [
|
const closingsColumns: ColumnDef<Closing>[] = [
|
||||||
{
|
{
|
||||||
header: '#',
|
header: 'No',
|
||||||
cell: (props) => props.row.index + 1,
|
cell: (props) => props.row.index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -133,6 +212,19 @@ const ClosingsTable = () => {
|
|||||||
{
|
{
|
||||||
accessorKey: 'project_status',
|
accessorKey: 'project_status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
|
cell: (props) => {
|
||||||
|
const status = props.row.original.project_status;
|
||||||
|
const badgeColor = getProjectStatusBadgeColor(status);
|
||||||
|
return (
|
||||||
|
<StatusBadge
|
||||||
|
color={badgeColor}
|
||||||
|
text={status}
|
||||||
|
className={{
|
||||||
|
badge: 'whitespace-nowrap',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Aksi',
|
header: 'Aksi',
|
||||||
@@ -142,27 +234,24 @@ const ClosingsTable = () => {
|
|||||||
const currentRowRelativeIndex =
|
const currentRowRelativeIndex =
|
||||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||||
|
|
||||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
|
const detailClickHandler = (id: number) => {
|
||||||
|
router.push(`/closing/detail/?closingId=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RowOptionsMenu
|
||||||
{currentPageSize > 3 && (
|
props={props}
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
detailClickHandler={detailClickHandler}
|
||||||
<RowOptionsMenu type='dropdown' props={props} />
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
</RowDropdownOptions>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPageSize <= 3 && (
|
|
||||||
<RowCollapseOptions>
|
|
||||||
<RowOptionsMenu type='collapse' props={props} />
|
|
||||||
</RowCollapseOptions>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ===== LOCATION OPTIONS =====
|
||||||
const {
|
const {
|
||||||
setInputValue: setLocationInputValue,
|
setInputValue: setLocationInputValue,
|
||||||
options: locationOptions,
|
options: locationOptions,
|
||||||
@@ -170,115 +259,246 @@ const ClosingsTable = () => {
|
|||||||
loadMore: loadMoreLocations,
|
loadMore: loadMoreLocations,
|
||||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
// ===== PROJECT STATUS OPTIONS =====
|
||||||
null
|
const projectStatusOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: '1', label: 'Pengajuan' },
|
||||||
|
{ value: '2', label: 'Aktif' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
// ===== FILTER HELPERS =====
|
||||||
setSelectedLocation(val as OptionType);
|
const locationIdValue = useMemo(() => {
|
||||||
updateFilter(
|
if (!formik.values.location_id) return null;
|
||||||
'locationId',
|
return (
|
||||||
val ? ((val as OptionType).value as string) : ''
|
locationOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.location_id
|
||||||
|
) || null
|
||||||
);
|
);
|
||||||
};
|
}, [formik.values.location_id, locationOptions]);
|
||||||
|
|
||||||
const [selectedProjectStatus, setSelectedProjectStatus] =
|
const projectStatusValue = useMemo(() => {
|
||||||
useState<OptionType | null>(null);
|
if (!formik.values.project_status) return null;
|
||||||
|
return (
|
||||||
const projectStatusChangeHandler = (
|
projectStatusOptions.find(
|
||||||
val: OptionType | OptionType[] | null
|
(opt) => opt.value === formik.values.project_status
|
||||||
) => {
|
) || null
|
||||||
setSelectedProjectStatus(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'projectStatus',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
);
|
||||||
};
|
}, [formik.values.project_status, projectStatusOptions]);
|
||||||
|
|
||||||
|
// ===== ACTIVE FILTERS COUNT =====
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (tableFilterState.location_id) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableFilterState.project_status) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}, [tableFilterState.location_id, tableFilterState.project_status]);
|
||||||
|
|
||||||
|
const hasFilters = activeFiltersCount > 0;
|
||||||
|
|
||||||
|
// ===== SEARCH CHANGE HANDLER =====
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
updateFilter('search', e.target.value);
|
updateFilter('search', e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
// track sorting
|
// track sorting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
|
|
||||||
if (!isNameSorted) {
|
if (!isNameSorted) {
|
||||||
updateFilter('nameSort', '');
|
// updateFilter('nameSort', '');
|
||||||
} else {
|
} else {
|
||||||
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
// updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||||
}
|
}
|
||||||
}, [sorting, updateFilter]);
|
}, [sorting]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='w-full p-0 sm:p-4'>
|
<div className='w-full'>
|
||||||
<div className='flex flex-col gap-2 mb-4'>
|
<div className='flex flex-col mb-4'>
|
||||||
<div className='flex flex-col gap-2 mb-4'>
|
<div className='relative w-full p-3 pt-0 px-0 flex flex-row justify-between gap-3 flex-wrap after:absolute after:bottom-0 after:left-0 after:right-0 after:-mx-4 after:border-b after:border-base-content/10'>
|
||||||
<div className='w-full flex flex-col sm:flex-row justify-end items-end sm:items-center gap-4'>
|
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||||
<DebouncedTextInput
|
<DebouncedTextInput
|
||||||
name='search'
|
name='search'
|
||||||
placeholder='Cari Closing'
|
placeholder='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
/>
|
<Icon
|
||||||
</div>
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
<div className='grid grid-cols-12 justify-end gap-2'>
|
height={20}
|
||||||
<SelectInput
|
/>
|
||||||
label='Lokasi'
|
}
|
||||||
options={locationOptions}
|
|
||||||
isLoading={isLoadingLocationOptions}
|
|
||||||
value={selectedLocation}
|
|
||||||
onChange={locationChangeHandler}
|
|
||||||
onInputChange={setLocationInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreLocations}
|
|
||||||
isClearable
|
|
||||||
className={{
|
className={{
|
||||||
wrapper: 'col-span-12 sm:col-span-6',
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
|
input:
|
||||||
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectInput
|
<Button
|
||||||
label='Status Project'
|
variant='outline'
|
||||||
placeholder='Pilih Status'
|
color='none'
|
||||||
options={PROJECT_STATUS_OPTIONS}
|
onClick={handleFilterModalOpen}
|
||||||
value={selectedProjectStatus}
|
className={cn(
|
||||||
onChange={projectStatusChangeHandler}
|
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||||
isClearable
|
{
|
||||||
className={{
|
'border-primary-gradient text-primary': hasFilters,
|
||||||
wrapper: 'col-span-12 sm:col-span-6',
|
}
|
||||||
}}
|
)}
|
||||||
/>
|
>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
Filter
|
||||||
|
{hasFilters && (
|
||||||
|
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||||
|
{activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Table<Closing>
|
{isLoadingClosings ? (
|
||||||
data={isResponseSuccess(closings) ? closings?.data : []}
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
columns={closingsColumns}
|
<span className='loading loading-spinner loading-xl' />
|
||||||
pageSize={tableFilterState.pageSize}
|
</div>
|
||||||
onPageSizeChange={setPageSize}
|
) : data.length === 0 ? (
|
||||||
rowOptions={[10, 20, 50, 100]}
|
<ClosingTableSkeleton
|
||||||
page={isResponseSuccess(closings) ? closings?.meta?.page : 0}
|
columns={closingsColumns}
|
||||||
totalItems={
|
icon={
|
||||||
isResponseSuccess(closings) ? closings?.meta?.total_results : 0
|
<Icon
|
||||||
}
|
icon='heroicons:chart-bar'
|
||||||
onPageChange={setPage}
|
className='text-white'
|
||||||
isLoading={isLoadingClosings}
|
width={20}
|
||||||
sorting={sorting}
|
height={20}
|
||||||
setSorting={setSorting}
|
/>
|
||||||
rowSelection={rowSelection}
|
}
|
||||||
setRowSelection={setRowSelection}
|
title='Data Closing Belum Tersedia'
|
||||||
className={{
|
subtitle='Tidak ada data closing untuk saat ini.'
|
||||||
containerClassName: cn({
|
/>
|
||||||
'w-full mb-20':
|
) : (
|
||||||
isResponseSuccess(closings) && closings?.data?.length === 0,
|
<Table<Closing>
|
||||||
}),
|
data={isResponseSuccess(closings) ? closings?.data : []}
|
||||||
}}
|
columns={closingsColumns}
|
||||||
/>
|
pageSize={tableFilterState.pageSize}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
rowOptions={[10, 20, 50, 100]}
|
||||||
|
page={isResponseSuccess(closings) ? closings?.meta?.page : 0}
|
||||||
|
totalItems={
|
||||||
|
isResponseSuccess(closings) ? closings?.meta?.total_results : 0
|
||||||
|
}
|
||||||
|
onPageChange={setPage}
|
||||||
|
isLoading={isLoadingClosings}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
setRowSelection={setRowSelection}
|
||||||
|
className={{
|
||||||
|
containerClassName: cn('mt-3', {
|
||||||
|
'w-full mb-0':
|
||||||
|
isResponseSuccess(closings) && closings?.data?.length === 0,
|
||||||
|
}),
|
||||||
|
headerColumnClassName: 'text-nowrap',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
value={locationIdValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'location_id',
|
||||||
|
val?.value ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
isLoading={isLoadingLocationOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreLocations}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Status Project'
|
||||||
|
placeholder='Pilih Status'
|
||||||
|
options={projectStatusOptions}
|
||||||
|
value={projectStatusValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
formik.setFieldValue('project_status', val?.value || null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
isClearable={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='reset'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type ClosingFilterType = {
|
||||||
|
location_id: string | null;
|
||||||
|
project_status: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClosingFilterSchema = yup.object({
|
||||||
|
location_id: yup.string().nullable(),
|
||||||
|
project_status: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ClosingFilterValues = yup.InferType<typeof ClosingFilterSchema>;
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
'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 = ({
|
|
||||||
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;
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const ClosingTabSkeleton = <T extends object>({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<T, unknown>[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClosingTabSkeleton;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { Closing } from '@/types/api/closing';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const ClosingTableSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<Closing>[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClosingTableSkeleton;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Card from '@/components/Card';
|
||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
|
||||||
|
const FinanceClosingSkeleton = ({
|
||||||
|
title = 'Data Keuangan Belum Tersedia',
|
||||||
|
subtitle = 'Tidak ada data keuangan untuk periode ini.',
|
||||||
|
iconName = 'heroicons:chart-bar',
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
iconName?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant='bordered'
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full',
|
||||||
|
body: 'p-8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-center p-8'>
|
||||||
|
<DataStateSkeleton
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon={iconName}
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={title}
|
||||||
|
description={subtitle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinanceClosingSkeleton;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import ClosingTabSkeleton from './ClosingTabSkeleton';
|
||||||
|
import { BaseExpeditionCost } from '@/types/api/closing';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const HppExpeditionClosingSkeleton = ({
|
||||||
|
columns,
|
||||||
|
title = 'Data HPP Ekspedisi Belum Tersedia',
|
||||||
|
subtitle = 'Tidak ada data HPP ekspedisi untuk periode ini.',
|
||||||
|
iconName = 'heroicons:chart-bar',
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<BaseExpeditionCost>[];
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
iconName?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ClosingTabSkeleton<BaseExpeditionCost>
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon icon={iconName} className='text-white' width={20} height={20} />
|
||||||
|
}
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HppExpeditionClosingSkeleton;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import ClosingTabSkeleton from './ClosingTabSkeleton';
|
||||||
|
import { Overhead } from '@/types/api/closing';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const OverheadClosingSkeleton = ({
|
||||||
|
columns,
|
||||||
|
title = 'Data Overhead Belum Tersedia',
|
||||||
|
subtitle = 'Tidak ada data overhead untuk periode ini.',
|
||||||
|
iconName = 'heroicons:chart-bar',
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<Overhead>[];
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
iconName?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ClosingTabSkeleton<Overhead>
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon icon={iconName} className='text-white' width={20} height={20} />
|
||||||
|
}
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OverheadClosingSkeleton;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
|
||||||
|
const ProductionDataClosingSkeleton = ({
|
||||||
|
title = 'Data Produksi Belum Tersedia',
|
||||||
|
subtitle = 'Tidak ada data produksi untuk periode ini.',
|
||||||
|
iconName = 'heroicons:chart-bar',
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
iconName?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='w-full rounded-xl p-8 shadow-sm'>
|
||||||
|
<div className='flex items-center justify-center p-12'>
|
||||||
|
<DataStateSkeleton
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon={iconName}
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={title}
|
||||||
|
description={subtitle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionDataClosingSkeleton;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import ClosingTabSkeleton from './ClosingTabSkeleton';
|
||||||
|
import { BaseSales } from '@/types/api/closing';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const SalesClosingSkeleton = ({
|
||||||
|
columns,
|
||||||
|
title = 'Data Penjualan Belum Tersedia',
|
||||||
|
subtitle = 'Tidak ada data penjualan untuk periode ini.',
|
||||||
|
iconName = 'heroicons:chart-bar',
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<BaseSales>[];
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
iconName?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ClosingTabSkeleton<BaseSales>
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon icon={iconName} className='text-white' width={20} height={20} />
|
||||||
|
}
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SalesClosingSkeleton;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import ClosingTabSkeleton from './ClosingTabSkeleton';
|
||||||
|
import { RowSapronakCalculation } from '@/types/api/closing';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const SapronakCalculationClosingSkeleton = ({
|
||||||
|
columns,
|
||||||
|
title = 'Data Perhitungan Sapronak Belum Tersedia',
|
||||||
|
subtitle = 'Tidak ada data perhitungan sapronak untuk periode ini.',
|
||||||
|
iconName = 'heroicons:chart-bar',
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<RowSapronakCalculation>[];
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
iconName?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ClosingTabSkeleton<RowSapronakCalculation>
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon icon={iconName} className='text-white' width={20} height={20} />
|
||||||
|
}
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SapronakCalculationClosingSkeleton;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import ClosingTabSkeleton from './ClosingTabSkeleton';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const SapronakClosingSkeleton = <T extends object>({
|
||||||
|
columns,
|
||||||
|
type = 'incoming',
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
iconName = 'heroicons:chart-bar',
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<T, unknown>[];
|
||||||
|
type?: 'incoming' | 'outgoing';
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
iconName?: string;
|
||||||
|
}) => {
|
||||||
|
const defaultTitle =
|
||||||
|
type === 'incoming'
|
||||||
|
? 'Data Sapronak Masuk Belum Tersedia'
|
||||||
|
: 'Data Sapronak Keluar Belum Tersedia';
|
||||||
|
|
||||||
|
const defaultSubtitle =
|
||||||
|
type === 'incoming'
|
||||||
|
? 'Tidak ada data sapronak masuk untuk periode ini.'
|
||||||
|
: 'Tidak ada data sapronak keluar untuk periode ini.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClosingTabSkeleton<T>
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon icon={iconName} className='text-white' width={20} height={20} />
|
||||||
|
}
|
||||||
|
title={title || defaultTitle}
|
||||||
|
subtitle={subtitle || defaultSubtitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SapronakClosingSkeleton;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import FinanceClosingTable from '@/components/pages/closing/table/FinanceClosingTable';
|
||||||
|
|
||||||
|
const FinanceClosingTab = ({ projectFlockId }: { projectFlockId: number }) => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
{projectFlockId && (
|
||||||
|
<FinanceClosingTable projectFlockId={projectFlockId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinanceClosingTab;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import HppExpeditionClosingTable from '@/components/pages/closing/table/HppExpeditionClosingTable';
|
||||||
|
|
||||||
|
interface HppExpeditionClosingTabProps {
|
||||||
|
projectFlockId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HppExpeditionClosingTab = ({
|
||||||
|
projectFlockId,
|
||||||
|
}: HppExpeditionClosingTabProps) => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
{projectFlockId && (
|
||||||
|
<HppExpeditionClosingTable projectFlockId={projectFlockId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HppExpeditionClosingTab;
|
||||||
+6
-6
@@ -1,22 +1,22 @@
|
|||||||
import ClosingOverheadTable from '@/components/pages/closing/ClosingOverheadTable';
|
import OverheadClosingTable from '@/components/pages/closing/table/OverheadClosingTable';
|
||||||
import { ClosingGeneralInformation } from '@/types/api/closing';
|
import { ClosingGeneralInformation } from '@/types/api/closing';
|
||||||
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
||||||
|
|
||||||
interface ClosingOverheadTabContentProps {
|
interface OverheadClosingTabProps {
|
||||||
projectFlockId: number;
|
projectFlockId: number;
|
||||||
generalInformation?: ClosingGeneralInformation;
|
generalInformation?: ClosingGeneralInformation;
|
||||||
kandangData?: ProjectFlockKandang;
|
kandangData?: ProjectFlockKandang;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClosingOverheadTabContent = ({
|
const OverheadClosingTab = ({
|
||||||
projectFlockId,
|
projectFlockId,
|
||||||
generalInformation,
|
generalInformation,
|
||||||
kandangData,
|
kandangData,
|
||||||
}: ClosingOverheadTabContentProps) => {
|
}: OverheadClosingTabProps) => {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
{projectFlockId && (
|
{projectFlockId && (
|
||||||
<ClosingOverheadTable
|
<OverheadClosingTable
|
||||||
projectFlockId={projectFlockId}
|
projectFlockId={projectFlockId}
|
||||||
generalInformation={generalInformation}
|
generalInformation={generalInformation}
|
||||||
kandangData={kandangData}
|
kandangData={kandangData}
|
||||||
@@ -26,4 +26,4 @@ const ClosingOverheadTabContent = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ClosingOverheadTabContent;
|
export default OverheadClosingTab;
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { formatNumber } from '@/lib/helper';
|
||||||
|
import ProductionDataClosingSkeleton from '@/components/pages/closing/skeleton/ProductionDataClosingSkeleton';
|
||||||
|
import Card from '@/components/Card';
|
||||||
|
|
||||||
|
interface ProductionDataClosingTabProps {
|
||||||
|
projectFlockId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductionDataClosingTab = ({
|
||||||
|
projectFlockId,
|
||||||
|
}: ProductionDataClosingTabProps) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const kandangId = searchParams.get('kandangId');
|
||||||
|
|
||||||
|
const { data: productionData, isLoading } = useSWR(
|
||||||
|
`${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`,
|
||||||
|
() => ClosingApi.getProductionData(projectFlockId, Number(kandangId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ProductionDataClosingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!productionData || !isResponseSuccess(productionData)) {
|
||||||
|
return (
|
||||||
|
<ProductionDataClosingSkeleton
|
||||||
|
iconName='heroicons:exclamation-circle'
|
||||||
|
title='Gagal Memuat Data Produksi'
|
||||||
|
subtitle='Terjadi kesalahan saat memuat data produksi. Silakan coba lagi.'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { purchase, sales, performance } = productionData.data;
|
||||||
|
|
||||||
|
// Helper for consistent row styling
|
||||||
|
const DataRow = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit = '',
|
||||||
|
valueClassName = 'font-bold text-gray-800',
|
||||||
|
unitClassName = 'text-gray-500 w-12 text-right',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
unit?: string;
|
||||||
|
valueClassName?: string;
|
||||||
|
unitClassName?: string;
|
||||||
|
}) => (
|
||||||
|
<div className='flex justify-between items-center py-1'>
|
||||||
|
<span className='text-gray-500 text-sm font-medium w-1/2'>{label}</span>
|
||||||
|
<div className='flex gap-2 w-1/2 justify-end items-center'>
|
||||||
|
<span className={valueClassName}>{value}</span>
|
||||||
|
{unit && <span className={unitClassName}>{unit}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full pt-3'>
|
||||||
|
<Card
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
|
title='Data Produksi'
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={false}
|
||||||
|
>
|
||||||
|
<div className='p-6'>
|
||||||
|
<div className='grid grid-cols-1 lg:grid-cols-2 gap-x-24 gap-y-12 relative'>
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className='space-y-10'>
|
||||||
|
{/* Purchase Section */}
|
||||||
|
<section>
|
||||||
|
<h3 className='font-bold text-gray-700 mb-4 text-base'>
|
||||||
|
Pembelian
|
||||||
|
</h3>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<DataRow
|
||||||
|
label='Populasi Awal'
|
||||||
|
value={formatNumber(purchase.initial_population)}
|
||||||
|
unit='Ekor'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Claim Culling'
|
||||||
|
value={formatNumber(purchase.claim_culling)}
|
||||||
|
unit='Ekor'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Populasi Akhir'
|
||||||
|
value={formatNumber(purchase.final_population)}
|
||||||
|
unit='Ekor'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Pakan Masuk'
|
||||||
|
value={formatNumber(purchase.feed_in)}
|
||||||
|
unit='Kg'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Pakan Terpakai'
|
||||||
|
value={formatNumber(purchase.feed_used)}
|
||||||
|
unit='Kg'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sales Section */}
|
||||||
|
<section>
|
||||||
|
<h3 className='font-bold text-gray-700 mb-4 text-base'>
|
||||||
|
Penjualan
|
||||||
|
</h3>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* Chicken Sales */}
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<DataRow
|
||||||
|
label='Penjualan (Ekor)'
|
||||||
|
value={formatNumber(sales.chicken.sales_population)}
|
||||||
|
unit='Ekor'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Penjualan (Kg)'
|
||||||
|
value={formatNumber(sales.chicken.sales_weight)}
|
||||||
|
unit='Kg'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Bobot Rata-Rata'
|
||||||
|
value={formatNumber(sales.chicken.avg_weight)}
|
||||||
|
unit='Kg/Ekor'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Harga Jual Rata-Rata'
|
||||||
|
value={formatNumber(sales.chicken.avg_selling_price)}
|
||||||
|
unit='Rupiah'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Egg Sales (if available) */}
|
||||||
|
{sales.egg && (
|
||||||
|
<>
|
||||||
|
<div className='h-px bg-gray-100 my-2' />
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<DataRow
|
||||||
|
label='Telur (Butir)'
|
||||||
|
value={formatNumber(sales.egg.egg_pieces)}
|
||||||
|
unit='Butir'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Telur (Kg)'
|
||||||
|
value={formatNumber(sales.egg.egg_mass)}
|
||||||
|
unit='Kg'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Berat Telur Rata-Rata'
|
||||||
|
value={formatNumber(sales.egg.avg_egg_weight)}
|
||||||
|
unit='Kg'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Harga Jual Telur Rata-Rata'
|
||||||
|
value={formatNumber(sales.egg.avg_selling_price)}
|
||||||
|
unit='Rupiah'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider Line (Absolute centered) */}
|
||||||
|
<div className='hidden lg:block absolute left-1/2 top-0 bottom-0 w-px bg-gray-200 -translate-x-1/2' />
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className='space-y-10 flex flex-col h-full'>
|
||||||
|
{/* Performance Section */}
|
||||||
|
<section>
|
||||||
|
<h3 className='font-bold text-gray-700 mb-4 text-base'>
|
||||||
|
Performance
|
||||||
|
</h3>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<DataRow
|
||||||
|
label='Deplesi'
|
||||||
|
value={formatNumber(performance.depletion)}
|
||||||
|
unit='Ekor'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Umur'
|
||||||
|
value={formatNumber(performance.age_day)}
|
||||||
|
unit='Hari'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Mortalitas Std'
|
||||||
|
value={formatNumber(performance.mor_std)}
|
||||||
|
unitClassName='hidden'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Mortalitas Act'
|
||||||
|
value={formatNumber(performance.mor_act)}
|
||||||
|
unitClassName='hidden'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='DEFF Mortalitas'
|
||||||
|
value={formatNumber(performance.mor_diff)}
|
||||||
|
unitClassName='hidden'
|
||||||
|
/>
|
||||||
|
{/* <DataRow
|
||||||
|
label='AWG Std'
|
||||||
|
value={formatNumber(performance.awg_std)}
|
||||||
|
unit='Gr/Hari'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='AWG Act'
|
||||||
|
value={formatNumber(performance.awg_act)}
|
||||||
|
unit='Gr/Hari'
|
||||||
|
/> */}
|
||||||
|
<DataRow
|
||||||
|
label='Feed Intake Std'
|
||||||
|
value={formatNumber(performance.feed_intake_std)}
|
||||||
|
unitClassName='hidden'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Feed Intake Act'
|
||||||
|
value={formatNumber(performance.feed_intake)}
|
||||||
|
unitClassName='hidden'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='FCR Std'
|
||||||
|
value={formatNumber(performance.fcr_std)}
|
||||||
|
unitClassName='hidden'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='FCR Act'
|
||||||
|
value={formatNumber(performance.fcr_act)}
|
||||||
|
unitClassName='hidden'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='DEFF FCR'
|
||||||
|
value={formatNumber(performance.fcr_diff)}
|
||||||
|
unitClassName='hidden'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Laying Specific Fields */}
|
||||||
|
{performance.hen_day_act !== undefined && (
|
||||||
|
<>
|
||||||
|
<DataRow
|
||||||
|
label='Hen Day Std'
|
||||||
|
value={formatNumber(performance.hen_day_std!)}
|
||||||
|
unit='%'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Hen Day Act'
|
||||||
|
value={formatNumber(performance.hen_day_act)}
|
||||||
|
unit='%'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{performance.egg_mass !== undefined && (
|
||||||
|
<>
|
||||||
|
<DataRow
|
||||||
|
label='Egg Mass Std'
|
||||||
|
value={formatNumber(performance.egg_mass_std!)}
|
||||||
|
unit='Kg'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Egg Mass Act'
|
||||||
|
value={formatNumber(performance.egg_mass)}
|
||||||
|
unit='Kg'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{performance.egg_weight !== undefined && (
|
||||||
|
<>
|
||||||
|
<DataRow
|
||||||
|
label='Egg Weight Std'
|
||||||
|
value={formatNumber(performance.egg_weight_std!)}
|
||||||
|
unit='Gr'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Egg Weight Act'
|
||||||
|
value={formatNumber(performance.egg_weight)}
|
||||||
|
unit='Gr'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{performance.hen_housed_act !== undefined && (
|
||||||
|
<>
|
||||||
|
<DataRow
|
||||||
|
label='Hen Housed Std'
|
||||||
|
value={formatNumber(performance.hen_housed_std!)}
|
||||||
|
unit='%'
|
||||||
|
/>
|
||||||
|
<DataRow
|
||||||
|
label='Hen Housed Act'
|
||||||
|
value={formatNumber(performance.hen_housed_act)}
|
||||||
|
unit='%'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionDataClosingTab;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import SalesClosingTable from '@/components/pages/closing/table/SalesClosingTable';
|
||||||
|
|
||||||
|
interface SalesClosingTabProps {
|
||||||
|
projectFlockId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SalesClosingTab = ({ projectFlockId }: SalesClosingTabProps) => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
{projectFlockId && <SalesClosingTable projectFlockId={projectFlockId} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SalesClosingTab;
|
||||||
+6
-6
@@ -1,22 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable';
|
import SapronakCalculationClosingTable from '@/components/pages/closing/table/SapronakCalculationClosingTable';
|
||||||
import { ClosingGeneralInformation } from '@/types/api/closing';
|
import { ClosingGeneralInformation } from '@/types/api/closing';
|
||||||
|
|
||||||
interface ClosingSapronakCalculationTabContentProps {
|
interface SapronakCalculationClosingTabProps {
|
||||||
projectFlockId?: number;
|
projectFlockId?: number;
|
||||||
closingGeneralInformation?: ClosingGeneralInformation;
|
closingGeneralInformation?: ClosingGeneralInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClosingSapronakCalculationTabContent = ({
|
const SapronakCalculationClosingTab = ({
|
||||||
projectFlockId,
|
projectFlockId,
|
||||||
closingGeneralInformation,
|
closingGeneralInformation,
|
||||||
}: ClosingSapronakCalculationTabContentProps) => {
|
}: SapronakCalculationClosingTabProps) => {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
{projectFlockId && (
|
{projectFlockId && (
|
||||||
<>
|
<>
|
||||||
<ClosingSapronakCalculationTable
|
<SapronakCalculationClosingTable
|
||||||
closingGeneralInformation={closingGeneralInformation}
|
closingGeneralInformation={closingGeneralInformation}
|
||||||
projectFlockId={projectFlockId}
|
projectFlockId={projectFlockId}
|
||||||
/>
|
/>
|
||||||
@@ -26,4 +26,4 @@ const ClosingSapronakCalculationTabContent = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ClosingSapronakCalculationTabContent;
|
export default SapronakCalculationClosingTab;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import IncomingSapronaksTable from '@/components/pages/closing/table/sapronak/IncomingSapronaksTable';
|
||||||
|
import OutgoingSapronaksTable from '@/components/pages/closing/table/sapronak/OutgoingSapronaksTable';
|
||||||
|
import IncomingSapronaksSummaryTable from '@/components/pages/closing/table/sapronak/IncomingSapronaksSummaryTable';
|
||||||
|
import OutgoingSapronaksSummaryTable from '@/components/pages/closing/table/sapronak/OutgoingSapronaksSummaryTable';
|
||||||
|
|
||||||
|
interface SapronakClosingTabProps {
|
||||||
|
projectFlockId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SapronakClosingTab = ({ projectFlockId }: SapronakClosingTabProps) => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
{projectFlockId && (
|
||||||
|
<>
|
||||||
|
<IncomingSapronaksTable projectFlockId={projectFlockId} />
|
||||||
|
|
||||||
|
<IncomingSapronaksSummaryTable projectFlockId={projectFlockId} />
|
||||||
|
|
||||||
|
<OutgoingSapronaksTable projectFlockId={projectFlockId} />
|
||||||
|
|
||||||
|
<OutgoingSapronaksSummaryTable projectFlockId={projectFlockId} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SapronakClosingTab;
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import Alert from '@/components/Alert';
|
||||||
|
import Card from '@/components/Card';
|
||||||
|
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { formatCurrency, formatTitleCase } from '@/lib/helper';
|
||||||
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
|
import { HppItem, ProfitLossItem } from '@/types/api/closing';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import FinanceClosingSkeleton from '@/components/pages/closing/skeleton/FinanceClosingSkeleton';
|
||||||
|
|
||||||
|
const FinanceClosingTable = ({
|
||||||
|
projectFlockId,
|
||||||
|
}: {
|
||||||
|
projectFlockId: number;
|
||||||
|
}) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const kandangId = searchParams.get('kandangId');
|
||||||
|
|
||||||
|
const { data: finance, isLoading } = useSWR(
|
||||||
|
`/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
|
||||||
|
() =>
|
||||||
|
ClosingApi.getFinance(
|
||||||
|
projectFlockId,
|
||||||
|
kandangId ? Number(kandangId) : undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hppTableData: HppItem[] = useMemo(() => {
|
||||||
|
if (isResponseSuccess(finance)) {
|
||||||
|
const customItems = {
|
||||||
|
label: 'HPP dan Pengeluaran',
|
||||||
|
code: 'custom_row',
|
||||||
|
} as HppItem;
|
||||||
|
const purchases = finance.data.hpp.items.filter(
|
||||||
|
(item) => item.category === 'purchase'
|
||||||
|
);
|
||||||
|
const totalBudgeting = {
|
||||||
|
label: 'HPP dan Bahan Baku',
|
||||||
|
code: 'custom_row',
|
||||||
|
} as HppItem;
|
||||||
|
const overheads = finance.data.hpp.items.filter(
|
||||||
|
(item) => item.category === 'overhead'
|
||||||
|
);
|
||||||
|
return [customItems, ...purchases, totalBudgeting, ...overheads];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [finance]);
|
||||||
|
|
||||||
|
const profitLossTableData: ProfitLossItem[] = useMemo(() => {
|
||||||
|
if (isResponseSuccess(finance)) {
|
||||||
|
const incomes = finance.data.profit_loss.items.filter(
|
||||||
|
(item) => item.type === 'income'
|
||||||
|
);
|
||||||
|
const purchases = finance.data.profit_loss.items.filter(
|
||||||
|
(item) => item.type === 'purchase'
|
||||||
|
);
|
||||||
|
const overheads = finance.data.profit_loss.items.filter(
|
||||||
|
(item) => item.type === 'overhead'
|
||||||
|
);
|
||||||
|
const grossProfit = {
|
||||||
|
label: 'LABA RUGI BRUTO',
|
||||||
|
code: 'custom_row',
|
||||||
|
type: 'gross_profit',
|
||||||
|
rp_per_bird:
|
||||||
|
finance.data.profit_loss.summary.gross_profit.rp_per_bird ?? 0,
|
||||||
|
rp_per_kg: finance.data.profit_loss.summary.gross_profit.rp_per_kg ?? 0,
|
||||||
|
amount: finance.data.profit_loss.summary.gross_profit.amount ?? 0,
|
||||||
|
} as ProfitLossItem;
|
||||||
|
const subtotal = {
|
||||||
|
label: 'Subtotal',
|
||||||
|
code: 'custom_row',
|
||||||
|
type: 'subtotal',
|
||||||
|
rp_per_bird:
|
||||||
|
finance.data.profit_loss.summary.sub_total.rp_per_bird ?? 0,
|
||||||
|
rp_per_kg: finance.data.profit_loss.summary.sub_total.rp_per_kg ?? 0,
|
||||||
|
amount: finance.data.profit_loss.summary.sub_total.amount ?? 0,
|
||||||
|
} as ProfitLossItem;
|
||||||
|
return [...incomes, ...purchases, grossProfit, ...overheads, subtotal];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [finance]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4 pt-3'>
|
||||||
|
{isLoading ? (
|
||||||
|
<FinanceClosingSkeleton />
|
||||||
|
) : !isResponseSuccess(finance) ? (
|
||||||
|
<FinanceClosingSkeleton
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
title='Data Keuangan Tidak Ditemukan'
|
||||||
|
subtitle='Tidak ada data keuangan untuk periode ini.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className='grid grid-cols-1 md:grid-cols-2 gap-3'>
|
||||||
|
<Card
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-xl border border-base-content/10',
|
||||||
|
body: 'p-0',
|
||||||
|
wrapperContent:
|
||||||
|
'h-full flex flex-col items-between justify-between',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
|
>
|
||||||
|
<div className='flex flex-row items-center gap-4 px-4 py-4'>
|
||||||
|
<Alert
|
||||||
|
variant='soft'
|
||||||
|
color='success'
|
||||||
|
className='rounded-lg p-3 bg-success/12 flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:chart-bar-square'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<h3 className='text-base-content/50 font-semibold text-sm'>
|
||||||
|
Laba Rugi Brutto
|
||||||
|
</h3>
|
||||||
|
<p className='text-xl font-semibold'>
|
||||||
|
{isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.profit_loss.summary.gross_profit.amount
|
||||||
|
)
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-xl border border-base-content/10',
|
||||||
|
body: 'p-0',
|
||||||
|
wrapperContent:
|
||||||
|
'h-full flex flex-col items-between justify-between',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
|
>
|
||||||
|
<div className='flex flex-row items-center gap-4 px-4 py-4'>
|
||||||
|
<Alert
|
||||||
|
variant='soft'
|
||||||
|
color='info'
|
||||||
|
className='rounded-lg p-3 bg-info/12 flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:currency-dollar'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<h3 className='text-base-content/50 font-semibold text-sm'>
|
||||||
|
Laba Rugi Netto
|
||||||
|
</h3>
|
||||||
|
<p className='text-xl font-semibold'>
|
||||||
|
{isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.profit_loss.summary.net_profit.amount
|
||||||
|
)
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
<Card
|
||||||
|
title='HPP Purchases'
|
||||||
|
variant='bordered'
|
||||||
|
collapsible
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='p-0'>
|
||||||
|
<Table<HppItem>
|
||||||
|
data={hppTableData}
|
||||||
|
isLoading={isLoading}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'No.',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item, index) => {
|
||||||
|
if (item.code === 'custom_row') return '-';
|
||||||
|
const dataRowsBefore = hppTableData
|
||||||
|
.slice(0, index)
|
||||||
|
.filter((row) => row.code !== 'custom_row').length;
|
||||||
|
return dataRowsBefore + 1;
|
||||||
|
},
|
||||||
|
footer: () => {
|
||||||
|
return 'HPP';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Jenis',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) => formatTitleCase(item.label || '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Budgeting',
|
||||||
|
enableSorting: false,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: 'Rp/Ekor',
|
||||||
|
id: 'budgeting_rp_per_bird',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) =>
|
||||||
|
formatCurrency(item.budgeting?.rp_per_bird || 0),
|
||||||
|
footer: (props) => {
|
||||||
|
return props.column.id === 'budgeting_rp_per_bird' &&
|
||||||
|
isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.hpp.summary?.budgeting
|
||||||
|
?.rp_per_bird || 0
|
||||||
|
)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Rp/Kg',
|
||||||
|
id: 'budgeting_rp_per_kg',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) =>
|
||||||
|
formatCurrency(item.budgeting?.rp_per_kg || 0),
|
||||||
|
footer: (props) => {
|
||||||
|
return props.column.id === 'budgeting_rp_per_kg' &&
|
||||||
|
isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.hpp.summary?.budgeting
|
||||||
|
?.rp_per_kg || 0
|
||||||
|
)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Jumlah (Rp)',
|
||||||
|
id: 'budgeting_amount',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) =>
|
||||||
|
formatCurrency(item.budgeting?.amount || 0),
|
||||||
|
footer: (props) => {
|
||||||
|
return props.column.id === 'budgeting_amount' &&
|
||||||
|
isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.hpp.summary?.budgeting?.amount || 0
|
||||||
|
)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Realization',
|
||||||
|
enableSorting: false,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: 'Rp/Ekor',
|
||||||
|
id: 'realization_rp_per_bird',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) =>
|
||||||
|
formatCurrency(item.realization?.rp_per_bird || 0),
|
||||||
|
footer: (props) => {
|
||||||
|
return props.column.id ===
|
||||||
|
'realization_rp_per_bird' &&
|
||||||
|
isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.hpp.summary?.realization
|
||||||
|
?.rp_per_bird || 0
|
||||||
|
)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Rp/Kg',
|
||||||
|
id: 'realization_rp_per_kg',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) =>
|
||||||
|
formatCurrency(item.realization?.rp_per_kg || 0),
|
||||||
|
footer: (props) => {
|
||||||
|
return props.column.id === 'realization_rp_per_kg' &&
|
||||||
|
isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.hpp.summary?.realization
|
||||||
|
?.rp_per_kg || 0
|
||||||
|
)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Jumlah (Rp)',
|
||||||
|
id: 'realization_amount',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) =>
|
||||||
|
formatCurrency(item.realization?.amount || 0),
|
||||||
|
footer: (props) => {
|
||||||
|
return props.column.id === 'realization_amount' &&
|
||||||
|
isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.hpp.summary?.realization?.amount ||
|
||||||
|
0
|
||||||
|
)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName:
|
||||||
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
renderCustomRow={(row) => {
|
||||||
|
const rowData = row.original;
|
||||||
|
if (rowData.code === 'custom_row') {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||||
|
></td>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||||
|
>
|
||||||
|
<div className='font-bold'>
|
||||||
|
{formatTitleCase(rowData.label ?? '-')}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
renderFooter={isResponseSuccess(finance)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
title='Profit/Loss'
|
||||||
|
variant='bordered'
|
||||||
|
collapsible
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='p-0'>
|
||||||
|
<Table<ProfitLossItem>
|
||||||
|
data={profitLossTableData}
|
||||||
|
isLoading={isLoading}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Jenis',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) => item.label,
|
||||||
|
cell: (item) => (
|
||||||
|
<div className=''>
|
||||||
|
{formatTitleCase(item.row.original.label || '-')}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
footer: () => (
|
||||||
|
<div className='font-bold uppercase'>LABA RUGI NETTO</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Rp/Ekor',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
|
||||||
|
footer: () => (
|
||||||
|
<div className='font-bold'>
|
||||||
|
{isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.profit_loss.summary.net_profit
|
||||||
|
.rp_per_bird || 0
|
||||||
|
)
|
||||||
|
: formatCurrency(0)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Rp/Kg',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
|
||||||
|
footer: () => (
|
||||||
|
<div className='font-bold'>
|
||||||
|
{isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.profit_loss.summary.net_profit
|
||||||
|
.rp_per_kg || 0
|
||||||
|
)
|
||||||
|
: formatCurrency(0)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Jumlah (Rp)',
|
||||||
|
enableSorting: false,
|
||||||
|
accessorFn: (item) => formatCurrency(item.amount || 0),
|
||||||
|
footer: () => (
|
||||||
|
<div className='font-bold'>
|
||||||
|
{isResponseSuccess(finance)
|
||||||
|
? formatCurrency(
|
||||||
|
finance.data.profit_loss.summary.net_profit
|
||||||
|
.amount || 0
|
||||||
|
)
|
||||||
|
: formatCurrency(0)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName:
|
||||||
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
renderCustomRow={(row) => {
|
||||||
|
const rowData = row.original;
|
||||||
|
if (rowData.code === 'custom_row') {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={TABLE_DEFAULT_STYLING.footerRowClassName}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||||
|
>
|
||||||
|
<div className='font-bold ps-6 uppercase'>
|
||||||
|
{formatTitleCase(rowData.label ?? '-')}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||||
|
>
|
||||||
|
<div className='font-bold'>
|
||||||
|
{formatCurrency(rowData.rp_per_bird ?? 0)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||||
|
>
|
||||||
|
<div className='font-bold'>
|
||||||
|
{formatCurrency(rowData.rp_per_kg ?? 0)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||||
|
>
|
||||||
|
<div className='font-bold'>
|
||||||
|
{formatCurrency(rowData.amount ?? 0)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
renderFooter={isResponseSuccess(finance)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinanceClosingTable;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
'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 { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { BaseExpeditionCost } from '@/types/api/closing';
|
||||||
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import HppExpeditionClosingSkeleton from '@/components/pages/closing/skeleton/HppExpeditionClosingSkeleton';
|
||||||
|
|
||||||
|
interface HppExpeditionClosingTableProps {
|
||||||
|
projectFlockId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HppExpeditionClosingTable = ({
|
||||||
|
projectFlockId,
|
||||||
|
}: HppExpeditionClosingTableProps) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const kandangId = searchParams.get('kandangId');
|
||||||
|
|
||||||
|
const { data: hppExpedition, isLoading } = useSWR(
|
||||||
|
kandangId
|
||||||
|
? `/closing/hpp-expedition/${projectFlockId}/${kandangId}`
|
||||||
|
: `/closing/hpp-expedition/${projectFlockId}`,
|
||||||
|
() =>
|
||||||
|
kandangId
|
||||||
|
? ClosingApi.getHppEkspedisiByKandang(projectFlockId, Number(kandangId))
|
||||||
|
: ClosingApi.getHppEkspedisi(projectFlockId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const costOfRevenueExpeditionData: BaseExpeditionCost[] = useMemo(() => {
|
||||||
|
if (isResponseSuccess(hppExpedition)) {
|
||||||
|
return hppExpedition.data.expedition_costs || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [hppExpedition]);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
if (isResponseSuccess(hppExpedition)) {
|
||||||
|
return {
|
||||||
|
totalHpp: hppExpedition.data.total_hpp_amount || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
totalHpp: 0,
|
||||||
|
};
|
||||||
|
}, [hppExpedition]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className='w-full pt-3'>
|
||||||
|
<Card
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
|
title='HPP Ekspedisi'
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={false}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<HppExpeditionClosingSkeleton
|
||||||
|
columns={costOfRevenueExpeditionColumns}
|
||||||
|
/>
|
||||||
|
) : costOfRevenueExpeditionData.length === 0 ? (
|
||||||
|
<HppExpeditionClosingSkeleton
|
||||||
|
columns={costOfRevenueExpeditionColumns}
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
data={costOfRevenueExpeditionData}
|
||||||
|
columns={costOfRevenueExpeditionColumns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
renderFooter={costOfRevenueExpeditionData.length > 0}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName:
|
||||||
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HppExpeditionClosingTable;
|
||||||
+79
-36
@@ -14,18 +14,19 @@ import { ColumnDef } from '@tanstack/react-table';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import OverheadClosingSkeleton from '@/components/pages/closing/skeleton/OverheadClosingSkeleton';
|
||||||
|
|
||||||
interface ClosingOverheadTableProps {
|
interface OverheadClosingTableProps {
|
||||||
projectFlockId: number;
|
projectFlockId: number;
|
||||||
generalInformation?: ClosingGeneralInformation;
|
generalInformation?: ClosingGeneralInformation;
|
||||||
kandangData?: ProjectFlockKandang;
|
kandangData?: ProjectFlockKandang;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClosingOverheadTable = ({
|
const OverheadClosingTable = ({
|
||||||
projectFlockId,
|
projectFlockId,
|
||||||
generalInformation,
|
generalInformation,
|
||||||
kandangData,
|
kandangData,
|
||||||
}: ClosingOverheadTableProps) => {
|
}: OverheadClosingTableProps) => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const kandangId = searchParams.get('kandangId');
|
const kandangId = searchParams.get('kandangId');
|
||||||
|
|
||||||
@@ -208,42 +209,84 @@ const ClosingOverheadTable = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='w-full pt-3'>
|
||||||
<Card
|
<Card
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
title='Pengeluaran Overhead'
|
title='Pengeluaran Overhead'
|
||||||
collapsible
|
collapsible
|
||||||
defaultCollapsed={false}
|
defaultCollapsed={false}
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
body: 'p-4 shadow',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Table<Overhead>
|
{isLoadingOverhead ? (
|
||||||
data={
|
<OverheadClosingSkeleton columns={columns} />
|
||||||
kandangId
|
) : !isResponseSuccess(overhead) ? (
|
||||||
? isResponseSuccess(overheadKandang)
|
<OverheadClosingSkeleton
|
||||||
? (overheadKandang.data?.overheads ?? [])
|
columns={columns}
|
||||||
: []
|
iconName='heroicons:chart-bar'
|
||||||
: isResponseSuccess(overhead)
|
title='Data Overhead Tidak Ditemukan'
|
||||||
? (overhead.data?.overheads ?? [])
|
subtitle='Tidak ada data overhead untuk periode ini.'
|
||||||
: []
|
/>
|
||||||
}
|
) : kandangId && !isResponseSuccess(overheadKandang) ? (
|
||||||
columns={columns}
|
<OverheadClosingSkeleton
|
||||||
className={{
|
columns={columns}
|
||||||
containerClassName: 'my-4',
|
iconName='heroicons:chart-bar'
|
||||||
headerColumnClassName: cn(
|
title='Data Overhead Tidak Ditemukan'
|
||||||
TABLE_DEFAULT_STYLING.headerColumnClassName,
|
subtitle='Tidak ada data overhead untuk periode ini.'
|
||||||
'whitespace-nowrap'
|
/>
|
||||||
),
|
) : (!kandangId && overhead.data?.overheads.length === 0) ||
|
||||||
}}
|
(kandangId &&
|
||||||
isLoading={isLoadingOverhead}
|
isResponseSuccess(overheadKandang) &&
|
||||||
renderFooter={
|
overheadKandang.data?.overheads.length === 0) ? (
|
||||||
isResponseSuccess(overhead)
|
<OverheadClosingSkeleton
|
||||||
? overhead.data?.overheads.length > 0
|
columns={columns}
|
||||||
: false
|
iconName='heroicons:chart-bar'
|
||||||
}
|
/>
|
||||||
/>
|
) : (
|
||||||
{kandangId && (
|
<Table<Overhead>
|
||||||
|
data={
|
||||||
|
kandangId
|
||||||
|
? isResponseSuccess(overheadKandang)
|
||||||
|
? (overheadKandang.data?.overheads ?? [])
|
||||||
|
: []
|
||||||
|
: isResponseSuccess(overhead)
|
||||||
|
? (overhead.data?.overheads ?? [])
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
columns={columns}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName:
|
||||||
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
|
tableClassName: 'w-full table-auto text-sm',
|
||||||
|
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
|
||||||
|
headerColumnClassName: cn(
|
||||||
|
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
|
||||||
|
'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',
|
||||||
|
}}
|
||||||
|
isLoading={isLoadingOverhead}
|
||||||
|
renderFooter={
|
||||||
|
isResponseSuccess(overhead)
|
||||||
|
? overhead.data?.overheads.length > 0
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{kandangId && !isLoadingOverhead && isResponseSuccess(overhead) && (
|
||||||
<Card
|
<Card
|
||||||
className={{
|
className={{
|
||||||
wrapper: 'w-full',
|
wrapper: 'w-full',
|
||||||
@@ -298,8 +341,8 @@ const ClosingOverheadTable = ({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ClosingOverheadTable;
|
export default OverheadClosingTable;
|
||||||
+79
-46
@@ -5,28 +5,47 @@ import { ColumnDef } from '@tanstack/react-table';
|
|||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
|
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
|
||||||
import {
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
BaseClosingSales,
|
import { BaseSales, ClosingSalesSummary } from '@/types/api/closing';
|
||||||
BaseSales,
|
|
||||||
ClosingSalesSummary,
|
|
||||||
} from '@/types/api/closing';
|
|
||||||
import { Product } from '@/types/api/master-data/product';
|
import { Product } from '@/types/api/master-data/product';
|
||||||
import { Customer } from '@/types/api/master-data/customer';
|
import { Customer } from '@/types/api/master-data/customer';
|
||||||
import { Kandang } from '@/types/api/master-data/kandang';
|
import { Kandang } from '@/types/api/master-data/kandang';
|
||||||
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import SalesClosingSkeleton from '@/components/pages/closing/skeleton/SalesClosingSkeleton';
|
||||||
|
|
||||||
interface SalesReportTableProps {
|
interface SalesClosingTableProps {
|
||||||
type?: 'detail';
|
projectFlockId: number;
|
||||||
initialValues?: BaseClosingSales;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SalesReportTable = ({ initialValues }: SalesReportTableProps) => {
|
const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const kandangId = searchParams.get('kandangId');
|
||||||
|
|
||||||
|
const { data: sales, isLoading } = useSWR(
|
||||||
|
kandangId
|
||||||
|
? `/closing/sales/${projectFlockId}/${kandangId}`
|
||||||
|
: `/closing/sales/${projectFlockId}`,
|
||||||
|
() =>
|
||||||
|
kandangId
|
||||||
|
? ClosingApi.getPenjualanByKandang(projectFlockId, Number(kandangId))
|
||||||
|
: ClosingApi.getPenjualan(projectFlockId)
|
||||||
|
);
|
||||||
|
|
||||||
const salesData: BaseSales[] = useMemo(() => {
|
const salesData: BaseSales[] = useMemo(() => {
|
||||||
return initialValues?.sales || [];
|
if (isResponseSuccess(sales)) {
|
||||||
}, [initialValues]);
|
return sales.data.sales || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [sales]);
|
||||||
|
|
||||||
const summary: ClosingSalesSummary | undefined = useMemo(() => {
|
const summary: ClosingSalesSummary | undefined = useMemo(() => {
|
||||||
return initialValues?.summary;
|
if (isResponseSuccess(sales)) {
|
||||||
}, [initialValues]);
|
return sales.data.summary;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [sales]);
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
if (salesData.length === 0) {
|
if (salesData.length === 0) {
|
||||||
@@ -293,41 +312,55 @@ const SalesReportTable = ({ initialValues }: SalesReportTableProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='w-full pt-3'>
|
||||||
<section className='w-full'>
|
<Card
|
||||||
<div className='p-4'>
|
className={{
|
||||||
<h2 className='text-xl font-semibold mb-4'>Penjualan</h2>
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
<Card
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
|
title='Penjualan'
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={false}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<SalesClosingSkeleton columns={salesColumns} />
|
||||||
|
) : salesData.length === 0 ? (
|
||||||
|
<SalesClosingSkeleton
|
||||||
|
columns={salesColumns}
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
data={salesData}
|
||||||
|
columns={salesColumns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
renderFooter={salesData.length > 0}
|
||||||
className={{
|
className={{
|
||||||
wrapper: 'w-full bg-base-100',
|
containerClassName: 'w-full mb-0!',
|
||||||
body: 'p-0',
|
tableWrapperClassName:
|
||||||
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
|
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',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Table
|
)}
|
||||||
data={salesData}
|
</Card>
|
||||||
columns={salesColumns}
|
</div>
|
||||||
renderFooter={salesData.length > 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 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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SalesReportTable;
|
export default SalesClosingTable;
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Card from '@/components/Card';
|
||||||
|
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||||
|
import {
|
||||||
|
RowSapronakCalculation,
|
||||||
|
TotalSapronakCalculation,
|
||||||
|
} from '@/types/api/closing';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { ClosingGeneralInformation } from '@/types/api/closing';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import SapronakCalculationClosingSkeleton from '@/components/pages/closing/skeleton/SapronakCalculationClosingSkeleton';
|
||||||
|
|
||||||
|
interface SapronakCalculationClosingTableProps {
|
||||||
|
projectFlockId: number;
|
||||||
|
closingGeneralInformation?: ClosingGeneralInformation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SapronakCalculationClosingTable = ({
|
||||||
|
projectFlockId,
|
||||||
|
closingGeneralInformation,
|
||||||
|
}: SapronakCalculationClosingTableProps) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const kandangId = searchParams.get('kandangId');
|
||||||
|
|
||||||
|
const { data: sapronakCalculation, isLoading } = useSWR(
|
||||||
|
`/closing/sapronak-calculation/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
|
||||||
|
() => ClosingApi.getPerhitunganSapronak(projectFlockId, Number(kandangId)),
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function to create columns with footer support
|
||||||
|
const createColumns = (
|
||||||
|
total?: TotalSapronakCalculation
|
||||||
|
): ColumnDef<RowSapronakCalculation>[] => [
|
||||||
|
{
|
||||||
|
header: 'Tanggal',
|
||||||
|
accessorKey: 'date',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.date
|
||||||
|
? formatDate(props.row.original.date, 'DD MMM YYYY')
|
||||||
|
: '-',
|
||||||
|
footer: 'Total',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No. Referensi',
|
||||||
|
accessorKey: 'reference_number',
|
||||||
|
cell: (props) => (props.row.original.reference_number as string) || '-',
|
||||||
|
footer: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'QTY Masuk',
|
||||||
|
accessorKey: 'qty_in',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.qty_in
|
||||||
|
? formatNumber(props.row.original.qty_in as number)
|
||||||
|
: '0',
|
||||||
|
footer: total
|
||||||
|
? () => (
|
||||||
|
<div className='font-semibold text-gray-900'>
|
||||||
|
{total?.qty_in ? formatNumber(total?.qty_in) : '0'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'QTY Keluar',
|
||||||
|
accessorKey: 'qty_out',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.qty_out
|
||||||
|
? formatNumber(props.row.original.qty_out as number)
|
||||||
|
: '0',
|
||||||
|
footer: total
|
||||||
|
? () => (
|
||||||
|
<div className='font-semibold text-gray-900'>
|
||||||
|
{total?.qty_out ? formatNumber(total?.qty_out) : '0'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'QTY Pakai',
|
||||||
|
accessorKey: 'qty_used',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.qty_used
|
||||||
|
? formatNumber(props.row.original.qty_used as number)
|
||||||
|
: '0',
|
||||||
|
footer: total
|
||||||
|
? () => (
|
||||||
|
<div className='font-semibold text-gray-900'>
|
||||||
|
{total?.qty_used ? formatNumber(total?.qty_used) : '0'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Uraian',
|
||||||
|
accessorKey: 'description',
|
||||||
|
cell: (props) => (props.row.original.description as string) || '-',
|
||||||
|
footer: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Kategori Produk',
|
||||||
|
accessorKey: 'product_category',
|
||||||
|
cell: (props) => (props.row.original.product_category as string) || '-',
|
||||||
|
footer: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Harga Beli/Qty (Rp)',
|
||||||
|
accessorKey: 'unit_price',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.unit_price
|
||||||
|
? formatCurrency(props.row.original.unit_price as number)
|
||||||
|
: '-',
|
||||||
|
footer: total
|
||||||
|
? () => (
|
||||||
|
<div className='font-semibold text-gray-900'>
|
||||||
|
{total?.avg_unit_price
|
||||||
|
? formatCurrency(total?.avg_unit_price)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Total Harga (Rp)',
|
||||||
|
accessorKey: 'total_amount',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.total_amount
|
||||||
|
? formatCurrency(props.row.original.total_amount as number)
|
||||||
|
: '-',
|
||||||
|
footer: total
|
||||||
|
? () => (
|
||||||
|
<div className='font-semibold text-gray-900'>
|
||||||
|
{total?.total_amount ? formatCurrency(total?.total_amount) : '-'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Keterangan',
|
||||||
|
accessorKey: 'notes',
|
||||||
|
cell: (props) => (props.row.original.notes as string) || '-',
|
||||||
|
footer: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Memoize columns untuk setiap kategori
|
||||||
|
const docColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(sapronakCalculation)
|
||||||
|
? createColumns(sapronakCalculation.data?.doc?.total)
|
||||||
|
: createColumns(),
|
||||||
|
[sapronakCalculation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ovkColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(sapronakCalculation)
|
||||||
|
? createColumns(sapronakCalculation.data?.ovk?.total)
|
||||||
|
: createColumns(),
|
||||||
|
[sapronakCalculation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pakanColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(sapronakCalculation)
|
||||||
|
? createColumns(sapronakCalculation.data?.pakan?.total)
|
||||||
|
: createColumns(),
|
||||||
|
[sapronakCalculation]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4 pt-3'>
|
||||||
|
{/* Table DOC jika kategori Project Flock Growing */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
closingGeneralInformation?.project_type == 'GROWING'
|
||||||
|
? 'DOC'
|
||||||
|
: 'Pullet'
|
||||||
|
}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={false}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<SapronakCalculationClosingSkeleton columns={docColumns} />
|
||||||
|
) : isResponseSuccess(sapronakCalculation) &&
|
||||||
|
sapronakCalculation.data?.doc?.rows?.length === 0 ? (
|
||||||
|
<SapronakCalculationClosingSkeleton
|
||||||
|
columns={docColumns}
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
title='Data Perhitungan Sapronak Tidak Ditemukan'
|
||||||
|
subtitle='Tidak ada data perhitungan sapronak untuk periode ini.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table<RowSapronakCalculation>
|
||||||
|
data={
|
||||||
|
isResponseSuccess(sapronakCalculation)
|
||||||
|
? (sapronakCalculation.data?.doc?.rows ?? [])
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
columns={docColumns}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName:
|
||||||
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
renderFooter={
|
||||||
|
isResponseSuccess(sapronakCalculation) &&
|
||||||
|
sapronakCalculation.data?.doc?.rows?.length > 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title='OVK'
|
||||||
|
variant='bordered'
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={true}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<SapronakCalculationClosingSkeleton columns={ovkColumns} />
|
||||||
|
) : isResponseSuccess(sapronakCalculation) &&
|
||||||
|
sapronakCalculation.data?.ovk?.rows?.length === 0 ? (
|
||||||
|
<SapronakCalculationClosingSkeleton
|
||||||
|
columns={ovkColumns}
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
title='Data Perhitungan Sapronak Tidak Ditemukan'
|
||||||
|
subtitle='Tidak ada data perhitungan sapronak untuk periode ini.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table<RowSapronakCalculation>
|
||||||
|
data={
|
||||||
|
isResponseSuccess(sapronakCalculation)
|
||||||
|
? (sapronakCalculation.data?.ovk?.rows ?? [])
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
columns={ovkColumns}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName:
|
||||||
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
renderFooter={
|
||||||
|
isResponseSuccess(sapronakCalculation) &&
|
||||||
|
sapronakCalculation.data?.ovk?.rows?.length > 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title='Pakan'
|
||||||
|
variant='bordered'
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={true}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<SapronakCalculationClosingSkeleton columns={pakanColumns} />
|
||||||
|
) : isResponseSuccess(sapronakCalculation) &&
|
||||||
|
sapronakCalculation.data?.pakan?.rows?.length === 0 ? (
|
||||||
|
<SapronakCalculationClosingSkeleton
|
||||||
|
columns={pakanColumns}
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
title='Data Perhitungan Sapronak Tidak Ditemukan'
|
||||||
|
subtitle='Tidak ada data perhitungan sapronak untuk periode ini.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table<RowSapronakCalculation>
|
||||||
|
data={
|
||||||
|
isResponseSuccess(sapronakCalculation)
|
||||||
|
? (sapronakCalculation.data?.pakan?.rows ?? [])
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
columns={pakanColumns}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName:
|
||||||
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
renderFooter={
|
||||||
|
isResponseSuccess(sapronakCalculation) &&
|
||||||
|
sapronakCalculation.data?.pakan?.rows?.length > 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SapronakCalculationClosingTable;
|
||||||
+86
-54
@@ -1,20 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Collapse from '@/components/Collapse';
|
import Badge from '@/components/Badge';
|
||||||
|
|
||||||
import { cn, formatNumber } from '@/lib/helper';
|
import { cn, formatNumber } from '@/lib/helper';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
import { ClosingApi } from '@/services/api/closing';
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
import { ClosingIncomingSapronakSummary } from '@/types/api/closing';
|
import { ClosingIncomingSapronakSummary } from '@/types/api/closing';
|
||||||
|
import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton';
|
||||||
|
|
||||||
interface ClosingIncomingSapronaksSummaryTableProps {
|
interface ClosingIncomingSapronaksSummaryTableProps {
|
||||||
projectFlockId: number;
|
projectFlockId: number;
|
||||||
@@ -55,20 +55,60 @@ const ClosingIncomingSapronaksSummaryTable = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronakSummary>[] =
|
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronakSummary>[] =
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
header: '#',
|
header: 'No',
|
||||||
cell: (props) => props.row.index + 1,
|
cell: (props) => props.row.index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'category',
|
accessorKey: 'category',
|
||||||
header: 'Kategori',
|
header: 'Kategori',
|
||||||
|
cell: (props) => {
|
||||||
|
const categories = props.row.original.category
|
||||||
|
.split(' ')
|
||||||
|
.filter((cat) => cat.trim());
|
||||||
|
const maxBadges = 4;
|
||||||
|
const visibleCategories = categories.slice(0, maxBadges);
|
||||||
|
const remainingCount = categories.length - maxBadges;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-wrap gap-1 whitespace-nowrap'>
|
||||||
|
{visibleCategories.map((category, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant='soft'
|
||||||
|
className={{
|
||||||
|
badge: cn(
|
||||||
|
'px-2 py-1 flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content bg-base-content/5 whitespace-nowrap'
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
title={category}
|
||||||
|
>
|
||||||
|
{category.length > 12
|
||||||
|
? `${category.slice(0, 12)}...`
|
||||||
|
: category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant='soft'
|
||||||
|
className={{
|
||||||
|
badge: cn(
|
||||||
|
'px-2 py-1 flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content bg-base-content/20'
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
title={categories.join(' ')}
|
||||||
|
>
|
||||||
|
+{remainingCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'total_qty',
|
accessorKey: 'total_qty',
|
||||||
@@ -78,10 +118,6 @@ const ClosingIncomingSapronaksSummaryTable = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
updateFilter('search', e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// track sorting
|
// track sorting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
@@ -93,44 +129,35 @@ const ClosingIncomingSapronaksSummaryTable = ({
|
|||||||
}
|
}
|
||||||
}, [sorting, updateFilter]);
|
}, [sorting, updateFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setOpen(
|
|
||||||
isResponseSuccess(incomingSapronakSummaries)
|
|
||||||
? incomingSapronakSummaries.data.length > 0
|
|
||||||
: false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [incomingSapronakSummaries, isResponseSuccess]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className='w-full'>
|
||||||
className={{
|
<Card
|
||||||
wrapper: 'w-full',
|
className={{
|
||||||
body: 'p-4 shadow',
|
wrapper: 'w-full rounded-lg',
|
||||||
}}
|
body: 'p-0',
|
||||||
>
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
<Collapse
|
collapsible: 'rounded-lg',
|
||||||
open={open}
|
}}
|
||||||
onOpenChange={setOpen}
|
variant='bordered'
|
||||||
title={
|
title='Ringkasan Sapronak Masuk'
|
||||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
collapsible
|
||||||
<div className='card-title'>Ringkasan Sapronak Masuk</div>
|
defaultCollapsed={false}
|
||||||
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:keyboard-arrow-down'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className={cn('text-primary transition-transform', {
|
|
||||||
'-rotate-180': open,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className='w-full!'
|
|
||||||
titleClassName='w-full p-0!'
|
|
||||||
>
|
>
|
||||||
<div className='w-full p-0'>
|
{isLoadingIncomingSapronakSummaries ? (
|
||||||
|
<SapronakClosingSkeleton
|
||||||
|
type='incoming'
|
||||||
|
columns={incomingSapronaksColumns}
|
||||||
|
/>
|
||||||
|
) : isResponseSuccess(incomingSapronakSummaries) &&
|
||||||
|
incomingSapronakSummaries.data.length === 0 ? (
|
||||||
|
<SapronakClosingSkeleton
|
||||||
|
type='incoming'
|
||||||
|
columns={incomingSapronaksColumns}
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
title='Ringkasan Sapronak Masuk Tidak Ditemukan'
|
||||||
|
subtitle='Tidak ada ringkasan sapronak masuk untuk periode ini.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Table<ClosingIncomingSapronakSummary>
|
<Table<ClosingIncomingSapronakSummary>
|
||||||
data={
|
data={
|
||||||
isResponseSuccess(incomingSapronakSummaries)
|
isResponseSuccess(incomingSapronakSummaries)
|
||||||
@@ -158,16 +185,21 @@ const ClosingIncomingSapronaksSummaryTable = ({
|
|||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
setRowSelection={setRowSelection}
|
setRowSelection={setRowSelection}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'w-full mb-5!',
|
||||||
'w-full mb-20':
|
tableWrapperClassName:
|
||||||
isResponseSuccess(incomingSapronakSummaries) &&
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
incomingSapronakSummaries?.data?.length === 0,
|
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-b border-gray-200',
|
||||||
|
bodyRowClassName: 'hover:bg-gray-50 transition-colors',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
</Collapse>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
+107
-58
@@ -9,13 +9,14 @@ import { Icon } from '@iconify/react';
|
|||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Collapse from '@/components/Collapse';
|
import Badge from '@/components/Badge';
|
||||||
|
|
||||||
import { cn, formatDate, formatNumber } from '@/lib/helper';
|
import { cn, formatDate, formatNumber } from '@/lib/helper';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
import { ClosingApi } from '@/services/api/closing';
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
import { ClosingIncomingSapronak } from '@/types/api/closing';
|
import { ClosingIncomingSapronak } from '@/types/api/closing';
|
||||||
|
import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton';
|
||||||
|
|
||||||
interface ClosingIncomingSapronaksTableProps {
|
interface ClosingIncomingSapronaksTableProps {
|
||||||
projectFlockId: number;
|
projectFlockId: number;
|
||||||
@@ -51,14 +52,12 @@ const ClosingIncomingSapronaksTable = ({
|
|||||||
ClosingApi.getAllIncomingSapronakFetcher
|
ClosingApi.getAllIncomingSapronakFetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronak>[] = [
|
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronak>[] = [
|
||||||
{
|
{
|
||||||
header: '#',
|
header: 'No',
|
||||||
cell: (props) => props.row.index + 1,
|
cell: (props) => props.row.index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,6 +80,48 @@ const ClosingIncomingSapronaksTable = ({
|
|||||||
{
|
{
|
||||||
accessorKey: 'product_category',
|
accessorKey: 'product_category',
|
||||||
header: 'Kategori Produk',
|
header: 'Kategori Produk',
|
||||||
|
cell: (props) => {
|
||||||
|
const categories = props.row.original.product_category
|
||||||
|
.split(' ')
|
||||||
|
.filter((cat) => cat.trim());
|
||||||
|
const maxBadges = 4;
|
||||||
|
const visibleCategories = categories.slice(0, maxBadges);
|
||||||
|
const remainingCount = categories.length - maxBadges;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-wrap gap-1 whitespace-nowrap'>
|
||||||
|
{visibleCategories.map((category, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant='soft'
|
||||||
|
className={{
|
||||||
|
badge: cn(
|
||||||
|
'px-2 py-1 flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content bg-base-content/5 whitespace-nowrap'
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
title={category}
|
||||||
|
>
|
||||||
|
{category.length > 12
|
||||||
|
? `${category.slice(0, 12)}...`
|
||||||
|
: category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant='soft'
|
||||||
|
className={{
|
||||||
|
badge: cn(
|
||||||
|
'px-2 py-1 flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content bg-base-content/20'
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
title={categories.join(' ')}
|
||||||
|
>
|
||||||
|
+{remainingCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'source_warehouse',
|
accessorKey: 'source_warehouse',
|
||||||
@@ -117,56 +158,59 @@ const ClosingIncomingSapronaksTable = ({
|
|||||||
}
|
}
|
||||||
}, [sorting, updateFilter]);
|
}, [sorting, updateFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setOpen(
|
|
||||||
isResponseSuccess(incomingSapronaks)
|
|
||||||
? incomingSapronaks.data.length > 0
|
|
||||||
: false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [incomingSapronaks, isResponseSuccess]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className='w-full pt-3'>
|
||||||
className={{
|
<Card
|
||||||
wrapper: 'w-full',
|
className={{
|
||||||
body: 'p-4 shadow',
|
wrapper: 'w-full rounded-lg',
|
||||||
}}
|
body: 'p-0',
|
||||||
>
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
<Collapse
|
collapsible: 'rounded-lg',
|
||||||
open={open}
|
}}
|
||||||
onOpenChange={setOpen}
|
variant='bordered'
|
||||||
title={
|
title='Sapronak Masuk'
|
||||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
collapsible
|
||||||
<div className='card-title'>Sapronak Masuk</div>
|
defaultCollapsed={false}
|
||||||
|
>
|
||||||
<Icon
|
<div className='flex flex-col gap-2 my-4'>
|
||||||
icon='material-symbols:keyboard-arrow-down'
|
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
||||||
width={24}
|
<DebouncedTextInput
|
||||||
height={24}
|
name='search'
|
||||||
className={cn('text-primary transition-transform', {
|
placeholder='Cari Sapronak Masuk'
|
||||||
'-rotate-180': open,
|
value={tableFilterState.search}
|
||||||
})}
|
onChange={searchChangeHandler}
|
||||||
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
|
input:
|
||||||
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
className='w-full!'
|
|
||||||
titleClassName='w-full p-0!'
|
|
||||||
>
|
|
||||||
<div className='w-full p-0'>
|
|
||||||
<div className='flex flex-col gap-2 mb-4'>
|
|
||||||
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
|
||||||
<DebouncedTextInput
|
|
||||||
name='search'
|
|
||||||
placeholder='Cari Sapronak Masuk'
|
|
||||||
value={tableFilterState.search}
|
|
||||||
onChange={searchChangeHandler}
|
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{isLoadingIncomingSapronaks ? (
|
||||||
|
<SapronakClosingSkeleton
|
||||||
|
type='incoming'
|
||||||
|
columns={incomingSapronaksColumns}
|
||||||
|
/>
|
||||||
|
) : isResponseSuccess(incomingSapronaks) &&
|
||||||
|
incomingSapronaks.data.length === 0 ? (
|
||||||
|
<SapronakClosingSkeleton
|
||||||
|
type='incoming'
|
||||||
|
columns={incomingSapronaksColumns}
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
title='Data Sapronak Masuk Tidak Ditemukan'
|
||||||
|
subtitle='Tidak ada data sapronak masuk untuk periode ini.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Table<ClosingIncomingSapronak>
|
<Table<ClosingIncomingSapronak>
|
||||||
data={
|
data={
|
||||||
isResponseSuccess(incomingSapronaks)
|
isResponseSuccess(incomingSapronaks)
|
||||||
@@ -194,16 +238,21 @@ const ClosingIncomingSapronaksTable = ({
|
|||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
setRowSelection={setRowSelection}
|
setRowSelection={setRowSelection}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'w-full mb-5!',
|
||||||
'w-full mb-20':
|
tableWrapperClassName:
|
||||||
isResponseSuccess(incomingSapronaks) &&
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
incomingSapronaks?.data?.length === 0,
|
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-b border-gray-200',
|
||||||
|
bodyRowClassName: 'hover:bg-gray-50 transition-colors',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
</Collapse>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
+86
-54
@@ -1,20 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Collapse from '@/components/Collapse';
|
import Badge from '@/components/Badge';
|
||||||
|
|
||||||
import { cn, formatNumber } from '@/lib/helper';
|
import { cn, formatNumber } from '@/lib/helper';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
import { ClosingApi } from '@/services/api/closing';
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
import { ClosingOutgoingSapronakSummary } from '@/types/api/closing';
|
import { ClosingOutgoingSapronakSummary } from '@/types/api/closing';
|
||||||
|
import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton';
|
||||||
|
|
||||||
interface ClosingOutgoingSapronaksSummaryTableProps {
|
interface ClosingOutgoingSapronaksSummaryTableProps {
|
||||||
projectFlockId: number;
|
projectFlockId: number;
|
||||||
@@ -55,20 +55,60 @@ const ClosingOutgoingSapronaksSummaryTable = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronakSummary>[] =
|
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronakSummary>[] =
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
header: '#',
|
header: 'No',
|
||||||
cell: (props) => props.row.index + 1,
|
cell: (props) => props.row.index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'category',
|
accessorKey: 'category',
|
||||||
header: 'Kategori',
|
header: 'Kategori',
|
||||||
|
cell: (props) => {
|
||||||
|
const categories = props.row.original.category
|
||||||
|
.split(' ')
|
||||||
|
.filter((cat) => cat.trim());
|
||||||
|
const maxBadges = 4;
|
||||||
|
const visibleCategories = categories.slice(0, maxBadges);
|
||||||
|
const remainingCount = categories.length - maxBadges;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-wrap gap-1 whitespace-nowrap'>
|
||||||
|
{visibleCategories.map((category, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant='soft'
|
||||||
|
className={{
|
||||||
|
badge: cn(
|
||||||
|
'px-2 py-1 flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content bg-base-content/5 whitespace-nowrap'
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
title={category}
|
||||||
|
>
|
||||||
|
{category.length > 12
|
||||||
|
? `${category.slice(0, 12)}...`
|
||||||
|
: category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant='soft'
|
||||||
|
className={{
|
||||||
|
badge: cn(
|
||||||
|
'px-2 py-1 flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content bg-base-content/20'
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
title={categories.join(' ')}
|
||||||
|
>
|
||||||
|
+{remainingCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'total_qty',
|
accessorKey: 'total_qty',
|
||||||
@@ -78,10 +118,6 @@ const ClosingOutgoingSapronaksSummaryTable = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
updateFilter('search', e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// track sorting
|
// track sorting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
@@ -93,44 +129,35 @@ const ClosingOutgoingSapronaksSummaryTable = ({
|
|||||||
}
|
}
|
||||||
}, [sorting, updateFilter]);
|
}, [sorting, updateFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setOpen(
|
|
||||||
isResponseSuccess(outgoingSapronakSummaries)
|
|
||||||
? outgoingSapronakSummaries.data.length > 0
|
|
||||||
: false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [outgoingSapronakSummaries, isResponseSuccess]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className='w-full'>
|
||||||
className={{
|
<Card
|
||||||
wrapper: 'w-full',
|
className={{
|
||||||
body: 'p-4 shadow',
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
}}
|
body: 'p-0',
|
||||||
>
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
<Collapse
|
collapsible: 'rounded-lg',
|
||||||
open={open}
|
}}
|
||||||
onOpenChange={setOpen}
|
variant='bordered'
|
||||||
title={
|
title='Ringkasan Sapronak Keluar'
|
||||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
collapsible
|
||||||
<div className='card-title'>Ringkasan Sapronak Keluar</div>
|
defaultCollapsed={false}
|
||||||
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:keyboard-arrow-down'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className={cn('text-primary transition-transform', {
|
|
||||||
'-rotate-180': open,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className='w-full!'
|
|
||||||
titleClassName='w-full p-0!'
|
|
||||||
>
|
>
|
||||||
<div className='w-full p-0'>
|
{isLoadingOutgoingSapronakSummaries ? (
|
||||||
|
<SapronakClosingSkeleton
|
||||||
|
type='outgoing'
|
||||||
|
columns={outgoingSapronaksColumns}
|
||||||
|
/>
|
||||||
|
) : isResponseSuccess(outgoingSapronakSummaries) &&
|
||||||
|
outgoingSapronakSummaries.data.length === 0 ? (
|
||||||
|
<SapronakClosingSkeleton
|
||||||
|
type='outgoing'
|
||||||
|
columns={outgoingSapronaksColumns}
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
title='Ringkasan Sapronak Keluar Tidak Ditemukan'
|
||||||
|
subtitle='Tidak ada ringkasan sapronak keluar untuk periode ini.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Table<ClosingOutgoingSapronakSummary>
|
<Table<ClosingOutgoingSapronakSummary>
|
||||||
data={
|
data={
|
||||||
isResponseSuccess(outgoingSapronakSummaries)
|
isResponseSuccess(outgoingSapronakSummaries)
|
||||||
@@ -158,16 +185,21 @@ const ClosingOutgoingSapronaksSummaryTable = ({
|
|||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
setRowSelection={setRowSelection}
|
setRowSelection={setRowSelection}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'w-full mb-5!',
|
||||||
'w-full mb-20':
|
tableWrapperClassName:
|
||||||
isResponseSuccess(outgoingSapronakSummaries) &&
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
outgoingSapronakSummaries?.data?.length === 0,
|
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-b border-gray-200',
|
||||||
|
bodyRowClassName: 'hover:bg-gray-50 transition-colors',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
</Collapse>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
+110
-59
@@ -9,13 +9,16 @@ import { Icon } from '@iconify/react';
|
|||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Collapse from '@/components/Collapse';
|
import Badge from '@/components/Badge';
|
||||||
|
|
||||||
import { cn, formatDate, formatNumber } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
import { formatDate, formatNumber } from '@/lib/helper';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
import { ClosingApi } from '@/services/api/closing';
|
import { ClosingApi } from '@/services/api/closing';
|
||||||
import { ClosingOutgoingSapronak } from '@/types/api/closing';
|
import { ClosingOutgoingSapronak } from '@/types/api/closing';
|
||||||
|
import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton';
|
||||||
|
|
||||||
interface ClosingOutgoingSapronaksTableProps {
|
interface ClosingOutgoingSapronaksTableProps {
|
||||||
projectFlockId: number;
|
projectFlockId: number;
|
||||||
@@ -51,14 +54,12 @@ const ClosingOutgoingSapronaksTable = ({
|
|||||||
ClosingApi.getAllOutgoingSapronakFetcher
|
ClosingApi.getAllOutgoingSapronakFetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronak>[] = [
|
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronak>[] = [
|
||||||
{
|
{
|
||||||
header: '#',
|
header: 'No',
|
||||||
cell: (props) => props.row.index + 1,
|
cell: (props) => props.row.index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,6 +82,48 @@ const ClosingOutgoingSapronaksTable = ({
|
|||||||
{
|
{
|
||||||
accessorKey: 'product_category',
|
accessorKey: 'product_category',
|
||||||
header: 'Kategori Produk',
|
header: 'Kategori Produk',
|
||||||
|
cell: (props) => {
|
||||||
|
const categories = props.row.original.product_category
|
||||||
|
.split(' ')
|
||||||
|
.filter((cat) => cat.trim());
|
||||||
|
const maxBadges = 4;
|
||||||
|
const visibleCategories = categories.slice(0, maxBadges);
|
||||||
|
const remainingCount = categories.length - maxBadges;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-wrap gap-1 whitespace-nowrap'>
|
||||||
|
{visibleCategories.map((category, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant='soft'
|
||||||
|
className={{
|
||||||
|
badge: cn(
|
||||||
|
'px-2 py-1 flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content bg-base-content/5 whitespace-nowrap'
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
title={category}
|
||||||
|
>
|
||||||
|
{category.length > 12
|
||||||
|
? `${category.slice(0, 12)}...`
|
||||||
|
: category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant='soft'
|
||||||
|
className={{
|
||||||
|
badge: cn(
|
||||||
|
'px-2 py-1 flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content bg-base-content/20'
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
title={categories.join(' ')}
|
||||||
|
>
|
||||||
|
+{remainingCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'source_warehouse',
|
accessorKey: 'source_warehouse',
|
||||||
@@ -117,56 +160,59 @@ const ClosingOutgoingSapronaksTable = ({
|
|||||||
}
|
}
|
||||||
}, [sorting, updateFilter]);
|
}, [sorting, updateFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setOpen(
|
|
||||||
isResponseSuccess(outgoingSapronaks)
|
|
||||||
? outgoingSapronaks.data.length > 0
|
|
||||||
: false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [outgoingSapronaks, isResponseSuccess]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className='w-full'>
|
||||||
className={{
|
<Card
|
||||||
wrapper: 'w-full',
|
className={{
|
||||||
body: 'p-4 shadow',
|
wrapper: 'w-full rounded-lg',
|
||||||
}}
|
body: 'p-0',
|
||||||
>
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
<Collapse
|
collapsible: 'rounded-lg',
|
||||||
open={open}
|
}}
|
||||||
onOpenChange={setOpen}
|
variant='bordered'
|
||||||
title={
|
title='Sapronak Keluar'
|
||||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
collapsible
|
||||||
<div className='card-title'>Sapronak Keluar</div>
|
defaultCollapsed={false}
|
||||||
|
>
|
||||||
<Icon
|
<div className='flex flex-col gap-2 my-4'>
|
||||||
icon='material-symbols:keyboard-arrow-down'
|
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
||||||
width={24}
|
<DebouncedTextInput
|
||||||
height={24}
|
name='search'
|
||||||
className={cn('text-primary transition-transform', {
|
placeholder='Cari Sapronak Keluar'
|
||||||
'-rotate-180': open,
|
value={tableFilterState.search}
|
||||||
})}
|
onChange={searchChangeHandler}
|
||||||
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
|
input:
|
||||||
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
className='w-full!'
|
|
||||||
titleClassName='w-full p-0!'
|
|
||||||
>
|
|
||||||
<div className='w-full p-0'>
|
|
||||||
<div className='flex flex-col gap-2 mb-4'>
|
|
||||||
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
|
||||||
<DebouncedTextInput
|
|
||||||
name='search'
|
|
||||||
placeholder='Cari Sapronak Keluar'
|
|
||||||
value={tableFilterState.search}
|
|
||||||
onChange={searchChangeHandler}
|
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{isLoadingOutgoingSapronaks ? (
|
||||||
|
<SapronakClosingSkeleton
|
||||||
|
type='outgoing'
|
||||||
|
columns={outgoingSapronaksColumns}
|
||||||
|
/>
|
||||||
|
) : isResponseSuccess(outgoingSapronaks) &&
|
||||||
|
outgoingSapronaks.data.length === 0 ? (
|
||||||
|
<SapronakClosingSkeleton
|
||||||
|
type='outgoing'
|
||||||
|
columns={outgoingSapronaksColumns}
|
||||||
|
iconName='heroicons:chart-bar'
|
||||||
|
title='Data Sapronak Keluar Tidak Ditemukan'
|
||||||
|
subtitle='Tidak ada data sapronak keluar untuk periode ini.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Table<ClosingOutgoingSapronak>
|
<Table<ClosingOutgoingSapronak>
|
||||||
data={
|
data={
|
||||||
isResponseSuccess(outgoingSapronaks)
|
isResponseSuccess(outgoingSapronaks)
|
||||||
@@ -194,16 +240,21 @@ const ClosingOutgoingSapronaksTable = ({
|
|||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
setRowSelection={setRowSelection}
|
setRowSelection={setRowSelection}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'w-full mb-5!',
|
||||||
'w-full mb-20':
|
tableWrapperClassName:
|
||||||
isResponseSuccess(outgoingSapronaks) &&
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
outgoingSapronaks?.data?.length === 0,
|
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-b border-gray-200',
|
||||||
|
bodyRowClassName: 'hover:bg-gray-50 transition-colors',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
</Collapse>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,6 +35,13 @@ const StockLogTable = ({
|
|||||||
header: 'Gudang',
|
header: 'Gudang',
|
||||||
accessorKey: 'warehouse_name',
|
accessorKey: 'warehouse_name',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: 'Stock Akhir',
|
||||||
|
accessorKey: 'stock',
|
||||||
|
cell: (props) => {
|
||||||
|
return formatNumber(props.row.original.stock);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Peningkatan',
|
header: 'Peningkatan',
|
||||||
accessorKey: 'increase',
|
accessorKey: 'increase',
|
||||||
|
|||||||
@@ -49,17 +49,19 @@ const SalesOrderProductTable = ({
|
|||||||
>
|
>
|
||||||
<Icon icon='heroicons:pencil' width={20} height={20} />
|
<Icon icon='heroicons:pencil' width={20} height={20} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{data.length > 1 && (
|
||||||
type='button'
|
<Button
|
||||||
variant='ghost'
|
type='button'
|
||||||
color='none'
|
variant='ghost'
|
||||||
onClick={() => {
|
color='none'
|
||||||
onDeleteRef.current(item.id as number);
|
onClick={() => {
|
||||||
}}
|
onDeleteRef.current(item.id as number);
|
||||||
className='p-0 text-error hover:text-base-content'
|
}}
|
||||||
>
|
className='p-0 text-error hover:text-base-content'
|
||||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
>
|
||||||
</Button>
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ChickinLogsView from '@/components/pages/production/chickin/form/tabs/Chi
|
|||||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line';
|
import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
import { BaseApproval } from '@/types/api/api-general';
|
import { BaseApproval } from '@/types/api/api-general';
|
||||||
@@ -53,135 +54,126 @@ const ChickinFormKandang = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className='w-full h-full sm:w-[446px] overflow-y-auto'>
|
<div className='h-full w-full flex flex-col overflow-x-hidden overflow-y-auto'>
|
||||||
|
{/* Header */}
|
||||||
<DrawerHeader
|
<DrawerHeader
|
||||||
subtitle={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
|
leftIcon='heroicons:chevron-left'
|
||||||
leftIcon='mdi:arrow-left'
|
|
||||||
leftIconHref={`/production/project-flock/detail?projectFlockId=${initialValues?.project_flock?.id}`}
|
leftIconHref={`/production/project-flock/detail?projectFlockId=${initialValues?.project_flock?.id}`}
|
||||||
|
leftIconClassName='hover:text-gray-400'
|
||||||
|
subtitle={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
|
||||||
|
className='sticky top-0 z-10 bg-base-100'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Informasi Kandang */}
|
{approvals && !approvalsLoading && (
|
||||||
<div className='divider'></div>
|
<ApprovalSteps approvals={approvals} />
|
||||||
<div className='px-4 pb-4 flex flex-col gap-4'>
|
)}
|
||||||
<h2 className='text-xl font-semibold'>Informasi Kandang</h2>
|
|
||||||
|
|
||||||
{approvals && !approvalsLoading && (
|
{/* Informasi Kandang */}
|
||||||
<div className='mb-3 text-sm'>
|
<div className='w-full p-4 flex flex-col gap-3 border-b border-base-content/10'>
|
||||||
<ApprovalSteps approvals={approvals} />
|
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
|
||||||
</div>
|
Informasi Kandang
|
||||||
)}
|
</h4>
|
||||||
|
|
||||||
{/* Badge Row */}
|
{/* Badge Row */}
|
||||||
<div className='flex flex-row gap-2'>
|
<div className='flex flex-row gap-2'>
|
||||||
<Badge
|
<StatusBadge
|
||||||
variant='soft'
|
color='success'
|
||||||
color='primary'
|
text='Active'
|
||||||
className={{
|
className={{ badge: 'w-fit text-nowrap' }}
|
||||||
badge: 'rounded-lg px-2',
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon='mdi:circle' width={12} height={12} color='primary' />{' '}
|
|
||||||
Aktif
|
|
||||||
</Badge>
|
|
||||||
<div className='divider divider-horizontal p-0 m-0'></div>
|
<div className='divider divider-horizontal p-0 m-0'></div>
|
||||||
<Badge
|
<StatusBadge
|
||||||
color='neutral'
|
color='neutral'
|
||||||
variant='soft'
|
text={` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`}
|
||||||
className={{ badge: 'rounded-lg px-2' }}
|
className={{ badge: 'w-fit text-nowrap' }}
|
||||||
>
|
/>
|
||||||
<Icon icon='mdi:home' width={12} height={12} />
|
|
||||||
{` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Information Grid */}
|
{/* Information Card */}
|
||||||
<div className='grid grid-cols-3 gap-4'>
|
<Card
|
||||||
{/* Area */}
|
variant='bordered'
|
||||||
<div
|
className={{
|
||||||
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
|
wrapper: 'w-full rounded-lg',
|
||||||
relative
|
body: 'p-3',
|
||||||
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
|
}}
|
||||||
>
|
>
|
||||||
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
|
<div className='flex flex-col gap-6'>
|
||||||
|
<div className='flex flex-row justify-between items-center'>
|
||||||
|
<div className='flex flex-row gap-2 items-center text-gray-400'>
|
||||||
|
<Icon icon={'mdi:circle-slice-8'} width={14} height={14} />{' '}
|
||||||
|
<span>Area</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-end text-gray-500'>
|
||||||
|
{initialValues.project_flock.area.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row justify-between items-center'>
|
||||||
|
<div className='flex flex-row gap-2 items-center text-gray-400'>
|
||||||
|
<Icon icon={'mdi:circle-slice-8'} width={14} height={14} />{' '}
|
||||||
|
<span>Lokasi</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-end text-gray-500'>
|
||||||
|
{initialValues.project_flock?.location.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row justify-between items-center'>
|
||||||
|
<div className='flex flex-row gap-2 items-center text-gray-400'>
|
||||||
|
<Icon icon={'mdi:circle-slice-8'} width={14} height={14} />{' '}
|
||||||
|
<span>Kandang</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-end text-gray-500'>
|
||||||
|
{initialValues.kandang.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row justify-between items-center'>
|
||||||
|
<div className='flex flex-row gap-2 items-center text-gray-400'>
|
||||||
|
<Icon icon={'mdi:circle-slice-8'} width={14} height={14} />{' '}
|
||||||
|
<span>Jumlah DOC</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-end text-gray-500'>
|
||||||
|
{formatNumber(
|
||||||
|
initialValues.chickins?.reduce(
|
||||||
|
(total, chickin) => total + chickin.usage_qty,
|
||||||
|
0
|
||||||
|
) ?? 0
|
||||||
|
)}{' '}
|
||||||
|
Ekor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-2'>
|
</Card>
|
||||||
{initialValues.project_flock.area.name}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lokasi */}
|
|
||||||
<div
|
|
||||||
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
|
|
||||||
relative
|
|
||||||
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
|
|
||||||
>
|
|
||||||
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
|
|
||||||
</div>
|
|
||||||
<div className='col-span-2'>
|
|
||||||
{initialValues.project_flock?.location.name}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Kandang */}
|
|
||||||
<div
|
|
||||||
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
|
|
||||||
relative
|
|
||||||
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
|
|
||||||
>
|
|
||||||
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Kandang
|
|
||||||
</div>
|
|
||||||
<div className='col-span-2'>{initialValues.kandang.name}</div>
|
|
||||||
|
|
||||||
{/* Jumlah DOC */}
|
|
||||||
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
|
|
||||||
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Jumlah DOC
|
|
||||||
</div>
|
|
||||||
<div className='col-span-2'>
|
|
||||||
{formatNumber(
|
|
||||||
initialValues.chickins?.reduce(
|
|
||||||
(total, chickin) => total + chickin.usage_qty,
|
|
||||||
0
|
|
||||||
) ?? 0
|
|
||||||
)}{' '}
|
|
||||||
Ekor
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='divider'></div>
|
{/* Informasi Chick In */}
|
||||||
<div className='px-4 pb-4 flex flex-col gap-4'>
|
<div className='w-full p-4 flex flex-col gap-3 border-b border-base-content/10'>
|
||||||
<h2 className='text-xl font-semibold'>Informasi Chick In</h2>
|
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
|
||||||
|
Informasi Chick In
|
||||||
|
</h4>
|
||||||
{/* Badge Row */}
|
{/* Badge Row */}
|
||||||
<div className='flex flex-row gap-2'>
|
<div className='flex flex-row gap-2'>
|
||||||
<RequirePermission permissions='lti.production.chickins.create'>
|
<RequirePermission permissions='lti.production.chickins.create'>
|
||||||
<Badge
|
<StatusBadge
|
||||||
variant='soft'
|
color='success'
|
||||||
color={'success'}
|
text={`Perlu Chick In (${initialValues.available_qtys?.length ?? 0})`}
|
||||||
className={{
|
className={{ badge: 'w-fit text-nowrap' }}
|
||||||
badge: 'rounded-lg px-2',
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='mdi:circle'
|
|
||||||
width={12}
|
|
||||||
height={12}
|
|
||||||
color={'success'}
|
|
||||||
/>{' '}
|
|
||||||
Perlu Chick In ({initialValues.available_qtys?.length ?? 0})
|
|
||||||
</Badge>
|
|
||||||
<div className='divider divider-horizontal p-0 m-0'></div>
|
<div className='divider divider-horizontal p-0 m-0'></div>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
<Badge
|
<StatusBadge
|
||||||
color='neutral'
|
color='neutral'
|
||||||
variant='soft'
|
|
||||||
className={{ badge: 'rounded-lg px-2 cursor-pointer' }}
|
|
||||||
onClick={() => setOpenChickin(!openChickin)}
|
onClick={() => setOpenChickin(!openChickin)}
|
||||||
>
|
text={
|
||||||
{`Riwayat Chick In ${formatNumber(initialValues.chickins?.length ?? 0)}`}
|
<>
|
||||||
<Icon
|
{`Riwayat Chick In ${formatNumber(initialValues.chickins?.length ?? 0)}`}
|
||||||
icon={`mdi:${openChickin ? 'eye' : 'eye-off'}`}
|
<Icon
|
||||||
width={12}
|
icon={`mdi:${openChickin ? 'eye' : 'eye-off'}`}
|
||||||
height={12}
|
width={12}
|
||||||
/>
|
height={12}
|
||||||
</Badge>
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
className={{ badge: 'w-fit text-nowrap cursor-pointer' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{openChickin && (
|
{openChickin && (
|
||||||
@@ -198,7 +190,7 @@ const ChickinFormKandang = ({
|
|||||||
afterSubmit={afterSubmitFormChickin}
|
afterSubmit={afterSubmitFormChickin}
|
||||||
/>
|
/>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import Alert from '@/components/Alert';
|
|||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
import { useModal } from '@/components/Modal';
|
|
||||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
|
||||||
import PillBadge from '@/components/PillBadge';
|
import PillBadge from '@/components/PillBadge';
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { formatDate, formatNumber } from '@/lib/helper';
|
import { formatDate, formatNumber } from '@/lib/helper';
|
||||||
@@ -13,6 +11,7 @@ import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandan
|
|||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
|
||||||
|
|
||||||
const ChickinLogsView = ({
|
const ChickinLogsView = ({
|
||||||
initialValues,
|
initialValues,
|
||||||
@@ -23,32 +22,26 @@ const ChickinLogsView = ({
|
|||||||
afterSubmit?: () => void;
|
afterSubmit?: () => void;
|
||||||
rawDataApprovals: BaseApproval[];
|
rawDataApprovals: BaseApproval[];
|
||||||
}) => {
|
}) => {
|
||||||
const confirmModal = useModal();
|
|
||||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
|
||||||
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
|
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
|
||||||
|
const { openChickinApproveModal } = useChickinStore();
|
||||||
|
|
||||||
const handleClickApprove = () => {
|
const handleClickApprove = () => {
|
||||||
confirmModal.openModal();
|
openChickinApproveModal(initialValues, async (notes?: string) => {
|
||||||
};
|
setChickinErrorMessage('');
|
||||||
|
const approveChickinRes = await ChickinApi.singleApproval(
|
||||||
const confirmationModalApproveClickHandler = async (notes?: string) => {
|
initialValues?.id as number,
|
||||||
setChickinErrorMessage('');
|
'APPROVED',
|
||||||
setIsApproveLoading(true);
|
notes
|
||||||
const approveChickinRes = await ChickinApi.singleApproval(
|
);
|
||||||
initialValues?.id as number,
|
if (isResponseSuccess(approveChickinRes)) {
|
||||||
'APPROVED',
|
toast.success(approveChickinRes?.message as string);
|
||||||
notes
|
}
|
||||||
);
|
if (isResponseError(approveChickinRes)) {
|
||||||
if (isResponseSuccess(approveChickinRes)) {
|
toast.error(approveChickinRes?.message as string);
|
||||||
toast.success(approveChickinRes?.message as string);
|
setChickinErrorMessage(approveChickinRes?.message as string);
|
||||||
}
|
}
|
||||||
if (isResponseError(approveChickinRes)) {
|
afterSubmit && afterSubmit();
|
||||||
toast.error(approveChickinRes?.message as string);
|
});
|
||||||
setChickinErrorMessage(approveChickinRes?.message as string);
|
|
||||||
}
|
|
||||||
confirmModal.closeModal();
|
|
||||||
setIsApproveLoading(false);
|
|
||||||
afterSubmit && afterSubmit();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,7 +76,7 @@ const ChickinLogsView = ({
|
|||||||
key={chickin.id || index}
|
key={chickin.id || index}
|
||||||
variant='bordered'
|
variant='bordered'
|
||||||
className={{
|
className={{
|
||||||
wrapper: 'w-full',
|
wrapper: 'w-full mt-3',
|
||||||
body: 'p-3',
|
body: 'p-3',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -176,23 +169,6 @@ const ChickinLogsView = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmationModalWithNotes
|
|
||||||
ref={confirmModal.ref}
|
|
||||||
type='success'
|
|
||||||
text={`Apakah anda yakin ingin approve data Chickin yang Pending?`}
|
|
||||||
secondaryButton={{
|
|
||||||
text: 'Tidak',
|
|
||||||
}}
|
|
||||||
primaryButton={{
|
|
||||||
text: 'Ya',
|
|
||||||
color: 'success',
|
|
||||||
onClick: (notes) => {
|
|
||||||
confirmationModalApproveClickHandler(notes);
|
|
||||||
},
|
|
||||||
isLoading: isApproveLoading,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ import StatusBadge from '@/components/helper/StatusBadge';
|
|||||||
import PopoverButton from '@/components/popover/PopoverButton';
|
import PopoverButton from '@/components/popover/PopoverButton';
|
||||||
import PopoverContent from '@/components/popover/PopoverContent';
|
import PopoverContent from '@/components/popover/PopoverContent';
|
||||||
import ProjectFlockConfirmationModal from './ProjectFlockConfirmationModal';
|
import ProjectFlockConfirmationModal from './ProjectFlockConfirmationModal';
|
||||||
import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store';
|
import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store';
|
||||||
import { ProjectFlockFormValues } from './form/ProjectFlockForm.schema';
|
import { ProjectFlockFormValues } from './form/ProjectFlockForm.schema';
|
||||||
|
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
props,
|
props,
|
||||||
@@ -193,6 +194,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
const deleteModal = useModal();
|
const deleteModal = useModal();
|
||||||
const confirmModal = useModal();
|
const confirmModal = useModal();
|
||||||
const successModal = useModal();
|
const successModal = useModal();
|
||||||
|
const chickinApproveModal = useModal();
|
||||||
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
|
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
|
||||||
'APPROVED'
|
'APPROVED'
|
||||||
);
|
);
|
||||||
@@ -200,6 +202,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const {
|
||||||
|
isChickinApproveModalOpen,
|
||||||
|
isChickinApproveLoading,
|
||||||
|
chickinApproveCallback,
|
||||||
|
closeChickinApproveModal,
|
||||||
|
setChickinApproveLoading,
|
||||||
|
} = useChickinStore();
|
||||||
|
|
||||||
// ===== Fetch Data =====
|
// ===== Fetch Data =====
|
||||||
const {
|
const {
|
||||||
@@ -271,7 +280,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isResponseSuccess(approveProjectFlockRes)) {
|
if (isResponseSuccess(approveProjectFlockRes)) {
|
||||||
toast.success('Project Flock berhasil di-approve!');
|
const successMessage =
|
||||||
|
approvalAction === 'APPROVED'
|
||||||
|
? 'Project Flock berhasil di-approve!'
|
||||||
|
: 'Project Flock berhasil di-reject!';
|
||||||
|
toast.success(successMessage);
|
||||||
confirmModal.closeModal();
|
confirmModal.closeModal();
|
||||||
}
|
}
|
||||||
if (isResponseError(approveProjectFlockRes)) {
|
if (isResponseError(approveProjectFlockRes)) {
|
||||||
@@ -288,6 +301,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
refreshProjectFlocks();
|
refreshProjectFlocks();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isChickinApproveModalOpen) {
|
||||||
|
chickinApproveModal.openModal();
|
||||||
|
} else {
|
||||||
|
chickinApproveModal.closeModal();
|
||||||
|
}
|
||||||
|
}, [isChickinApproveModalOpen, chickinApproveModal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
successModal.openModal();
|
successModal.openModal();
|
||||||
@@ -384,7 +405,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => {
|
header: ({ table }) => {
|
||||||
const allRows = table.getRowModel().rows;
|
const allRows = table.getRowModel().rows;
|
||||||
const selectableRows = allRows;
|
const selectableRows = allRows.filter((row) => {
|
||||||
|
const projectFlock = row.original;
|
||||||
|
return (
|
||||||
|
projectFlock.approval?.step_number === 1 &&
|
||||||
|
projectFlock.approval?.action !== 'REJECTED'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const allSelected =
|
const allSelected =
|
||||||
selectableRows.every((row) => row.getIsSelected()) &&
|
selectableRows.every((row) => row.getIsSelected()) &&
|
||||||
@@ -398,6 +425,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
selectableRows.forEach((row) => row.toggleSelected(shouldSelect));
|
selectableRows.forEach((row) => row.toggleSelected(shouldSelect));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasNoSelectableRows = selectableRows.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full flex flex-row justify-center'>
|
<div className='w-full flex flex-row justify-center'>
|
||||||
<CheckboxInput
|
<CheckboxInput
|
||||||
@@ -405,6 +434,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
checked={allSelected}
|
checked={allSelected}
|
||||||
indeterminate={someSelected}
|
indeterminate={someSelected}
|
||||||
onChange={toggleSelectableRows}
|
onChange={toggleSelectableRows}
|
||||||
|
disabled={hasNoSelectableRows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -845,6 +875,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
setRowSelection={setRowSelection}
|
setRowSelection={setRowSelection}
|
||||||
|
enableRowSelection={(row) => {
|
||||||
|
const projectFlock = row.original;
|
||||||
|
return (
|
||||||
|
projectFlock.approval?.step_number === 1 &&
|
||||||
|
projectFlock.approval?.action !== 'REJECTED'
|
||||||
|
);
|
||||||
|
}}
|
||||||
withCheckbox
|
withCheckbox
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn('p-3', {
|
containerClassName: cn('p-3', {
|
||||||
@@ -954,6 +991,40 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
onClose={handleSuccessModalClose}
|
onClose={handleSuccessModalClose}
|
||||||
secondaryButton={undefined}
|
secondaryButton={undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Chickin Approval Modal */}
|
||||||
|
<ConfirmationModalWithNotes
|
||||||
|
ref={chickinApproveModal.ref}
|
||||||
|
type='success'
|
||||||
|
text={`Apakah anda yakin ingin approve data Chickin yang Pending?`}
|
||||||
|
className={{
|
||||||
|
modal: 'z-9999',
|
||||||
|
}}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
onClick: () => {
|
||||||
|
closeChickinApproveModal();
|
||||||
|
chickinApproveModal.closeModal();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'success',
|
||||||
|
onClick: async (notes) => {
|
||||||
|
if (chickinApproveCallback) {
|
||||||
|
setChickinApproveLoading(true);
|
||||||
|
try {
|
||||||
|
await chickinApproveCallback(notes);
|
||||||
|
} finally {
|
||||||
|
setChickinApproveLoading(false);
|
||||||
|
closeChickinApproveModal();
|
||||||
|
chickinApproveModal.closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isLoading: isChickinApproveLoading,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+166
-122
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
|
import Card from '@/components/Card';
|
||||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
import { cn, formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
|
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
|
||||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||||
import {
|
import {
|
||||||
ClosingExpense,
|
ClosingExpense,
|
||||||
@@ -20,9 +22,28 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
|
||||||
import { ApprovalApi } from '@/services/api/approval';
|
import { ApprovalApi } from '@/services/api/approval';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
|
import { Color } from '@/types/theme';
|
||||||
|
|
||||||
|
const getExpenseStatusBadgeColor = (step: number): Color => {
|
||||||
|
switch (step) {
|
||||||
|
case 1:
|
||||||
|
return 'neutral';
|
||||||
|
case 2:
|
||||||
|
return 'info';
|
||||||
|
case 3:
|
||||||
|
return 'warning';
|
||||||
|
case 4:
|
||||||
|
return 'error';
|
||||||
|
case 5:
|
||||||
|
return 'warning';
|
||||||
|
case 6:
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ProjectFlockClosingForm = ({
|
const ProjectFlockClosingForm = ({
|
||||||
projectFlock,
|
projectFlock,
|
||||||
@@ -49,6 +70,10 @@ const ProjectFlockClosingForm = ({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isKandangClosed = useMemo(() => {
|
||||||
|
return projectFlockKandang.kandang?.status === 'NON_ACTIVE';
|
||||||
|
}, [projectFlockKandang]);
|
||||||
|
|
||||||
const isCanClose = useMemo(() => {
|
const isCanClose = useMemo(() => {
|
||||||
return isResponseSuccess(projectFlockKandangApprovals)
|
return isResponseSuccess(projectFlockKandangApprovals)
|
||||||
? projectFlockKandangApprovals?.data?.[0]?.step_number <= 2
|
? projectFlockKandangApprovals?.data?.[0]?.step_number <= 2
|
||||||
@@ -60,8 +85,10 @@ const ProjectFlockClosingForm = ({
|
|||||||
const deleteProjectFlockRes = await ProjectFlockKandangApi.closing(
|
const deleteProjectFlockRes = await ProjectFlockKandangApi.closing(
|
||||||
projectFlockKandang?.id as number,
|
projectFlockKandang?.id as number,
|
||||||
{
|
{
|
||||||
closed_date: isCanClose ? formatDate(new Date(), 'YYYY-MM-DD') : '',
|
closed_date: !isKandangClosed
|
||||||
action: isCanClose ? 'close' : 'unclose',
|
? formatDate(new Date(), 'YYYY-MM-DD')
|
||||||
|
: '',
|
||||||
|
action: !isKandangClosed ? 'close' : 'unclose',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,160 +105,167 @@ const ProjectFlockClosingForm = ({
|
|||||||
closeModal.closeModal();
|
closeModal.closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorStock = useMemo(() => {
|
// const errorStock = useMemo(() => {
|
||||||
return isResponseSuccess(closingData)
|
// return isResponseSuccess(closingData)
|
||||||
? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0)
|
// ? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0)
|
||||||
: true;
|
// : true;
|
||||||
}, [closingData]);
|
// }, [closingData]);
|
||||||
|
|
||||||
const errorExpense = useMemo(() => {
|
// const errorExpense = useMemo(() => {
|
||||||
return isResponseSuccess(closingData)
|
// return isResponseSuccess(closingData)
|
||||||
? closingData?.data?.expenses.every((expense) => expense.step < 5)
|
// ? closingData?.data?.expenses.every((expense) => expense.step < 5)
|
||||||
: true;
|
// : true;
|
||||||
}, [closingData]);
|
// }, [closingData]);
|
||||||
|
|
||||||
const isCanCloseValid = true;
|
const isCanCloseValid = true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className='w-full h-full sm:w-[446px] overflow-y-auto'>
|
<div className='h-full w-full flex flex-col overflow-x-hidden overflow-y-auto'>
|
||||||
|
{/* Header */}
|
||||||
<DrawerHeader
|
<DrawerHeader
|
||||||
leftIcon='mdi:arrow-left'
|
leftIcon='heroicons:chevron-left'
|
||||||
leftIconHref={`/production/project-flock/detail?projectFlockId=${projectFlock.id}`}
|
leftIconHref={`/production/project-flock/detail?projectFlockId=${projectFlock.id}`}
|
||||||
subtitle={`Close ${projectFlock.flock_name}`}
|
leftIconClassName='hover:text-gray-400'
|
||||||
></DrawerHeader>
|
subtitle={isKandangClosed ? 'Unclose Flock' : 'Close Flock'}
|
||||||
|
className='sticky top-0 z-10 bg-base-100'
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Informasi Kandang */}
|
{/* Informasi Kandang */}
|
||||||
<div className='divider'></div>
|
<div className='w-full p-4 flex flex-col gap-3 border-b border-base-content/10'>
|
||||||
<div className='px-4 pb-4 flex flex-col gap-4'>
|
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
|
||||||
<h2 className='text-2xl font-semibold'>Informasi Kandang</h2>
|
Informasi Kandang
|
||||||
|
</h4>
|
||||||
|
|
||||||
{/* Badge Row */}
|
{/* Badge Row */}
|
||||||
<div className='flex flex-row gap-2'>
|
<div className='flex flex-row gap-2'>
|
||||||
<Badge
|
<StatusBadge
|
||||||
variant='soft'
|
color={isKandangClosed ? 'error' : 'success'}
|
||||||
color='success'
|
text={isKandangClosed ? 'Closed' : 'Active'}
|
||||||
className={{
|
className={{ badge: 'w-fit text-nowrap' }}
|
||||||
badge: 'rounded-lg px-2',
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
|
|
||||||
Aktif
|
|
||||||
</Badge>
|
|
||||||
<div className='divider divider-horizontal p-0 m-0'></div>
|
<div className='divider divider-horizontal p-0 m-0'></div>
|
||||||
<Badge
|
<StatusBadge
|
||||||
color='neutral'
|
color='neutral'
|
||||||
variant='soft'
|
text={` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`}
|
||||||
className={{ badge: 'rounded-lg px-2' }}
|
className={{ badge: 'w-fit text-nowrap' }}
|
||||||
>
|
/>
|
||||||
<Icon icon='mdi:home' width={12} height={12} />
|
|
||||||
{` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Information Grid */}
|
{/* Information Card */}
|
||||||
<div className='grid grid-cols-3 gap-4'>
|
<Card
|
||||||
{/* Area */}
|
variant='bordered'
|
||||||
<div
|
className={{
|
||||||
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
|
wrapper: 'w-full rounded-lg',
|
||||||
relative
|
body: 'p-3',
|
||||||
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
|
}}
|
||||||
>
|
>
|
||||||
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
|
<div className='flex flex-col gap-6'>
|
||||||
|
<div className='flex flex-row justify-between items-center'>
|
||||||
|
<div className='flex flex-row gap-2 items-center text-gray-400'>
|
||||||
|
<Icon icon={'mdi:circle-slice-8'} width={14} height={14} />{' '}
|
||||||
|
<span>Area</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-end text-gray-500'>
|
||||||
|
{projectFlock.area?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row justify-between items-center'>
|
||||||
|
<div className='flex flex-row gap-2 items-center text-gray-400'>
|
||||||
|
<Icon icon={'mdi:circle-slice-8'} width={14} height={14} />{' '}
|
||||||
|
<span>Lokasi</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-end text-gray-500'>
|
||||||
|
{projectFlock.location?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row justify-between items-center'>
|
||||||
|
<div className='flex flex-row gap-2 items-center text-gray-400'>
|
||||||
|
<Icon icon={'mdi:circle-slice-8'} width={14} height={14} />{' '}
|
||||||
|
<span>Kandang</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-end text-gray-500'>
|
||||||
|
{projectFlockKandang.kandang?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row justify-between items-center'>
|
||||||
|
<div className='flex flex-row gap-2 items-center text-gray-400'>
|
||||||
|
<Icon icon={'mdi:circle-slice-8'} width={14} height={14} />{' '}
|
||||||
|
<span>Jumlah DOC</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-end text-gray-500'>
|
||||||
|
{formatNumber(
|
||||||
|
projectFlockKandang.chickins?.reduce(
|
||||||
|
(total, chickin) => total + chickin.usage_qty,
|
||||||
|
0
|
||||||
|
) ?? 0
|
||||||
|
)}{' '}
|
||||||
|
Ekor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-2'>{projectFlock.area?.name}</div>
|
</Card>
|
||||||
|
|
||||||
{/* Lokasi */}
|
|
||||||
<div
|
|
||||||
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
|
|
||||||
relative
|
|
||||||
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
|
|
||||||
>
|
|
||||||
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
|
|
||||||
</div>
|
|
||||||
<div className='col-span-2'>{projectFlock.location?.name}</div>
|
|
||||||
|
|
||||||
{/* Kandang */}
|
|
||||||
<div
|
|
||||||
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
|
|
||||||
relative
|
|
||||||
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
|
|
||||||
>
|
|
||||||
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Kandang
|
|
||||||
</div>
|
|
||||||
<div className='col-span-2'>
|
|
||||||
{projectFlockKandang.kandang?.name}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Jumlah DOC */}
|
|
||||||
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
|
|
||||||
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Jumlah
|
|
||||||
DOC
|
|
||||||
</div>
|
|
||||||
<div className='col-span-2'>
|
|
||||||
{formatNumber(
|
|
||||||
projectFlockKandang.chickins?.reduce(
|
|
||||||
(total, chickin) => total + chickin.usage_qty,
|
|
||||||
0
|
|
||||||
) ?? 0
|
|
||||||
)}{' '}
|
|
||||||
Ekor
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table Biaya */}
|
{/* Table Biaya */}
|
||||||
<div className='divider'></div>
|
<div className='w-full p-4 flex flex-col gap-3 border-b border-base-content/10'>
|
||||||
<div className='px-4 pb-4'>
|
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
|
||||||
<h2 className='text-2xl font-semibold'>Biaya</h2>
|
Biaya
|
||||||
|
</h4>
|
||||||
<Table<ClosingExpense>
|
<Table<ClosingExpense>
|
||||||
data={
|
data={
|
||||||
isResponseSuccess(closingData) ? closingData.data?.expenses : []
|
isResponseSuccess(closingData) ? closingData.data?.expenses : []
|
||||||
}
|
}
|
||||||
columns={[
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Ref Number',
|
||||||
|
accessorKey: 'reference_number',
|
||||||
|
cell(props) {
|
||||||
|
return props.row.original.reference_number || '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'PO Number',
|
header: 'PO Number',
|
||||||
accessorKey: 'po_number',
|
accessorKey: 'po_number',
|
||||||
|
cell(props) {
|
||||||
|
return props.row.original.po_number || '-';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Total',
|
header: 'Total',
|
||||||
accessorKey: 'total',
|
accessorKey: 'total',
|
||||||
|
cell(props) {
|
||||||
|
return formatNumber(props.row.original.total);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell(props) {
|
cell(props) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<StatusBadge
|
||||||
|
color={getExpenseStatusBadgeColor(
|
||||||
|
props.row.original.step
|
||||||
|
)}
|
||||||
|
text={formatTitleCase(props.row.original.step_name)}
|
||||||
className={{
|
className={{
|
||||||
badge: 'rounded-lg',
|
badge: 'whitespace-nowrap max-w-max w-fit',
|
||||||
}}
|
}}
|
||||||
variant='soft'
|
/>
|
||||||
color={
|
|
||||||
props.row.original.step < 5
|
|
||||||
? props.row.original.step == 1
|
|
||||||
? 'neutral'
|
|
||||||
: 'success'
|
|
||||||
: 'error'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formatTitleCase(props.row.original.step_name)}
|
|
||||||
</Badge>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn('my-4'),
|
containerClassName: 'mb-0',
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
|
tableWrapperClassName: 'overflow-x-auto max-w-120',
|
||||||
tableClassName: 'font-inter w-full table-sm min-h-full!',
|
tableClassName: 'font-inter w-full table-sm',
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
headerRowClassName: 'border-b border-base-content/10',
|
||||||
headerColumnClassName:
|
headerColumnClassName:
|
||||||
'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
|
'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
|
||||||
bodyRowClassName: 'border-b border-b-gray-200',
|
bodyRowClassName: 'border-b border-base-content/10',
|
||||||
bodyColumnClassName:
|
bodyColumnClassName:
|
||||||
'px-3 py-3 last:flex last:flex-row last:justify-end',
|
'px-3 py-3 last:flex last:flex-row last:justify-end',
|
||||||
paginationClassName: 'hidden',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* {errorExpense && (
|
{/* {errorExpense && (
|
||||||
@@ -242,9 +276,10 @@ const ProjectFlockClosingForm = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table Persediaan Gudang */}
|
{/* Table Persediaan Gudang */}
|
||||||
<div className='divider'></div>
|
<div className='w-full p-4 flex flex-col gap-3 border-b border-base-content/10'>
|
||||||
<div className='px-4 pb-4'>
|
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
|
||||||
<h2 className='text-2xl font-semibold'>Persediaan Gudang</h2>
|
Persediaan Gudang
|
||||||
|
</h4>
|
||||||
<Table<StockItem>
|
<Table<StockItem>
|
||||||
data={
|
data={
|
||||||
isResponseSuccess(closingData)
|
isResponseSuccess(closingData)
|
||||||
@@ -263,6 +298,9 @@ const ProjectFlockClosingForm = ({
|
|||||||
{
|
{
|
||||||
header: 'Quantity',
|
header: 'Quantity',
|
||||||
accessorKey: 'quantity',
|
accessorKey: 'quantity',
|
||||||
|
cell(props) {
|
||||||
|
return formatNumber(props.row.original.quantity);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'UOM',
|
header: 'UOM',
|
||||||
@@ -270,16 +308,15 @@ const ProjectFlockClosingForm = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn('my-4'),
|
containerClassName: 'mb-0',
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
|
tableWrapperClassName: 'overflow-x-auto max-w-120',
|
||||||
tableClassName: 'font-inter w-full table-sm min-h-full!',
|
tableClassName: 'font-inter w-full table-sm',
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
headerRowClassName: 'border-b border-base-content/10',
|
||||||
headerColumnClassName:
|
headerColumnClassName:
|
||||||
'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
|
'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
|
||||||
bodyRowClassName: 'border-b border-b-gray-200',
|
bodyRowClassName: 'border-b border-base-content/10',
|
||||||
bodyColumnClassName:
|
bodyColumnClassName:
|
||||||
'px-3 py-3 last:flex last:flex-row last:justify-end',
|
'px-3 py-3 last:flex last:flex-row last:justify-end',
|
||||||
paginationClassName: 'hidden',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* {errorStock && (
|
{/* {errorStock && (
|
||||||
@@ -289,17 +326,24 @@ const ProjectFlockClosingForm = ({
|
|||||||
)} */}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='p-4 mt-6'>
|
<div className='p-4'>
|
||||||
<RequirePermission permissions='lti.production.project_flock_kandangs.closing'>
|
<RequirePermission permissions='lti.production.project_flock_kandangs.closing'>
|
||||||
<Button
|
<Button
|
||||||
className='w-full'
|
className='w-full'
|
||||||
|
variant='outline'
|
||||||
color='error'
|
color='error'
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={!isCanCloseValid}
|
disabled={!isCanCloseValid}
|
||||||
onClick={() => closeModal.openModal()}
|
onClick={() => closeModal.openModal()}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:checkbox-marked-circle-outline' />{' '}
|
<Icon
|
||||||
{isCanClose ? 'Close' : 'Unclose'}
|
icon={
|
||||||
|
isKandangClosed
|
||||||
|
? 'mdi:lock-open-variant'
|
||||||
|
: 'mdi:checkbox-marked-circle-outline'
|
||||||
|
}
|
||||||
|
/>{' '}
|
||||||
|
{isKandangClosed ? 'Unclose' : 'Close'}
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,7 +352,7 @@ const ProjectFlockClosingForm = ({
|
|||||||
ref={closeModal.ref}
|
ref={closeModal.ref}
|
||||||
type='error'
|
type='error'
|
||||||
text={
|
text={
|
||||||
isCanClose
|
!isKandangClosed
|
||||||
? 'Apakah kamu yakin ingin mengakhiri project ini ? *Pastikan persediaan produk di gudang terkait sudah kosong, dan BOP sudah selesai'
|
? 'Apakah kamu yakin ingin mengakhiri project ini ? *Pastikan persediaan produk di gudang terkait sudah kosong, dan BOP sudah selesai'
|
||||||
: 'Apakah kamu yakin ingin membuka kembali project ini ? *Project ini akan kembali ke status aktif'
|
: 'Apakah kamu yakin ingin membuka kembali project ini ? *Project ini akan kembali ke status aktif'
|
||||||
}
|
}
|
||||||
@@ -322,7 +366,7 @@ const ProjectFlockClosingForm = ({
|
|||||||
onClick: confirmationModalCloseClickHandler,
|
onClick: confirmationModalCloseClickHandler,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</section>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -226,15 +226,37 @@ const ProjectFlockDetail = ({
|
|||||||
|
|
||||||
<div className='w-full p-4 flex flex-col gap-3 border-b border-base-content/10'>
|
<div className='w-full p-4 flex flex-col gap-3 border-b border-base-content/10'>
|
||||||
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
|
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
|
||||||
Kandang Aktif
|
Kandang
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className='flex flex-row gap-2'>
|
<div className='flex flex-row flex-wrap gap-2 overflow-hidden'>
|
||||||
<StatusBadge
|
{projectFlock.kandangs?.filter(
|
||||||
color='info'
|
(kandang) => kandang.status !== 'NON_ACTIVE'
|
||||||
text={`Kandang Aktif (${projectFlock.kandangs?.length})`}
|
).length > 0 && (
|
||||||
className={{ badge: 'w-fit text-nowrap' }}
|
<StatusBadge
|
||||||
/>
|
color='success'
|
||||||
|
text={`Kandang Active (${
|
||||||
|
projectFlock.kandangs?.filter(
|
||||||
|
(kandang) => kandang.status !== 'NON_ACTIVE'
|
||||||
|
).length ?? 0
|
||||||
|
})`}
|
||||||
|
className={{ badge: 'w-fit' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projectFlock.kandangs?.filter(
|
||||||
|
(kandang) => kandang.status === 'NON_ACTIVE'
|
||||||
|
).length > 0 && (
|
||||||
|
<StatusBadge
|
||||||
|
color='error'
|
||||||
|
text={`Kandang Closed (${
|
||||||
|
projectFlock.kandangs?.filter(
|
||||||
|
(kandang) => kandang.status === 'NON_ACTIVE'
|
||||||
|
).length ?? 0
|
||||||
|
})`}
|
||||||
|
className={{ badge: 'w-fit' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
color='neutral'
|
color='neutral'
|
||||||
@@ -256,7 +278,7 @@ const ProjectFlockDetail = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
className={{ badge: 'w-fit text-nowrap cursor-pointer' }}
|
className={{ badge: 'w-fit cursor-pointer' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -355,43 +377,53 @@ const ProjectFlockDetail = ({
|
|||||||
disabled={projectFlock?.approval?.step_number == 1}
|
disabled={projectFlock?.approval?.step_number == 1}
|
||||||
/>
|
/>
|
||||||
<div className='text-end'>
|
<div className='text-end'>
|
||||||
<Badge
|
<StatusBadge
|
||||||
className={{
|
color={
|
||||||
badge: 'rounded-lg',
|
kandang?.status === 'NON_ACTIVE' ? 'error' : 'success'
|
||||||
}}
|
}
|
||||||
>
|
text={<>Kapasitas {kandang?.capacity} Ekor</>}
|
||||||
Kapasitas {kandang?.capacity} Ekor
|
className={{ badge: 'w-fit text-nowrap' }}
|
||||||
</Badge>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ApprovalStepsV2
|
<div className='-mx-4'>
|
||||||
approvals={projectFlockKandangApproval}
|
<ApprovalStepsV2
|
||||||
steps={APPROVAL_WORKFLOWS.PROJECT_FLOCK_KANDANGS}
|
approvals={projectFlockKandangApproval}
|
||||||
/>
|
steps={APPROVAL_WORKFLOWS.PROJECT_FLOCK_KANDANGS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-4 gap-3'>
|
<div
|
||||||
<RequirePermission permissions='lti.production.chickins.detail'>
|
className={`grid gap-3 ${
|
||||||
<Link
|
selectedKandang?.status !== 'NON_ACTIVE'
|
||||||
href={`/production/project-flock/chickin/add/kandang?projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}&projectFlockId=${projectFlock.id}`}
|
? 'grid-cols-2'
|
||||||
className='m-0 p-0'
|
: 'grid-cols-1'
|
||||||
>
|
}`}
|
||||||
<Button
|
>
|
||||||
className='w-full px-2 py-1 text-sm'
|
{selectedKandang?.status !== 'NON_ACTIVE' && (
|
||||||
variant='outline'
|
<RequirePermission permissions='lti.production.chickins.detail'>
|
||||||
color='success'
|
<Link
|
||||||
disabled={
|
href={`/production/project-flock/chickin/add/kandang?projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}&projectFlockId=${projectFlock.id}`}
|
||||||
!selectedKandangId ||
|
className='m-0 p-0'
|
||||||
projectFlock?.approval?.step_number == 1
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Chickin <Icon icon='mdi:checkbox-marked-outline' />
|
<Button
|
||||||
</Button>
|
className='w-full px-2 py-1 text-sm'
|
||||||
</Link>
|
variant='outline'
|
||||||
</RequirePermission>
|
color='success'
|
||||||
|
disabled={
|
||||||
|
!selectedKandangId ||
|
||||||
|
projectFlock?.approval?.step_number == 1
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Chickin <Icon icon='mdi:checkbox-marked-outline' />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</RequirePermission>
|
||||||
|
)}
|
||||||
<RequirePermission permissions='lti.production.project_flock_kandangs.closing.detail'>
|
<RequirePermission permissions='lti.production.project_flock_kandangs.closing.detail'>
|
||||||
<Link
|
<Link
|
||||||
href={`/production/project-flock/closing?projectFlockId=${projectFlock.id}&projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}`}
|
href={`/production/project-flock/closing?projectFlockId=${projectFlock.id}&projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}`}
|
||||||
@@ -406,7 +438,15 @@ const ProjectFlockDetail = ({
|
|||||||
projectFlock?.approval?.step_number == 1
|
projectFlock?.approval?.step_number == 1
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Close <Icon icon='mdi:checkbox-marked-circle-outline' />
|
{selectedKandang?.status === 'NON_ACTIVE' ? (
|
||||||
|
<>
|
||||||
|
Unclose <Icon icon='mdi:lock-open-variant' />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Close <Icon icon='mdi:checkbox-marked-circle-outline' />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import Table from '@/components/Table';
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import StatusBadge from '@/components/helper/StatusBadge';
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||||
import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store';
|
import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store';
|
||||||
|
|
||||||
interface ProjectFlockFormProps {
|
interface ProjectFlockFormProps {
|
||||||
formType?: 'add' | 'edit' | 'detail';
|
formType?: 'add' | 'edit' | 'detail';
|
||||||
|
|||||||
@@ -38,38 +38,26 @@ import { Color } from '@/types/theme';
|
|||||||
// ===== STATUS BADGE UTILITIES =====
|
// ===== STATUS BADGE UTILITIES =====
|
||||||
const statusTextMap: Record<string, string> = {
|
const statusTextMap: Record<string, string> = {
|
||||||
APPROVED: 'Disetujui',
|
APPROVED: 'Disetujui',
|
||||||
Disetujui: 'Disetujui',
|
|
||||||
REJECTED: 'Ditolak',
|
REJECTED: 'Ditolak',
|
||||||
Ditolak: 'Ditolak',
|
CREATED: 'Pengajuan',
|
||||||
CREATED: 'Dibuat',
|
|
||||||
UPDATED: 'Diperbarui',
|
UPDATED: 'Diperbarui',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (status: string): string => {
|
const getStatusText = (status: string): string => {
|
||||||
return statusTextMap[status] || status;
|
const normalizedStatus = status.toUpperCase();
|
||||||
|
return statusTextMap[normalizedStatus] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusBadgeColorMap: Record<string, Color> = {
|
const statusBadgeColorMap: Record<string, Color> = {
|
||||||
APPROVED: 'success',
|
APPROVED: 'success',
|
||||||
Disetujui: 'success',
|
|
||||||
approved: 'success',
|
|
||||||
disetujui: 'success',
|
|
||||||
REJECTED: 'error',
|
REJECTED: 'error',
|
||||||
Ditolak: 'error',
|
|
||||||
rejected: 'error',
|
|
||||||
ditolak: 'error',
|
|
||||||
CREATED: 'neutral',
|
CREATED: 'neutral',
|
||||||
Dibuat: 'neutral',
|
|
||||||
created: 'neutral',
|
|
||||||
dibuat: 'neutral',
|
|
||||||
UPDATED: 'warning',
|
UPDATED: 'warning',
|
||||||
Diperbarui: 'warning',
|
|
||||||
updated: 'warning',
|
|
||||||
diperbarui: 'warning',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadgeColor = (status: string): Color => {
|
const getStatusBadgeColor = (status: string): Color => {
|
||||||
return statusBadgeColorMap[status] || 'neutral';
|
const normalizedStatus = status.toUpperCase();
|
||||||
|
return statusBadgeColorMap[normalizedStatus] || 'neutral';
|
||||||
};
|
};
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
@@ -852,8 +840,7 @@ const RecordingTable = () => {
|
|||||||
|
|
||||||
const status = approval.action;
|
const status = approval.action;
|
||||||
const statusColor = getStatusBadgeColor(status);
|
const statusColor = getStatusBadgeColor(status);
|
||||||
|
const statusText = getStatusText(status);
|
||||||
const statusText = approval.step_name || getStatusText(status);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
|||||||
} = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', '', {
|
} = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', '', {
|
||||||
location_id: depletionProductsLocationId,
|
location_id: depletionProductsLocationId,
|
||||||
kandang_id: depletionProductsKandangId,
|
kandang_id: depletionProductsKandangId,
|
||||||
|
type: 'AYAM',
|
||||||
});
|
});
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
@@ -784,18 +785,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
|||||||
if (isResponseSuccess(depletionProductsData) && selectedKandang) {
|
if (isResponseSuccess(depletionProductsData) && selectedKandang) {
|
||||||
const data = depletionProductsData.data as unknown as ProductWarehouse[];
|
const data = depletionProductsData.data as unknown as ProductWarehouse[];
|
||||||
data.forEach((product) => {
|
data.forEach((product) => {
|
||||||
const productName = product.product.name;
|
options.push({
|
||||||
|
value: product.id,
|
||||||
if (
|
label: product.product.name,
|
||||||
productName.toLowerCase().includes('culling') ||
|
});
|
||||||
productName.toLowerCase().includes('mati') ||
|
|
||||||
productName.toLowerCase().includes('afkir')
|
|
||||||
) {
|
|
||||||
options.push({
|
|
||||||
value: product.id,
|
|
||||||
label: product.product.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import UniformityTableSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityTableSkeleton';
|
import UniformityTableSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityTableSkeleton';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
|
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
|
||||||
import FloatingActionsButton from '@/components/FloatingActionsButton';
|
import FloatingActionsButton from '@/components/FloatingActionsButton';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import SelectInput, {
|
import SelectInput, {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Icon } from '@iconify/react';
|
|||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||||
import { useUiStore } from '@/stores/ui/ui.store';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
|
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import DateInput from '@/components/input/DateInput';
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Button from '@/components/Button';
|
|||||||
import Tooltip from '@/components/Tooltip';
|
import Tooltip from '@/components/Tooltip';
|
||||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||||
import { useUiStore } from '@/stores/ui/ui.store';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
|
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Button from '@/components/Button';
|
|||||||
import Tooltip from '@/components/Tooltip';
|
import Tooltip from '@/components/Tooltip';
|
||||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||||
import { useUiStore } from '@/stores/ui/ui.store';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
|
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|||||||
@@ -1,901 +0,0 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
|
||||||
import { ChangeEventHandler } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
import DateInput from '@/components/input/DateInput';
|
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
|
||||||
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
|
||||||
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
|
||||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
|
||||||
import { ReportExpense } from '@/types/api/report/report-expense';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { ReportExpenseApi } from '@/services/api/report';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
|
||||||
import Pagination from '@/components/Pagination';
|
|
||||||
import Dropdown from '@/components/dropdown/Dropdown';
|
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
|
||||||
import * as XLSX from 'xlsx';
|
|
||||||
import { generateReportExpensePDF } from './pdf/ReportExpenseExport';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import {
|
|
||||||
KandangApi,
|
|
||||||
LocationApi,
|
|
||||||
NonstockApi,
|
|
||||||
SupplierApi,
|
|
||||||
} from '@/services/api/master-data';
|
|
||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
|
||||||
import { Kandang } from '@/types/api/master-data/kandang';
|
|
||||||
import { Nonstock } from '@/types/api/master-data/nonstock';
|
|
||||||
|
|
||||||
const ReportExpenseTable = () => {
|
|
||||||
// ===== STATE MANAGEMENT =====
|
|
||||||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
|
||||||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
const [pdfProgress, setPdfProgress] = useState(0);
|
|
||||||
const [excelProgress, setExcelProgress] = useState(0);
|
|
||||||
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
|
||||||
|
|
||||||
// ===== SUBMISSION STATE =====
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
||||||
|
|
||||||
// ===== TABLE FILTER STATE =====
|
|
||||||
const {
|
|
||||||
state: filterState,
|
|
||||||
updateFilter,
|
|
||||||
setPage,
|
|
||||||
setPageSize,
|
|
||||||
reset: resetFilterState,
|
|
||||||
toQueryString,
|
|
||||||
} = useTableFilter({
|
|
||||||
initial: {
|
|
||||||
location_id: '',
|
|
||||||
supplier_id: '',
|
|
||||||
kandang_id: '',
|
|
||||||
nonstock_id: '',
|
|
||||||
realization_date: '',
|
|
||||||
category: '',
|
|
||||||
search: '',
|
|
||||||
},
|
|
||||||
paramMap: {
|
|
||||||
page: 'page',
|
|
||||||
pageSize: 'limit',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== SELECT OPTIONS =====
|
|
||||||
const {
|
|
||||||
setInputValue: setLocationInputValue,
|
|
||||||
options: locationOptions,
|
|
||||||
isLoadingOptions: isLoadingLocationOptions,
|
|
||||||
loadMore: loadMoreLocations,
|
|
||||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setSupplierInputValue,
|
|
||||||
options: supplierOptions,
|
|
||||||
isLoadingOptions: isLoadingSupplierOptions,
|
|
||||||
loadMore: loadMoreSuppliers,
|
|
||||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setKandangInputValue,
|
|
||||||
options: kandangOptions,
|
|
||||||
isLoadingOptions: isLoadingKandangOptions,
|
|
||||||
loadMore: loadMoreKandangs,
|
|
||||||
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setNonstockInputValue,
|
|
||||||
options: nonstockOptions,
|
|
||||||
isLoadingOptions: isLoadingNonstockOptions,
|
|
||||||
loadMore: loadMoreNonstocks,
|
|
||||||
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const categoryOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{ value: 'BOP', label: 'BOP' },
|
|
||||||
{ value: 'NON-BOP', label: 'Non BOP' },
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mendapatkan value option select dari filter state
|
|
||||||
const selectedLocation = useMemo(
|
|
||||||
() =>
|
|
||||||
locationOptions.find(
|
|
||||||
(opt) => String(opt.value) === filterState.location_id
|
|
||||||
) || null,
|
|
||||||
[locationOptions, filterState.location_id]
|
|
||||||
);
|
|
||||||
const selectedSupplier = useMemo(
|
|
||||||
() =>
|
|
||||||
supplierOptions.find(
|
|
||||||
(opt) => String(opt.value) === filterState.supplier_id
|
|
||||||
) || null,
|
|
||||||
[supplierOptions, filterState.supplier_id]
|
|
||||||
);
|
|
||||||
const selectedKandang = useMemo(
|
|
||||||
() =>
|
|
||||||
kandangOptions.find(
|
|
||||||
(opt) => String(opt.value) === filterState.kandang_id
|
|
||||||
) || null,
|
|
||||||
[kandangOptions, filterState.kandang_id]
|
|
||||||
);
|
|
||||||
const selectedNonstock = useMemo(
|
|
||||||
() =>
|
|
||||||
nonstockOptions.find(
|
|
||||||
(opt) => String(opt.value) === filterState.nonstock_id
|
|
||||||
) || null,
|
|
||||||
[nonstockOptions, filterState.nonstock_id]
|
|
||||||
);
|
|
||||||
const selectedCategory = useMemo(
|
|
||||||
() =>
|
|
||||||
categoryOptions.find((opt) => opt.value === filterState.category) || null,
|
|
||||||
[categoryOptions, filterState.category]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== FILTER CHANGE HANDLERS =====
|
|
||||||
const locationChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('location_id', option ? String(option.value) : '');
|
|
||||||
updateFilter('kandang_id', '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const kandangChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('kandang_id', option ? String(option.value) : '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const supplierChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('supplier_id', option ? String(option.value) : '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const nonstockChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('nonstock_id', option ? String(option.value) : '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const categoryChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('category', option ? String(option.value) : '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const realizationDateChangeHandler = useCallback<
|
|
||||||
ChangeEventHandler<HTMLInputElement>
|
|
||||||
>(
|
|
||||||
(e) => {
|
|
||||||
updateFilter('realization_date', e.target.value || '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchChangeHandler = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
updateFilter('search', e.target.value);
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== RESET FILTERS =====
|
|
||||||
const resetFilters = useCallback(() => {
|
|
||||||
resetFilterState();
|
|
||||||
setIsSubmitted(false);
|
|
||||||
}, [resetFilterState]);
|
|
||||||
|
|
||||||
// ===== SUBMIT HANDLER =====
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setPage(1);
|
|
||||||
}, [setPage]);
|
|
||||||
|
|
||||||
// ===== DATA FETCHING FOR TABLE =====
|
|
||||||
const { data: reportExpenseResponse, isLoading } = useSWR(
|
|
||||||
isSubmitted
|
|
||||||
? () => {
|
|
||||||
return ['report-expense', toQueryString()];
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
([, query]) => {
|
|
||||||
const endpoint = `${ReportExpenseApi.basePath}${query}`;
|
|
||||||
return ReportExpenseApi.getAllFetcher(endpoint);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data: ReportExpense[] = useMemo(
|
|
||||||
() =>
|
|
||||||
isResponseSuccess(reportExpenseResponse)
|
|
||||||
? (reportExpenseResponse?.data as ReportExpense[]) || []
|
|
||||||
: [],
|
|
||||||
[reportExpenseResponse]
|
|
||||||
);
|
|
||||||
|
|
||||||
const meta = useMemo(
|
|
||||||
() =>
|
|
||||||
isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta
|
|
||||||
? reportExpenseResponse.meta
|
|
||||||
: null,
|
|
||||||
[reportExpenseResponse]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== EXPORT DATA FETCHER =====
|
|
||||||
const reportExpenseExport = useCallback(async (): Promise<
|
|
||||||
ReportExpense[] | null
|
|
||||||
> => {
|
|
||||||
const params = new URLSearchParams(toQueryString().replace('?', ''));
|
|
||||||
params.set('limit', 'limit');
|
|
||||||
params.set('page', '1');
|
|
||||||
|
|
||||||
const endpoint = `${ReportExpenseApi.basePath}?${params.toString()}`;
|
|
||||||
const response = await ReportExpenseApi.getAllFetcher(endpoint);
|
|
||||||
|
|
||||||
return isResponseSuccess(response) ? response.data : null;
|
|
||||||
}, [toQueryString]);
|
|
||||||
|
|
||||||
// ===== EXPORT HANDLERS =====
|
|
||||||
const handleExportPdf = useCallback(async () => {
|
|
||||||
if (isPdfExportLoading) return;
|
|
||||||
setIsPdfExportLoading(true);
|
|
||||||
setPdfProgress(0);
|
|
||||||
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
requestAnimationFrame(() => resolve(undefined))
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Stage 1: Fetching data (0-20%)
|
|
||||||
setPdfProgress(10);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
const allData = await reportExpenseExport();
|
|
||||||
if (!allData || allData.length === 0) {
|
|
||||||
toast.error('Tidak ada data untuk diekspor.');
|
|
||||||
setIsPdfExportLoading(false);
|
|
||||||
setPdfProgress(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 2: Data fetched - langsung loncat ke progress tinggi
|
|
||||||
setPdfProgress(30);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
setPdfProgress((prev) => {
|
|
||||||
// Increment kecil dan random antara 0.5-2%
|
|
||||||
const increment = Math.random() * 1.5 + 0.5;
|
|
||||||
const newProgress = Math.min(prev + increment, 50);
|
|
||||||
return newProgress;
|
|
||||||
});
|
|
||||||
}, 300); // Update setiap 300ms
|
|
||||||
|
|
||||||
const pdfParams = {
|
|
||||||
location_name: selectedLocation?.label,
|
|
||||||
supplier_name: selectedSupplier?.label,
|
|
||||||
kandang_name: selectedKandang?.label,
|
|
||||||
nonstock_name: selectedNonstock?.label,
|
|
||||||
category: selectedCategory?.label,
|
|
||||||
realization_date: filterState.realization_date,
|
|
||||||
search: filterState.search,
|
|
||||||
};
|
|
||||||
|
|
||||||
setDropdownOpen(false);
|
|
||||||
|
|
||||||
// Stage 3: Langsung loncat ke 80-85% untuk menghindari stuck
|
|
||||||
const baseProgress = 80 + Math.floor(Math.random() * 16); // Random 80-85%
|
|
||||||
setPdfProgress(baseProgress);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
// Stage 4: Berikan jeda untuk UI update
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
requestAnimationFrame(() => resolve(undefined))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Proses PDF yang sebenarnya
|
|
||||||
await generateReportExpensePDF(allData, pdfParams);
|
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
|
|
||||||
// Stage 5: Finalizing (98-100%)
|
|
||||||
setPdfProgress(99);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
setPdfProgress(100);
|
|
||||||
toast.success('PDF berhasil dibuat dan diunduh.');
|
|
||||||
|
|
||||||
// Reset progress setelah selesai
|
|
||||||
setTimeout(() => setPdfProgress(0), 500);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('PDF Export Error:', error);
|
|
||||||
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
|
||||||
setPdfProgress(0);
|
|
||||||
} finally {
|
|
||||||
setIsPdfExportLoading(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
reportExpenseExport,
|
|
||||||
selectedLocation,
|
|
||||||
selectedSupplier,
|
|
||||||
selectedKandang,
|
|
||||||
selectedNonstock,
|
|
||||||
selectedCategory,
|
|
||||||
filterState.realization_date,
|
|
||||||
filterState.search,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleExportExcel = useCallback(async () => {
|
|
||||||
if (isExcelExportLoading) return;
|
|
||||||
setIsExcelExportLoading(true);
|
|
||||||
setExcelProgress(0);
|
|
||||||
setDropdownOpen(false);
|
|
||||||
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
requestAnimationFrame(() => resolve(undefined))
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Stage 1: Fetching data (0-20%)
|
|
||||||
setExcelProgress(15);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
const allDataForExport = await reportExpenseExport();
|
|
||||||
|
|
||||||
if (!allDataForExport || allDataForExport.length === 0) {
|
|
||||||
toast.error('Tidak ada data untuk diekspor.');
|
|
||||||
setIsExcelExportLoading(false);
|
|
||||||
setExcelProgress(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 2: Data fetched (20-40%)
|
|
||||||
setExcelProgress(30);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
// Stage 3: Grouping data (40-60%)
|
|
||||||
setExcelProgress(50);
|
|
||||||
const groupedBySupplier: Record<string, ReportExpense[]> = {};
|
|
||||||
allDataForExport.forEach((item) => {
|
|
||||||
const supplierName = item.supplier?.name || 'Unknown Supplier';
|
|
||||||
if (!groupedBySupplier[supplierName]) {
|
|
||||||
groupedBySupplier[supplierName] = [];
|
|
||||||
}
|
|
||||||
groupedBySupplier[supplierName].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
// Stage 4: Creating workbook (60-80%)
|
|
||||||
setExcelProgress(70);
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
|
|
||||||
const supplierEntries = Object.entries(groupedBySupplier);
|
|
||||||
const totalSuppliers = supplierEntries.length;
|
|
||||||
|
|
||||||
for (let i = 0; i < supplierEntries.length; i++) {
|
|
||||||
const [supplierName, supplierData] = supplierEntries[i];
|
|
||||||
|
|
||||||
// Update progress per supplier
|
|
||||||
const progressIncrement = (20 / totalSuppliers) * (i + 1);
|
|
||||||
setExcelProgress(70 + progressIncrement);
|
|
||||||
|
|
||||||
const totals = supplierData.reduce(
|
|
||||||
(acc, item) => ({
|
|
||||||
qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0),
|
|
||||||
total_pengajuan:
|
|
||||||
acc.total_pengajuan +
|
|
||||||
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
|
|
||||||
qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0),
|
|
||||||
total_realisasi:
|
|
||||||
acc.total_realisasi +
|
|
||||||
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
qty_pengajuan: 0,
|
|
||||||
total_pengajuan: 0,
|
|
||||||
qty_realisasi: 0,
|
|
||||||
total_realisasi: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const excelData = supplierData.map((item, index) => ({
|
|
||||||
No: index + 1,
|
|
||||||
'No. PO': item.po_number || '',
|
|
||||||
'No. Referensi': item.reference_number || '',
|
|
||||||
'Tanggal Realisasi': item.realization_date
|
|
||||||
? formatDate(item.realization_date, 'DD MMM YYYY')
|
|
||||||
: '',
|
|
||||||
'Tanggal Transaksi': item.transaction_date
|
|
||||||
? formatDate(item.transaction_date, 'DD MMM YYYY')
|
|
||||||
: '',
|
|
||||||
Kategori: item.category || '',
|
|
||||||
Produk: item.pengajuan?.nonstock?.name || '',
|
|
||||||
Lokasi: item.kandang?.location?.name || '',
|
|
||||||
Kandang: item.kandang?.name || '',
|
|
||||||
'Qty Pengajuan': item.pengajuan?.qty || 0,
|
|
||||||
'Harga Pengajuan': item.pengajuan?.price || 0,
|
|
||||||
'Total Pengajuan':
|
|
||||||
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
|
|
||||||
'Qty Realisasi': item.realisasi?.qty || 0,
|
|
||||||
'Harga Realisasi': item.realisasi?.price || 0,
|
|
||||||
'Total Realisasi':
|
|
||||||
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
|
|
||||||
'Status Pencairan': item.latest_approval?.step_name || '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
excelData.push({
|
|
||||||
No: 'Total' as unknown as number,
|
|
||||||
'No. PO': '',
|
|
||||||
'No. Referensi': '',
|
|
||||||
'Tanggal Realisasi': '',
|
|
||||||
'Tanggal Transaksi': '',
|
|
||||||
Kategori: '',
|
|
||||||
Produk: '',
|
|
||||||
Lokasi: '',
|
|
||||||
Kandang: '',
|
|
||||||
'Qty Pengajuan': totals.qty_pengajuan,
|
|
||||||
'Harga Pengajuan': 0,
|
|
||||||
'Total Pengajuan': totals.total_pengajuan,
|
|
||||||
'Qty Realisasi': totals.qty_realisasi,
|
|
||||||
'Harga Realisasi': 0,
|
|
||||||
'Total Realisasi': totals.total_realisasi,
|
|
||||||
'Status Pencairan': '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
|
||||||
const colWidths = [
|
|
||||||
{ wch: 5 }, // No
|
|
||||||
{ wch: 20 }, // No. PO
|
|
||||||
{ wch: 20 }, // No. Referensi
|
|
||||||
{ wch: 15 }, // Tanggal Realisasi
|
|
||||||
{ wch: 15 }, // Tanggal Transaksi
|
|
||||||
{ wch: 15 }, // Kategori
|
|
||||||
{ wch: 30 }, // Produk
|
|
||||||
{ wch: 20 }, // Lokasi
|
|
||||||
{ wch: 15 }, // Kandang
|
|
||||||
{ wch: 15 }, // Qty Pengajuan
|
|
||||||
{ wch: 15 }, // Harga Pengajuan
|
|
||||||
{ wch: 20 }, // Total Pengajuan
|
|
||||||
{ wch: 15 }, // Qty Realisasi
|
|
||||||
{ wch: 15 }, // Harga Realisasi
|
|
||||||
{ wch: 20 }, // Total Realisasi
|
|
||||||
{ wch: 20 }, // Status Pencairan
|
|
||||||
];
|
|
||||||
worksheet['!cols'] = colWidths;
|
|
||||||
|
|
||||||
const sheetName = supplierName.slice(0, 31);
|
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
|
||||||
|
|
||||||
// Small delay to allow UI update
|
|
||||||
if (i < supplierEntries.length - 1) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 5: Writing file (90-100%)
|
|
||||||
setExcelProgress(95);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
|
|
||||||
XLSX.writeFile(workbook, filename);
|
|
||||||
|
|
||||||
setExcelProgress(100);
|
|
||||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
|
||||||
|
|
||||||
// Reset progress
|
|
||||||
setTimeout(() => setExcelProgress(0), 500);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Excel Export Error:', error);
|
|
||||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
|
||||||
setExcelProgress(0);
|
|
||||||
} finally {
|
|
||||||
setIsExcelExportLoading(false);
|
|
||||||
}
|
|
||||||
}, [isExcelExportLoading, reportExpenseExport]);
|
|
||||||
|
|
||||||
// ===== PAGINATION HANDLERS =====
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowChange = (pageSize: number) => {
|
|
||||||
setPageSize(pageSize);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextPage = () => {
|
|
||||||
if (meta && filterState.page < meta.total_pages) {
|
|
||||||
setPage(filterState.page + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
|
||||||
if (filterState.page > 1) {
|
|
||||||
setPage(filterState.page - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== TABLE COLUMNS DEFINITION =====
|
|
||||||
const columns = useMemo((): ColumnDef<ReportExpense>[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: 'No',
|
|
||||||
accessorFn: (_, index) =>
|
|
||||||
(filterState.page - 1) * filterState.pageSize + index + 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'No. PO',
|
|
||||||
accessorKey: 'po_number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'No. Referensi',
|
|
||||||
accessorKey: 'reference_number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Tanggal Realisasi',
|
|
||||||
accessorKey: 'realization_date',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Tanggal Transaksi',
|
|
||||||
accessorKey: 'transaction_date',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Kategori',
|
|
||||||
accessorKey: 'category',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Produk',
|
|
||||||
accessorFn: (row) => row.pengajuan?.nonstock?.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Supplier',
|
|
||||||
accessorFn: (row) => row.supplier?.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Lokasi',
|
|
||||||
accessorFn: (row) => row.kandang?.location?.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Kandang',
|
|
||||||
accessorFn: (row) => row.kandang?.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Pengajuan',
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: 'Qty',
|
|
||||||
id: 'qty_pengajuan',
|
|
||||||
accessorFn: (row) => row.pengajuan?.qty,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Harga',
|
|
||||||
id: 'harga_pengajuan',
|
|
||||||
accessorFn: (row) => row.pengajuan?.price,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
formatCurrency(row.original.pengajuan?.price || 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Total',
|
|
||||||
id: 'total_pengajuan',
|
|
||||||
accessorFn: (row) =>
|
|
||||||
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const total =
|
|
||||||
(row.original.pengajuan?.qty || 0) *
|
|
||||||
(row.original.pengajuan?.price || 0);
|
|
||||||
return formatCurrency(total);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Realisasi',
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: 'Qty',
|
|
||||||
id: 'qty_realisasi',
|
|
||||||
accessorFn: (row) => row.realisasi?.qty,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Harga',
|
|
||||||
id: 'harga_realisasi',
|
|
||||||
accessorFn: (row) => row.realisasi?.price,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
formatCurrency(row.original.realisasi?.price || 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Total',
|
|
||||||
id: 'total_realisasi',
|
|
||||||
accessorFn: (row) =>
|
|
||||||
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const total =
|
|
||||||
(row.original.realisasi?.qty || 0) *
|
|
||||||
(row.original.realisasi?.price || 0);
|
|
||||||
return formatCurrency(total);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Status Pencairan',
|
|
||||||
cell: (props) => (
|
|
||||||
<RealizationStatusBadge
|
|
||||||
approval={props.row.original?.latest_approval}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Status BOP',
|
|
||||||
cell: (props) => (
|
|
||||||
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [filterState.page, filterState.pageSize]);
|
|
||||||
|
|
||||||
// ===== RENDER =====
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
{isAnyExportLoading && (
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<progress
|
|
||||||
className='progress progress-primary w-full'
|
|
||||||
value={
|
|
||||||
isPdfExportLoading
|
|
||||||
? pdfProgress
|
|
||||||
: isExcelExportLoading
|
|
||||||
? excelProgress
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
max='100'
|
|
||||||
></progress>
|
|
||||||
{((isPdfExportLoading && pdfProgress > 0) ||
|
|
||||||
(isExcelExportLoading && excelProgress > 0)) && (
|
|
||||||
<div className='text-sm text-center text-gray-600'>
|
|
||||||
<div className='font-semibold'>
|
|
||||||
{(() => {
|
|
||||||
const currentProgress = isPdfExportLoading
|
|
||||||
? pdfProgress
|
|
||||||
: excelProgress;
|
|
||||||
const exportType = isPdfExportLoading ? 'PDF' : 'Excel';
|
|
||||||
|
|
||||||
if (currentProgress < 20)
|
|
||||||
return 'Mengambil data dari server...';
|
|
||||||
if (currentProgress < 30) return 'Memproses data laporan...';
|
|
||||||
if (currentProgress < 40)
|
|
||||||
return `Menyiapkan struktur dokumen ${exportType}...`;
|
|
||||||
if (currentProgress < 50)
|
|
||||||
return 'Mengelompokkan data per supplier...';
|
|
||||||
if (currentProgress < 70)
|
|
||||||
return 'Merender tabel dan kalkulasi...';
|
|
||||||
if (currentProgress < 96)
|
|
||||||
return `Memformat dokumen ${exportType}...`;
|
|
||||||
if (currentProgress < 100)
|
|
||||||
return 'Menyelesaikan dan mengunduh...';
|
|
||||||
return 'Selesai!';
|
|
||||||
})()}{' '}
|
|
||||||
{Math.round(isPdfExportLoading ? pdfProgress : excelProgress)}%
|
|
||||||
</div>
|
|
||||||
{((isPdfExportLoading && pdfProgress >= 35 && pdfProgress < 90) ||
|
|
||||||
(isExcelExportLoading &&
|
|
||||||
excelProgress >= 35 &&
|
|
||||||
excelProgress < 90)) && (
|
|
||||||
<div className='text-xs text-gray-500 mt-1'>
|
|
||||||
{(isPdfExportLoading ? pdfProgress : excelProgress) < 96
|
|
||||||
? 'Proses ini membutuhkan waktu lebih lama untuk data dalam jumlah besar. Mohon bersabar...'
|
|
||||||
: 'Sedang memproses baris data. Hampir selesai...'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Card
|
|
||||||
title='Laporan Biaya Operasional'
|
|
||||||
variant='bordered'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
footer={
|
|
||||||
<div className='flex flex-col gap-6'>
|
|
||||||
<div className='flex flex-row items-center justify-end gap-2'>
|
|
||||||
<div className='flex flex-row items-center gap-2'>
|
|
||||||
<Button className='min-w-24' onClick={handleSubmit}>
|
|
||||||
Cari
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className='min-w-24'
|
|
||||||
color='warning'
|
|
||||||
onClick={resetFilters}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Dropdown
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
color='success'
|
|
||||||
className='min-w-24'
|
|
||||||
isLoading={isAnyExportLoading}
|
|
||||||
onClick={() => {
|
|
||||||
setDropdownOpen(!dropdownOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
align='end'
|
|
||||||
direction='bottom'
|
|
||||||
open={dropdownOpen}
|
|
||||||
>
|
|
||||||
<Menu className='w-32'>
|
|
||||||
<MenuItem title='Excel' onClick={handleExportExcel} />
|
|
||||||
<MenuItem title='PDF' onClick={handleExportPdf} />
|
|
||||||
</Menu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Lokasi'
|
|
||||||
options={locationOptions}
|
|
||||||
isLoading={isLoadingLocationOptions}
|
|
||||||
placeholder='Lokasi'
|
|
||||||
value={selectedLocation}
|
|
||||||
onChange={locationChangeHandler}
|
|
||||||
onInputChange={setLocationInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreLocations}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Kandang'
|
|
||||||
options={kandangOptions}
|
|
||||||
isLoading={isLoadingKandangOptions}
|
|
||||||
placeholder='Kandang'
|
|
||||||
value={selectedKandang}
|
|
||||||
onChange={kandangChangeHandler}
|
|
||||||
onInputChange={setKandangInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreKandangs}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Supplier'
|
|
||||||
options={supplierOptions}
|
|
||||||
isLoading={isLoadingSupplierOptions}
|
|
||||||
placeholder='Supplier'
|
|
||||||
value={selectedSupplier}
|
|
||||||
onChange={supplierChangeHandler}
|
|
||||||
onInputChange={setSupplierInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreSuppliers}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Produk'
|
|
||||||
options={nonstockOptions}
|
|
||||||
isLoading={isLoadingNonstockOptions}
|
|
||||||
placeholder='Produk'
|
|
||||||
value={selectedNonstock}
|
|
||||||
onChange={nonstockChangeHandler}
|
|
||||||
onInputChange={setNonstockInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreNonstocks}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Kategori'
|
|
||||||
options={categoryOptions}
|
|
||||||
placeholder='Kategori'
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={categoryChangeHandler}
|
|
||||||
/>
|
|
||||||
<DateInput
|
|
||||||
label='Tanggal Realisasi'
|
|
||||||
value={filterState.realization_date}
|
|
||||||
onChange={realizationDateChangeHandler}
|
|
||||||
name='realization_date'
|
|
||||||
placeholder='Tanggal Realisasi'
|
|
||||||
/>
|
|
||||||
<DebouncedTextInput
|
|
||||||
label='Cari'
|
|
||||||
name='search'
|
|
||||||
value={filterState.search}
|
|
||||||
onChange={searchChangeHandler}
|
|
||||||
placeholder='Cari'
|
|
||||||
startAdornment={<Icon icon='mdi:magnify' width={24} height={24} />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ===== TABLE CONTENT ===== */}
|
|
||||||
{!isSubmitted ? (
|
|
||||||
<div className='mt-6 text-center text-gray-500'>
|
|
||||||
Silakan pilih filter dan klik tombol Cari 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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table<ReportExpense>
|
|
||||||
columns={columns}
|
|
||||||
data={data}
|
|
||||||
pageSize={10}
|
|
||||||
className={{
|
|
||||||
containerClassName: 'mb-0',
|
|
||||||
headerRowClassName: cn(
|
|
||||||
TABLE_DEFAULT_STYLING,
|
|
||||||
'whitespace-nowrap'
|
|
||||||
),
|
|
||||||
bodyRowClassName: cn(TABLE_DEFAULT_STYLING, 'whitespace-nowrap'),
|
|
||||||
paginationClassName: 'hidden',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{meta && (
|
|
||||||
<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 ReportExpenseTable;
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Tabs from '@/components/Tabs';
|
||||||
|
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
import ReportExpenseTab from './tab/ReportExpenseTab';
|
||||||
|
|
||||||
|
const ReportExpenseTabs = () => {
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Laporan Biaya Operasional',
|
||||||
|
content: <ReportExpenseTab tabId={'1'} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className='w-full'>
|
||||||
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
variant='boxed'
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onTabChange={setActiveTabId}
|
||||||
|
className={{
|
||||||
|
tabHeaderWrapper:
|
||||||
|
'justify-between items-center p-3 border-b border-base-content/10',
|
||||||
|
tab: 'w-fit',
|
||||||
|
content: 'p-0 m-0',
|
||||||
|
}}
|
||||||
|
sideContent={tabActions[activeTabId] || null}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportExpenseTabs;
|
||||||
+1
@@ -2,6 +2,7 @@ import { ReportExpense } from '@/types/api/report/report-expense';
|
|||||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
import autoTable, { UserOptions } from 'jspdf-autotable';
|
import autoTable, { UserOptions } from 'jspdf-autotable';
|
||||||
|
|
||||||
interface jsPDFWithAutoTable extends jsPDF {
|
interface jsPDFWithAutoTable extends jsPDF {
|
||||||
lastAutoTable: {
|
lastAutoTable: {
|
||||||
finalY: number;
|
finalY: number;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||||
|
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||||
|
|
||||||
|
export const generateReportExpenseExcel = async (
|
||||||
|
data: ReportExpense[]
|
||||||
|
): Promise<void> => {
|
||||||
|
// Group by supplier
|
||||||
|
const groupedBySupplier: Record<string, ReportExpense[]> = {};
|
||||||
|
data.forEach((item) => {
|
||||||
|
const supplierName = item.supplier?.name || 'Unknown Supplier';
|
||||||
|
if (!groupedBySupplier[supplierName]) {
|
||||||
|
groupedBySupplier[supplierName] = [];
|
||||||
|
}
|
||||||
|
groupedBySupplier[supplierName].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
Object.entries(groupedBySupplier).forEach(([supplierName, supplierData]) => {
|
||||||
|
const totals = supplierData.reduce(
|
||||||
|
(acc, item) => ({
|
||||||
|
qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0),
|
||||||
|
total_pengajuan:
|
||||||
|
acc.total_pengajuan +
|
||||||
|
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
|
||||||
|
qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0),
|
||||||
|
total_realisasi:
|
||||||
|
acc.total_realisasi +
|
||||||
|
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
qty_pengajuan: 0,
|
||||||
|
total_pengajuan: 0,
|
||||||
|
qty_realisasi: 0,
|
||||||
|
total_realisasi: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const excelData = supplierData.map((item, index) => ({
|
||||||
|
No: index + 1,
|
||||||
|
'No. PO': item.po_number || '',
|
||||||
|
'No. Referensi': item.reference_number || '',
|
||||||
|
'Tanggal Realisasi': item.realization_date
|
||||||
|
? formatDate(item.realization_date, 'DD MMM YYYY')
|
||||||
|
: '',
|
||||||
|
'Tanggal Transaksi': item.transaction_date
|
||||||
|
? formatDate(item.transaction_date, 'DD MMM YYYY')
|
||||||
|
: '',
|
||||||
|
Kategori: item.category || '',
|
||||||
|
Produk: item.pengajuan?.nonstock?.name || '',
|
||||||
|
Lokasi: item.kandang?.location?.name || '',
|
||||||
|
Kandang: item.kandang?.name || '',
|
||||||
|
'Qty Pengajuan': item.pengajuan?.qty || 0,
|
||||||
|
'Harga Pengajuan': item.pengajuan?.price || 0,
|
||||||
|
'Total Pengajuan':
|
||||||
|
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
|
||||||
|
'Qty Realisasi': item.realisasi?.qty || 0,
|
||||||
|
'Harga Realisasi': item.realisasi?.price || 0,
|
||||||
|
'Total Realisasi':
|
||||||
|
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
|
||||||
|
'Status Pencairan': item.latest_approval?.step_name || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
excelData.push({
|
||||||
|
No: 'Total' as unknown as number,
|
||||||
|
'No. PO': '',
|
||||||
|
'No. Referensi': '',
|
||||||
|
'Tanggal Realisasi': '',
|
||||||
|
'Tanggal Transaksi': '',
|
||||||
|
Kategori: '',
|
||||||
|
Produk: '',
|
||||||
|
Lokasi: '',
|
||||||
|
Kandang: '',
|
||||||
|
'Qty Pengajuan': totals.qty_pengajuan,
|
||||||
|
'Harga Pengajuan': 0,
|
||||||
|
'Total Pengajuan': totals.total_pengajuan,
|
||||||
|
'Qty Realisasi': totals.qty_realisasi,
|
||||||
|
'Harga Realisasi': 0,
|
||||||
|
'Total Realisasi': totals.total_realisasi,
|
||||||
|
'Status Pencairan': '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
||||||
|
const colWidths = [
|
||||||
|
{ wch: 5 },
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 30 },
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 20 },
|
||||||
|
];
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
const sheetName = supplierName.slice(0, 31);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
|
||||||
|
XLSX.writeFile(workbook, filename);
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type ReportExpenseFilterProps = {
|
||||||
|
location_id: string | null;
|
||||||
|
supplier_id: string | null;
|
||||||
|
kandang_id: string | null;
|
||||||
|
nonstock_id: string | null;
|
||||||
|
realization_date: string | null;
|
||||||
|
category: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportExpenseFilterFormType = {
|
||||||
|
location_id: OptionType | null;
|
||||||
|
supplier_id: OptionType | null;
|
||||||
|
kandang_id: OptionType | null;
|
||||||
|
nonstock_id: OptionType | null;
|
||||||
|
realization_date: string | null;
|
||||||
|
category: OptionType | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportExpenseFilterSchema = yup.object({
|
||||||
|
location_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Lokasi wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
supplier_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Supplier wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
kandang_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Kandang wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
nonstock_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Produk wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
realization_date: yup.string().nullable(),
|
||||||
|
category: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Kategori wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
}) as yup.ObjectSchema<ReportExpenseFilterFormType>;
|
||||||
|
|
||||||
|
export type ReportExpenseFilterValues = yup.InferType<
|
||||||
|
typeof ReportExpenseFilterSchema
|
||||||
|
>;
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
import { StyleSheet } from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
const pdfStyles = StyleSheet.create({
|
|
||||||
page: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontFamily: 'Helvetica',
|
|
||||||
padding: 20,
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
logo: {
|
|
||||||
width: 120,
|
|
||||||
height: 30,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
companyInfo: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: 4,
|
|
||||||
color: '#1f74bf',
|
|
||||||
},
|
|
||||||
address: {
|
|
||||||
fontSize: 7,
|
|
||||||
color: '#666666',
|
|
||||||
maxWidth: 400,
|
|
||||||
marginBottom: 10,
|
|
||||||
},
|
|
||||||
divider: {
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
marginBottom: 15,
|
|
||||||
},
|
|
||||||
titleSection: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
marginBottom: 20,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
flex: 3,
|
|
||||||
color: '#1f74bf',
|
|
||||||
},
|
|
||||||
poInfo: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
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: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellHeader: {
|
|
||||||
flex: 1,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellHeaderLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellRight: {
|
|
||||||
flex: 1,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableCellRightLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableCellNarrow: {
|
|
||||||
width: '1%',
|
|
||||||
minWidth: 20,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
tableCellNarrowHeader: {
|
|
||||||
width: '1%',
|
|
||||||
minWidth: 20,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
tableCellWrap: {
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: 80,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
},
|
|
||||||
tableCellWrapHeader: {
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: 80,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
// Nested header styles
|
|
||||||
tableHeaderGroup: {
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableHeaderGroupLast: {
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableHeaderGroupTitle: {
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'center',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
},
|
|
||||||
tableSubHeaderRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
|
||||||
// Specific width columns
|
|
||||||
tableCellXSmall: {
|
|
||||||
width: 30,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellXSmallHeader: {
|
|
||||||
width: 30,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellSmall: {
|
|
||||||
width: 40,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellSmallHeader: {
|
|
||||||
width: 40,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellMedium: {
|
|
||||||
width: 60,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellMediumHeader: {
|
|
||||||
width: 60,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellRightXSmall: {
|
|
||||||
width: 30,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableCellRightSmall: {
|
|
||||||
width: 40,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableCellRightMedium: {
|
|
||||||
width: 60,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableBorderBottom: {
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
},
|
|
||||||
grandTotalRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: '#000000',
|
|
||||||
borderTopStyle: 'solid',
|
|
||||||
},
|
|
||||||
grandTotalLabel: {
|
|
||||||
flex: 3,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'right',
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
},
|
|
||||||
grandTotalValue: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'right',
|
|
||||||
borderRightWidth: 0,
|
|
||||||
},
|
|
||||||
allocationSection: {
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
allocationTable: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#000000',
|
|
||||||
},
|
|
||||||
innerTable: {
|
|
||||||
marginTop: 5,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#000000',
|
|
||||||
},
|
|
||||||
innerRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
},
|
|
||||||
innerCell: {
|
|
||||||
flex: 1,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
innerCellLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
innerCellRight: {
|
|
||||||
flex: 1,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
innerCellRightLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
marginTop: 30,
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
},
|
|
||||||
footerCompany: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'right',
|
|
||||||
flex: 1,
|
|
||||||
color: '#1f74bf',
|
|
||||||
},
|
|
||||||
specialInstructionTable: {
|
|
||||||
width: '60%',
|
|
||||||
maxWidth: 300,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#000000',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default pdfStyles;
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
type ReportExpenseColumn =
|
||||||
|
| ColumnDef<ReportExpense>
|
||||||
|
| {
|
||||||
|
header: string;
|
||||||
|
columns: Array<{
|
||||||
|
header: string;
|
||||||
|
accessorKey?: string;
|
||||||
|
cell?: (props: { row: { original: ReportExpense } }) => React.ReactNode;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReportExpenseSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ReportExpenseColumn[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportExpenseSkeleton;
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Dropdown from '@/components/dropdown/Dropdown';
|
||||||
|
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import {
|
||||||
|
ReportExpenseFilterSchema,
|
||||||
|
type ReportExpenseFilterValues,
|
||||||
|
} from '@/components/pages/report/expense/filter/ReportExpenseFilter';
|
||||||
|
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
||||||
|
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
||||||
|
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||||
|
import { ReportExpenseApi } from '@/services/api/report';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
|
import Pagination from '@/components/Pagination';
|
||||||
|
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
|
||||||
|
import { generateReportExpensePDF } from '../export/ReportExpenseExportPDF';
|
||||||
|
import { generateReportExpenseExcel } from '../export/ReportExpenseExportXLSX';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import {
|
||||||
|
KandangApi,
|
||||||
|
LocationApi,
|
||||||
|
NonstockApi,
|
||||||
|
SupplierApi,
|
||||||
|
} from '@/services/api/master-data';
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
import { Kandang } from '@/types/api/master-data/kandang';
|
||||||
|
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { httpClient } from '@/services/http/client';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
interface ReportExpenseTabProps {
|
||||||
|
tabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterParams {
|
||||||
|
location_id?: string;
|
||||||
|
supplier_id?: string;
|
||||||
|
kandang_id?: string;
|
||||||
|
nonstock_id?: string;
|
||||||
|
realization_date?: string;
|
||||||
|
category?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||||
|
// ===== STATE MANAGEMENT =====
|
||||||
|
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||||
|
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||||
|
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||||
|
|
||||||
|
// ===== SUBMISSION STATE =====
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||||
|
|
||||||
|
// ===== PAGINATION STATE =====
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocations,
|
||||||
|
loadMore: loadMoreLocations,
|
||||||
|
} = useSelect<Kandang>(LocationApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setSupplierInputValue,
|
||||||
|
options: supplierOptions,
|
||||||
|
isLoadingOptions: isLoadingSuppliers,
|
||||||
|
loadMore: loadMoreSuppliers,
|
||||||
|
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setKandangInputValue,
|
||||||
|
options: kandangOptions,
|
||||||
|
isLoadingOptions: isLoadingKandangs,
|
||||||
|
loadMore: loadMoreKandangs,
|
||||||
|
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setNonstockInputValue,
|
||||||
|
options: nonstockOptions,
|
||||||
|
isLoadingOptions: isLoadingNonstocks,
|
||||||
|
loadMore: loadMoreNonstocks,
|
||||||
|
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'BOP', label: 'BOP' },
|
||||||
|
{ value: 'NON-BOP', label: 'Non BOP' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<ReportExpenseFilterValues>({
|
||||||
|
initialValues: {
|
||||||
|
location_id: null,
|
||||||
|
supplier_id: null,
|
||||||
|
kandang_id: null,
|
||||||
|
nonstock_id: null,
|
||||||
|
realization_date: null,
|
||||||
|
category: null,
|
||||||
|
},
|
||||||
|
validationSchema: ReportExpenseFilterSchema,
|
||||||
|
onSubmit: (values) => {
|
||||||
|
setFilterParams({
|
||||||
|
location_id: values.location_id?.value
|
||||||
|
? String(values.location_id.value)
|
||||||
|
: undefined,
|
||||||
|
supplier_id: values.supplier_id?.value
|
||||||
|
? String(values.supplier_id.value)
|
||||||
|
: undefined,
|
||||||
|
kandang_id: values.kandang_id?.value
|
||||||
|
? String(values.kandang_id.value)
|
||||||
|
: undefined,
|
||||||
|
nonstock_id: values.nonstock_id?.value
|
||||||
|
? String(values.nonstock_id.value)
|
||||||
|
: undefined,
|
||||||
|
realization_date: values.realization_date || undefined,
|
||||||
|
category: values.category?.value
|
||||||
|
? String(values.category.value)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
filterModal.closeModal();
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
setFilterParams({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== FILTER VALUES =====
|
||||||
|
const locationValue = useMemo(
|
||||||
|
() => formik.values.location_id,
|
||||||
|
[formik.values.location_id]
|
||||||
|
);
|
||||||
|
const supplierValue = useMemo(
|
||||||
|
() => formik.values.supplier_id,
|
||||||
|
[formik.values.supplier_id]
|
||||||
|
);
|
||||||
|
const kandangValue = useMemo(
|
||||||
|
() => formik.values.kandang_id,
|
||||||
|
[formik.values.kandang_id]
|
||||||
|
);
|
||||||
|
const nonstockValue = useMemo(
|
||||||
|
() => formik.values.nonstock_id,
|
||||||
|
[formik.values.nonstock_id]
|
||||||
|
);
|
||||||
|
const categoryValue = useMemo(
|
||||||
|
() => formik.values.category,
|
||||||
|
[formik.values.category]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== ACTIVE FILTERS COUNT =====
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
if (filterParams.location_id) count += 1;
|
||||||
|
if (filterParams.supplier_id) count += 1;
|
||||||
|
if (filterParams.kandang_id) count += 1;
|
||||||
|
if (filterParams.nonstock_id) count += 1;
|
||||||
|
if (filterParams.realization_date) count += 1;
|
||||||
|
if (filterParams.category) count += 1;
|
||||||
|
return count;
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
const hasFilters = activeFiltersCount > 0;
|
||||||
|
|
||||||
|
// ===== DATA FETCHING =====
|
||||||
|
const { data: reportExpenseResponse, isLoading } = useSWR(
|
||||||
|
isSubmitted
|
||||||
|
? () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterParams.location_id)
|
||||||
|
params.append('location_id', filterParams.location_id);
|
||||||
|
if (filterParams.supplier_id)
|
||||||
|
params.append('supplier_id', filterParams.supplier_id);
|
||||||
|
if (filterParams.kandang_id)
|
||||||
|
params.append('kandang_id', filterParams.kandang_id);
|
||||||
|
if (filterParams.nonstock_id)
|
||||||
|
params.append('nonstock_id', filterParams.nonstock_id);
|
||||||
|
if (filterParams.realization_date)
|
||||||
|
params.append('realization_date', filterParams.realization_date);
|
||||||
|
if (filterParams.category)
|
||||||
|
params.append('category', filterParams.category);
|
||||||
|
params.append('page', String(page));
|
||||||
|
params.append('limit', String(pageSize));
|
||||||
|
|
||||||
|
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: ReportExpense[] = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(reportExpenseResponse)
|
||||||
|
? (reportExpenseResponse.data as ReportExpense[]) || []
|
||||||
|
: [],
|
||||||
|
[reportExpenseResponse]
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta
|
||||||
|
? reportExpenseResponse.meta
|
||||||
|
: null,
|
||||||
|
[reportExpenseResponse]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== EXPORT DATA FETCHER =====
|
||||||
|
const reportExpenseExport = useCallback(async (): Promise<
|
||||||
|
ReportExpense[] | null
|
||||||
|
> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterParams.location_id)
|
||||||
|
params.append('location_id', filterParams.location_id);
|
||||||
|
if (filterParams.supplier_id)
|
||||||
|
params.append('supplier_id', filterParams.supplier_id);
|
||||||
|
if (filterParams.kandang_id)
|
||||||
|
params.append('kandang_id', filterParams.kandang_id);
|
||||||
|
if (filterParams.nonstock_id)
|
||||||
|
params.append('nonstock_id', filterParams.nonstock_id);
|
||||||
|
if (filterParams.realization_date)
|
||||||
|
params.append('realization_date', filterParams.realization_date);
|
||||||
|
if (filterParams.category) params.append('category', filterParams.category);
|
||||||
|
params.append('limit', '100');
|
||||||
|
params.append('page', '1');
|
||||||
|
|
||||||
|
const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
|
||||||
|
`${ReportExpenseApi.basePath}?${params.toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return isResponseSuccess(response) ? response.data : null;
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
// ===== EXPORT HANDLERS =====
|
||||||
|
const handleExportExcel = useCallback(async () => {
|
||||||
|
setIsExcelExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allDataForExport = await reportExpenseExport();
|
||||||
|
|
||||||
|
if (!allDataForExport || allDataForExport.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateReportExpenseExcel(allDataForExport);
|
||||||
|
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsExcelExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [reportExpenseExport]);
|
||||||
|
|
||||||
|
const handleExportPDF = useCallback(async () => {
|
||||||
|
setIsPdfExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allData = await reportExpenseExport();
|
||||||
|
if (!allData || allData.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfParams = {
|
||||||
|
location_name: locationValue?.label,
|
||||||
|
supplier_name: supplierValue?.label,
|
||||||
|
realization_date: formik.values.realization_date || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await generateReportExpensePDF(allData, pdfParams);
|
||||||
|
|
||||||
|
toast.success('PDF berhasil dibuat dan diunduh.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsPdfExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
reportExpenseExport,
|
||||||
|
locationValue,
|
||||||
|
supplierValue,
|
||||||
|
kandangValue,
|
||||||
|
nonstockValue,
|
||||||
|
categoryValue,
|
||||||
|
formik.values.realization_date,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTabActions(
|
||||||
|
tabId,
|
||||||
|
<div className='flex flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
onClick={() => filterModal.openModal()}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||||
|
{
|
||||||
|
'border-primary-gradient text-primary': hasFilters,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
Filter
|
||||||
|
{hasFilters && (
|
||||||
|
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||||
|
{activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
isLoading={isAnyExportLoading}
|
||||||
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
|
>
|
||||||
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:cloud-arrow-down'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
isLoading={isExcelExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
|
Export to Excel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
tabId,
|
||||||
|
hasFilters,
|
||||||
|
activeFiltersCount,
|
||||||
|
isAnyExportLoading,
|
||||||
|
handleExportExcel,
|
||||||
|
handleExportPDF,
|
||||||
|
setTabActions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTabActions(tabId);
|
||||||
|
};
|
||||||
|
}, [tabId, clearTabActions]);
|
||||||
|
|
||||||
|
// ===== TABLE COLUMNS DEFINITION =====
|
||||||
|
const columns = useMemo((): ColumnDef<ReportExpense>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
cell: (props) => (page - 1) * pageSize + props.row.index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No. PO',
|
||||||
|
accessorKey: 'po_number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No. Referensi',
|
||||||
|
accessorKey: 'reference_number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Tanggal Realisasi',
|
||||||
|
accessorKey: 'realization_date',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Tanggal Transaksi',
|
||||||
|
accessorKey: 'transaction_date',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Kategori',
|
||||||
|
accessorKey: 'category',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Produk',
|
||||||
|
accessorFn: (row) => row.pengajuan?.nonstock?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Supplier',
|
||||||
|
accessorFn: (row) => row.supplier?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Lokasi',
|
||||||
|
accessorFn: (row) => row.kandang?.location?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Kandang',
|
||||||
|
accessorFn: (row) => row.kandang?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Pengajuan',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: 'Qty',
|
||||||
|
id: 'qty_pengajuan',
|
||||||
|
accessorFn: (row) => row.pengajuan?.qty,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Harga',
|
||||||
|
id: 'harga_pengajuan',
|
||||||
|
accessorFn: (row) => row.pengajuan?.price,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
formatCurrency(row.original.pengajuan?.price || 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Total',
|
||||||
|
id: 'total_pengajuan',
|
||||||
|
accessorFn: (row) =>
|
||||||
|
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const total =
|
||||||
|
(row.original.pengajuan?.qty || 0) *
|
||||||
|
(row.original.pengajuan?.price || 0);
|
||||||
|
return formatCurrency(total);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Realisasi',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: 'Qty',
|
||||||
|
id: 'qty_realisasi',
|
||||||
|
accessorFn: (row) => row.realisasi?.qty,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Harga',
|
||||||
|
id: 'harga_realisasi',
|
||||||
|
accessorFn: (row) => row.realisasi?.price,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
formatCurrency(row.original.realisasi?.price || 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Total',
|
||||||
|
id: 'total_realisasi',
|
||||||
|
accessorFn: (row) =>
|
||||||
|
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const total =
|
||||||
|
(row.original.realisasi?.qty || 0) *
|
||||||
|
(row.original.realisasi?.price || 0);
|
||||||
|
return formatCurrency(total);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status Pencairan',
|
||||||
|
cell: (props) => (
|
||||||
|
<RealizationStatusBadge
|
||||||
|
approval={props.row.original?.latest_approval}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status BOP',
|
||||||
|
cell: (props) => (
|
||||||
|
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||||
|
{!isSubmitted ? (
|
||||||
|
<ReportExpenseSkeleton
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:funnel'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='No Filters Selected'
|
||||||
|
subtitle='Please choose filters to narrow down your results and make your search easier.'
|
||||||
|
/>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !data || data.length === 0 ? (
|
||||||
|
<ReportExpenseSkeleton
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:chart-bar'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='Data Not Yet Available'
|
||||||
|
subtitle='Please change your filters to get the data.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={pageSize}
|
||||||
|
page={meta?.page || 1}
|
||||||
|
totalItems={meta?.total_results || 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName: 'overflow-x-auto',
|
||||||
|
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 text-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',
|
||||||
|
paginationClassName: 'hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{meta && (
|
||||||
|
<div className='max-w-sm ml-auto'>
|
||||||
|
<Pagination
|
||||||
|
totalItems={meta.total_results || 0}
|
||||||
|
itemsPerPage={meta.limit || 0}
|
||||||
|
currentPage={meta.page || 0}
|
||||||
|
onPrevPage={() =>
|
||||||
|
setPage((currPage) =>
|
||||||
|
currPage > 1 ? currPage - 1 : currPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onNextPage={() =>
|
||||||
|
setPage((currPage) =>
|
||||||
|
meta && meta.total_pages && currPage < meta.total_pages
|
||||||
|
? currPage + 1
|
||||||
|
: currPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPageChange={(pageNumber) => setPage(pageNumber)}
|
||||||
|
rowOptions={[10, 20, 50, 100]}
|
||||||
|
onRowChange={setPageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className='p-4 flex flex-col gap-3'>
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
isLoading={isLoadingLocations}
|
||||||
|
value={locationValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('location_id', val);
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreLocations}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Kandang'
|
||||||
|
placeholder='Pilih Kandang'
|
||||||
|
options={kandangOptions}
|
||||||
|
isLoading={isLoadingKandangs}
|
||||||
|
value={kandangValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('kandang_id', val);
|
||||||
|
}}
|
||||||
|
onInputChange={setKandangInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreKandangs}
|
||||||
|
isClearable
|
||||||
|
isDisabled={!formik.values.location_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Supplier'
|
||||||
|
placeholder='Pilih Supplier'
|
||||||
|
options={supplierOptions}
|
||||||
|
isLoading={isLoadingSuppliers}
|
||||||
|
value={supplierValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('supplier_id', val);
|
||||||
|
}}
|
||||||
|
onInputChange={setSupplierInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreSuppliers}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Produk'
|
||||||
|
placeholder='Pilih Produk'
|
||||||
|
options={nonstockOptions}
|
||||||
|
isLoading={isLoadingNonstocks}
|
||||||
|
value={nonstockValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('nonstock_id', val);
|
||||||
|
}}
|
||||||
|
onInputChange={setNonstockInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreNonstocks}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Kategori'
|
||||||
|
placeholder='Pilih Kategori'
|
||||||
|
options={categoryOptions}
|
||||||
|
value={categoryValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('category', val);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
label='Tanggal Realisasi'
|
||||||
|
name='realization_date'
|
||||||
|
value={formik.values.realization_date || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'realization_date',
|
||||||
|
e.target.value || null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='reset'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportExpenseTab;
|
||||||
@@ -4,11 +4,11 @@ import { useState } from 'react';
|
|||||||
import Tabs from '@/components/Tabs';
|
import Tabs from '@/components/Tabs';
|
||||||
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
|
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
|
||||||
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
|
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
|
||||||
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
|
||||||
const FinanceTabs = () => {
|
const FinanceTabs = () => {
|
||||||
const [activeTabId, setActiveTabId] = useState<string>('1');
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
const tabActions = useFinanceTabStore((state) => state.tabActions);
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type CustomerPaymentFilterType = {
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
customer_ids: string | null;
|
||||||
|
filter_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomerPaymentFilterSchema = yup.object({
|
||||||
|
start_date: yup.string().optional().nullable(),
|
||||||
|
end_date: yup
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.test(
|
||||||
|
'is-greater-than-start',
|
||||||
|
'Tanggal akhir tidak boleh masa lampau',
|
||||||
|
function (value) {
|
||||||
|
const { start_date } = this.parent;
|
||||||
|
if (!start_date || !value) return true;
|
||||||
|
return new Date(value) >= new Date(start_date);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
customer_ids: yup.string().nullable(),
|
||||||
|
filter_by: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CustomerPaymentFilterValues = yup.InferType<
|
||||||
|
typeof CustomerPaymentFilterSchema
|
||||||
|
>;
|
||||||
@@ -2,17 +2,22 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Badge from '@/components/Badge';
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
import { useSelect } from '@/components/input/SelectInput';
|
import { useSelect } from '@/components/input/SelectInput';
|
||||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
import DateInput from '@/components/input/DateInput';
|
import DateInput from '@/components/input/DateInput';
|
||||||
import { CustomerApi } from '@/services/api/master-data';
|
import { CustomerApi } from '@/services/api/master-data';
|
||||||
import { FinanceApi } from '@/services/api/report/finance-report';
|
import { FinanceApi } from '@/services/api/report/finance-report';
|
||||||
// import { UserApi } from '@/services/api/user';
|
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { formatCurrency, formatDate, formatNumber, cn } from '@/lib/helper';
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
formatNumber,
|
||||||
|
formatTitleCase,
|
||||||
|
cn,
|
||||||
|
} from '@/lib/helper';
|
||||||
import {
|
import {
|
||||||
CustomerPaymentReport,
|
CustomerPaymentReport,
|
||||||
CustomerPaymentSummary,
|
CustomerPaymentSummary,
|
||||||
@@ -20,20 +25,31 @@ import {
|
|||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Dropdown from '@/components/Dropdown';
|
import Dropdown from '@/components/Dropdown';
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import Modal from '@/components/Modal';
|
|
||||||
import { useModal } from '@/components/Modal';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import {
|
||||||
|
CustomerPaymentFilterSchema,
|
||||||
|
CustomerPaymentFilterType,
|
||||||
|
} from '@/components/pages/report/finance/filter/CustomerPaymentFilter';
|
||||||
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
|
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
|
||||||
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
|
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
|
||||||
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
|
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
|
||||||
|
import { OptionType } from '@/components/table/TableRowSizeSelector';
|
||||||
|
import { Color } from '@/types/theme';
|
||||||
|
|
||||||
interface CustomerPaymentTabProps {
|
interface CustomerPaymentTabProps {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FilterParams {
|
||||||
|
customer_ids?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
filter_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||||
// ===== STATE MANAGEMENT =====
|
// ===== STATE MANAGEMENT =====
|
||||||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||||
@@ -46,31 +62,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
|
|
||||||
// ===== SUBMISSION STATE =====
|
// ===== SUBMISSION STATE =====
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||||
// ===== FILTER STATE =====
|
|
||||||
const [appliedFilterCustomer, setAppliedFilterCustomer] = useState<
|
|
||||||
typeof customerOptions
|
|
||||||
>([]);
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// const [appliedFilterSales, setAppliedFilterSales] = useState<
|
|
||||||
// typeof salesOptions
|
|
||||||
// >([]);
|
|
||||||
const [appliedFilterByType, setAppliedFilterByType] = useState<
|
|
||||||
(typeof dataTypeOptions)[0] | null
|
|
||||||
>(null);
|
|
||||||
const [appliedFilterStartDate, setAppliedFilterStartDate] = useState('');
|
|
||||||
const [appliedFilterEndDate, setAppliedFilterEndDate] = useState('');
|
|
||||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||||
const [hasDateError, setHasDateError] = useState(false);
|
const [hasDateError, setHasDateError] = useState(false);
|
||||||
|
|
||||||
const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>(
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
|
|
||||||
const [filterStartDate, setFilterStartDate] = useState('');
|
|
||||||
const [filterEndDate, setFilterEndDate] = useState('');
|
|
||||||
|
|
||||||
const filterModal = useModal();
|
const filterModal = useModal();
|
||||||
|
|
||||||
const dataTypeOptions = useMemo(
|
const dataTypeOptions = useMemo(
|
||||||
@@ -81,10 +76,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [filterByType, setFilterByType] = useState<
|
|
||||||
(typeof dataTypeOptions)[0] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
options: customerOptions,
|
options: customerOptions,
|
||||||
setInputValue: setCustomerInputValue,
|
setInputValue: setCustomerInputValue,
|
||||||
@@ -92,108 +83,67 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
loadMore: loadMoreCustomers,
|
loadMore: loadMoreCustomers,
|
||||||
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
|
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
// TODO: Uncomment when BE is ready
|
const handleFilterModalOpen = () => {
|
||||||
// const {
|
|
||||||
// options: salesOptions,
|
|
||||||
// setInputValue: setSalesInputValue,
|
|
||||||
// isLoadingOptions: isLoadingSales,
|
|
||||||
// loadMore: loadMoreSales,
|
|
||||||
// hasMore: hasMoreSales,
|
|
||||||
// } = useSelect(UserApi.basePath, 'id', 'name', 'search');
|
|
||||||
|
|
||||||
const getPaymentStatusColor = (notes: string) => {
|
|
||||||
const normalizedValue = notes.toLowerCase();
|
|
||||||
|
|
||||||
if (normalizedValue === 'lunas') {
|
|
||||||
return 'bg-info/10 text-black border-info';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedValue.includes('belum')) {
|
|
||||||
return 'bg-warning/10 text-black border-warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bg-gray-100 text-black border-gray-300';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPaymentStatusIndicatorColor = (notes: string) => {
|
|
||||||
const normalizedValue = notes.toLowerCase();
|
|
||||||
|
|
||||||
if (normalizedValue === 'lunas') {
|
|
||||||
return 'bg-info';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedValue.includes('belum')) {
|
|
||||||
return 'bg-warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bg-gray-400';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPaymentStatusText = (notes: string) => {
|
|
||||||
return notes
|
|
||||||
.toLowerCase()
|
|
||||||
.split(' ')
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== FILTER HANDLERS =====
|
|
||||||
const handleFilterModalOpen = useCallback(() => {
|
|
||||||
setFilterCustomer(appliedFilterCustomer);
|
|
||||||
// setFilterSales(appliedFilterSales);
|
|
||||||
setFilterByType(appliedFilterByType);
|
|
||||||
setFilterStartDate(appliedFilterStartDate);
|
|
||||||
setFilterEndDate(appliedFilterEndDate);
|
|
||||||
filterModal.openModal();
|
filterModal.openModal();
|
||||||
}, [
|
formik.validateForm();
|
||||||
filterModal,
|
};
|
||||||
appliedFilterCustomer,
|
|
||||||
appliedFilterByType,
|
|
||||||
appliedFilterStartDate,
|
|
||||||
appliedFilterEndDate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleResetFilters = useCallback(() => {
|
// ===== FORMIK SETUP =====
|
||||||
setIsSubmitted(false);
|
const formik = useFormik<CustomerPaymentFilterType>({
|
||||||
setFilterCustomer([]);
|
initialValues: {
|
||||||
setFilterByType(null);
|
start_date: null,
|
||||||
setFilterStartDate('');
|
end_date: null,
|
||||||
setFilterEndDate('');
|
customer_ids: null,
|
||||||
setAppliedFilterCustomer([]);
|
filter_by: null,
|
||||||
setAppliedFilterByType(null);
|
},
|
||||||
setAppliedFilterStartDate('');
|
validationSchema: CustomerPaymentFilterSchema,
|
||||||
setAppliedFilterEndDate('');
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
setHasDateError(false);
|
setFilterParams({
|
||||||
if (dateErrorShown) {
|
start_date: values.start_date || undefined,
|
||||||
toast.dismiss();
|
end_date: values.end_date || undefined,
|
||||||
setDateErrorShown(false);
|
customer_ids: values.customer_ids || undefined,
|
||||||
|
filter_by: values.filter_by || undefined,
|
||||||
|
});
|
||||||
|
filterModal.closeModal();
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
setFilterParams({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setHasDateError(false);
|
||||||
|
if (dateErrorShown) {
|
||||||
|
toast.dismiss();
|
||||||
|
setDateErrorShown(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPaymentStatusBadgeColor = (notes: string): Color => {
|
||||||
|
const normalizedValue = notes.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedValue === 'lunas') {
|
||||||
|
return 'primary';
|
||||||
}
|
}
|
||||||
}, [dateErrorShown]);
|
|
||||||
|
|
||||||
const handleApplyFilters = useCallback(() => {
|
if (normalizedValue.includes('belum')) {
|
||||||
setAppliedFilterCustomer(filterCustomer);
|
return 'warning';
|
||||||
setAppliedFilterByType(filterByType);
|
}
|
||||||
setAppliedFilterStartDate(filterStartDate);
|
|
||||||
setAppliedFilterEndDate(filterEndDate);
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setCurrentPage(1);
|
|
||||||
filterModal.closeModal();
|
|
||||||
}, [
|
|
||||||
filterModal,
|
|
||||||
filterCustomer,
|
|
||||||
filterByType,
|
|
||||||
filterStartDate,
|
|
||||||
filterEndDate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== DATE CHANGE HANDLERS =====
|
||||||
const handleStartDateChange = useCallback(
|
const handleStartDateChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setFilterStartDate(value);
|
formik.setFieldValue('start_date', value || null);
|
||||||
|
|
||||||
if (value && filterEndDate) {
|
if (value && formik.values.end_date) {
|
||||||
const startDate = new Date(value);
|
const startDate = new Date(value);
|
||||||
const endDateObj = new Date(filterEndDate);
|
const endDateObj = new Date(formik.values.end_date);
|
||||||
|
|
||||||
if (endDateObj < startDate) {
|
if (endDateObj < startDate) {
|
||||||
setHasDateError(true);
|
setHasDateError(true);
|
||||||
@@ -214,16 +164,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
setHasDateError(false);
|
setHasDateError(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filterEndDate, dateErrorShown]
|
[formik, dateErrorShown]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEndDateChange = useCallback(
|
const handleEndDateChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setFilterEndDate(value);
|
formik.setFieldValue('end_date', value || null);
|
||||||
|
|
||||||
if (value && filterStartDate) {
|
if (value && formik.values.start_date) {
|
||||||
const startDateObj = new Date(filterStartDate);
|
const startDateObj = new Date(formik.values.start_date);
|
||||||
const endDate = new Date(value);
|
const endDate = new Date(value);
|
||||||
|
|
||||||
if (endDate < startDateObj) {
|
if (endDate < startDateObj) {
|
||||||
@@ -244,41 +194,46 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
setDateErrorShown(false);
|
setDateErrorShown(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filterStartDate, dateErrorShown]
|
[formik, dateErrorShown]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const customerIdsValue = useMemo(() => {
|
||||||
|
if (!formik.values.customer_ids) return [];
|
||||||
|
return customerOptions.filter((opt) =>
|
||||||
|
formik.values.customer_ids?.split(',').includes(String(opt.value))
|
||||||
|
);
|
||||||
|
}, [formik.values.customer_ids, customerOptions]);
|
||||||
|
|
||||||
|
const filterByValue = useMemo(() => {
|
||||||
|
if (!formik.values.filter_by) return null;
|
||||||
|
return (
|
||||||
|
dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.filter_by]);
|
||||||
|
|
||||||
// ===== ACTIVE FILTERS COUNT =====
|
// ===== ACTIVE FILTERS COUNT =====
|
||||||
const activeFiltersCount = useMemo(() => {
|
const activeFiltersCount = useMemo(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
// Date filter (start_date + end_date = 1 filter)
|
// Date filter (start_date + end_date = 1 filter)
|
||||||
if (appliedFilterStartDate || appliedFilterEndDate) {
|
if (filterParams.start_date || filterParams.end_date) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customer filter
|
// Customer filter
|
||||||
if (appliedFilterCustomer.length > 0) {
|
if (filterParams.customer_ids) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by type filter (hanya dihitung jika ada nilai yang dipilih)
|
// Filter by type filter (hanya dihitung jika ada nilai yang dipilih)
|
||||||
if (appliedFilterByType) {
|
if (filterParams.filter_by) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// // Sales filter
|
|
||||||
// if (appliedFilterSales.length > 0) {
|
|
||||||
// count += 1;
|
|
||||||
// }
|
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
}, [
|
}, [filterParams]);
|
||||||
appliedFilterStartDate,
|
|
||||||
appliedFilterEndDate,
|
|
||||||
appliedFilterCustomer,
|
|
||||||
appliedFilterByType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasFilters = activeFiltersCount > 0;
|
const hasFilters = activeFiltersCount > 0;
|
||||||
|
|
||||||
@@ -287,21 +242,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
isSubmitted
|
isSubmitted
|
||||||
? () => {
|
? () => {
|
||||||
const params = {
|
const params = {
|
||||||
customer_ids:
|
customer_ids: filterParams.customer_ids,
|
||||||
appliedFilterCustomer.length > 0
|
filter_by: filterParams.filter_by as
|
||||||
? appliedFilterCustomer.map((v) => String(v.value)).join(',')
|
|
||||||
: undefined,
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// sales_id:
|
|
||||||
// appliedFilterSales.length > 0
|
|
||||||
// ? appliedFilterSales.map((v) => String(v.value)).join(',')
|
|
||||||
// : undefined,
|
|
||||||
filter_by: appliedFilterByType?.value as
|
|
||||||
| 'trans_date'
|
| 'trans_date'
|
||||||
| 'realization_date'
|
| 'realization_date'
|
||||||
| undefined,
|
| undefined,
|
||||||
start_date: appliedFilterStartDate || undefined,
|
start_date: filterParams.start_date,
|
||||||
end_date: appliedFilterEndDate || undefined,
|
end_date: filterParams.end_date,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
};
|
};
|
||||||
@@ -333,21 +280,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
CustomerPaymentReport[] | null
|
CustomerPaymentReport[] | null
|
||||||
> => {
|
> => {
|
||||||
const params = {
|
const params = {
|
||||||
customer_ids:
|
customer_ids: filterParams.customer_ids,
|
||||||
appliedFilterCustomer.length > 0
|
filter_by: filterParams.filter_by as
|
||||||
? appliedFilterCustomer.map((v) => String(v.value)).join(',')
|
|
||||||
: undefined,
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// sales_id:
|
|
||||||
// appliedFilterSales.length > 0
|
|
||||||
// ? appliedFilterSales.map((v) => String(v.value)).join(',')
|
|
||||||
// : undefined,
|
|
||||||
filter_by: appliedFilterByType?.value as
|
|
||||||
| 'trans_date'
|
| 'trans_date'
|
||||||
| 'realization_date'
|
| 'realization_date'
|
||||||
| undefined,
|
| undefined,
|
||||||
start_date: appliedFilterStartDate || undefined,
|
start_date: filterParams.start_date,
|
||||||
end_date: appliedFilterEndDate || undefined,
|
end_date: filterParams.end_date,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
page: 1,
|
page: 1,
|
||||||
};
|
};
|
||||||
@@ -364,13 +303,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
return isResponseSuccess(response)
|
return isResponseSuccess(response)
|
||||||
? (response.data as unknown as CustomerPaymentReport[])
|
? (response.data as unknown as CustomerPaymentReport[])
|
||||||
: null;
|
: null;
|
||||||
}, [
|
}, [filterParams]);
|
||||||
appliedFilterCustomer,
|
|
||||||
// appliedFilterSales,
|
|
||||||
appliedFilterStartDate,
|
|
||||||
appliedFilterEndDate,
|
|
||||||
appliedFilterByType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ===== EXPORT HANDLERS =====
|
// ===== EXPORT HANDLERS =====
|
||||||
const handleExportExcel = useCallback(async () => {
|
const handleExportExcel = useCallback(async () => {
|
||||||
@@ -410,21 +343,22 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customerName = filterParams.customer_ids
|
||||||
|
? customerOptions
|
||||||
|
.filter((opt) =>
|
||||||
|
filterParams.customer_ids?.split(',').includes(String(opt.value))
|
||||||
|
)
|
||||||
|
.map((opt) => opt.label)
|
||||||
|
.join(', ') || 'Semua Customer'
|
||||||
|
: 'Semua Customer';
|
||||||
|
|
||||||
await generateCustomerPaymentPDF({
|
await generateCustomerPaymentPDF({
|
||||||
data: allDataForExport,
|
data: allDataForExport,
|
||||||
params: {
|
params: {
|
||||||
customer_name:
|
customer_name: customerName,
|
||||||
appliedFilterCustomer.length > 0
|
start_date: filterParams.start_date,
|
||||||
? appliedFilterCustomer.map((c) => c.label).join(', ')
|
end_date: filterParams.end_date,
|
||||||
: undefined,
|
filter_by: filterParams.filter_by as
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// sales:
|
|
||||||
// appliedFilterSales.length > 0
|
|
||||||
// ? appliedFilterSales.map((s) => s.label).join(', ')
|
|
||||||
// : undefined,
|
|
||||||
start_date: appliedFilterStartDate || undefined,
|
|
||||||
end_date: appliedFilterEndDate || undefined,
|
|
||||||
filter_by: appliedFilterByType?.value as
|
|
||||||
| 'trans_date'
|
| 'trans_date'
|
||||||
| 'realization_date'
|
| 'realization_date'
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -436,11 +370,11 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsPdfExportLoading(false);
|
setIsPdfExportLoading(false);
|
||||||
}
|
}
|
||||||
}, [customerPaymentExport]);
|
}, [customerPaymentExport, filterParams, customerOptions]);
|
||||||
|
|
||||||
// ===== REGISTER TAB ACTIONS TO STORE =====
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTabActions(
|
setTabActions(
|
||||||
@@ -451,13 +385,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
color='none'
|
color='none'
|
||||||
onClick={handleFilterModalOpen}
|
onClick={handleFilterModalOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3 py-2.5',
|
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||||
'rounded-lg! font-semibold text-sm gap-1.5',
|
{
|
||||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
|
'border-primary-gradient text-primary': hasFilters,
|
||||||
hasFilters && 'border-primary-gradient text-primary'
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon icon='heroicons:funnel' width={18} height={18} />
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
Filter
|
Filter
|
||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||||
@@ -467,42 +401,55 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='none'
|
color='none'
|
||||||
isLoading={isAnyExportLoading}
|
isLoading={isAnyExportLoading}
|
||||||
className={cn(
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
'px-3 py-2.5',
|
|
||||||
'rounded-lg font-semibold text-sm gap-1.5',
|
|
||||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
Export
|
<Icon
|
||||||
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
|
icon='heroicons:cloud-arrow-down'
|
||||||
<Icon width={14} height={14} icon='heroicons:chevron-down' />
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
align='end'
|
|
||||||
className={{
|
|
||||||
content:
|
|
||||||
'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Menu className='p-0 w-full'>
|
<Button
|
||||||
<MenuItem
|
variant='ghost'
|
||||||
className='text-sm p-3'
|
color='none'
|
||||||
title='Excel'
|
onClick={handleExportExcel}
|
||||||
onClick={handleExportExcel}
|
isLoading={isExcelExportLoading}
|
||||||
/>
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
<MenuItem
|
>
|
||||||
className='text-sm p-3'
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
title='PDF'
|
Export to Excel
|
||||||
onClick={handleExportPdf}
|
</Button>
|
||||||
/>
|
<Button
|
||||||
</Menu>
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -517,7 +464,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
setTabActions,
|
setTabActions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearTabActions(tabId);
|
clearTabActions(tabId);
|
||||||
@@ -735,17 +681,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<StatusBadge
|
||||||
statusIndicator={true}
|
color={getPaymentStatusBadgeColor(value)}
|
||||||
size='sm'
|
text={formatTitleCase(value)}
|
||||||
variant='soft'
|
/>
|
||||||
className={{
|
|
||||||
badge: `py-2.5 px-2 font-thin text-xs border border-gray-200 rounded-xl justify-start ${getPaymentStatusColor(value)}`,
|
|
||||||
status: getPaymentStatusIndicatorColor(value),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getPaymentStatusText(value)}
|
|
||||||
</Badge>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -931,95 +870,86 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className='p-4 flex flex-col gap-1.5'>
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
<div>
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
<label className='block text-xs font-semibold text-base-content py-2'>
|
<div>
|
||||||
Tanggal
|
<label className='block text-xs font-semibold text-base-content py-2'>
|
||||||
</label>
|
Tanggal
|
||||||
<div className='flex flex-row gap-1.5 items-center justify-between'>
|
</label>
|
||||||
<DateInput
|
<div className='flex flex-row gap-1.5 items-center justify-between'>
|
||||||
name='start_date'
|
<DateInput
|
||||||
value={filterStartDate}
|
name='start_date'
|
||||||
onChange={handleStartDateChange}
|
value={formik.values.start_date || ''}
|
||||||
className={{ wrapper: 'w-full' }}
|
onChange={handleStartDateChange}
|
||||||
isNestedModal
|
className={{ wrapper: 'w-full' }}
|
||||||
/>
|
isNestedModal
|
||||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
/>
|
||||||
|
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||||
|
|
||||||
<DateInput
|
<DateInput
|
||||||
name='end_date'
|
name='end_date'
|
||||||
value={filterEndDate}
|
value={formik.values.end_date || ''}
|
||||||
onChange={handleEndDateChange}
|
onChange={handleEndDateChange}
|
||||||
className={{ wrapper: 'w-full' }}
|
className={{ wrapper: 'w-full' }}
|
||||||
isNestedModal
|
isNestedModal
|
||||||
/>
|
isError={hasDateError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SelectInputCheckbox
|
||||||
|
label='Customer'
|
||||||
|
placeholder='Pilih Customer'
|
||||||
|
options={customerOptions}
|
||||||
|
value={customerIdsValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'customer_ids',
|
||||||
|
Array.isArray(val) && val.length > 0
|
||||||
|
? val.map((v: OptionType) => String(v.value)).join(',')
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onInputChange={setCustomerInputValue}
|
||||||
|
isLoading={isLoadingCustomers}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreCustomers}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Filter Berdasarkan'
|
||||||
|
placeholder='Pilih Filter Berdasarkan'
|
||||||
|
options={dataTypeOptions}
|
||||||
|
value={filterByValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
formik.setFieldValue('filter_by', val?.value || null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
isClearable={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectInputCheckbox
|
{/* Modal Footer */}
|
||||||
label='Customer'
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
placeholder='Pilih Customer'
|
<Button
|
||||||
options={customerOptions}
|
type='reset'
|
||||||
value={filterCustomer}
|
variant='soft'
|
||||||
onChange={(val) => {
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
setFilterCustomer(Array.isArray(val) ? val : val ? [val] : []);
|
>
|
||||||
}}
|
Reset Filter
|
||||||
onInputChange={setCustomerInputValue}
|
</Button>
|
||||||
isLoading={isLoadingCustomers}
|
<Button
|
||||||
isClearable
|
type='submit'
|
||||||
onMenuScrollToBottom={loadMoreCustomers}
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
className={{ wrapper: 'w-full' }}
|
disabled={hasDateError || !formik.isValid || formik.isSubmitting}
|
||||||
/>
|
>
|
||||||
|
Apply Filter
|
||||||
{/* TODO: Uncomment when BE is ready */}
|
</Button>
|
||||||
{/* <div>
|
</div>
|
||||||
<SelectInputCheckbox
|
</form>
|
||||||
label='Sales'
|
|
||||||
placeholder='Pilih Sales'
|
|
||||||
options={salesOptions}
|
|
||||||
value={filterSales}
|
|
||||||
onChange={(val) => {
|
|
||||||
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
|
|
||||||
}}
|
|
||||||
onInputChange={setSalesInputValue}
|
|
||||||
isLoading={isLoadingSales}
|
|
||||||
isClearable
|
|
||||||
onMenuScrollToBottom={loadMoreSales}
|
|
||||||
className={{ wrapper: 'w-full' }}
|
|
||||||
/>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
<SelectInputRadio
|
|
||||||
label='Filter Berdasarkan'
|
|
||||||
placeholder='Pilih Filter Berdasarkan'
|
|
||||||
options={dataTypeOptions}
|
|
||||||
value={filterByType}
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val && !Array.isArray(val)) {
|
|
||||||
setFilterByType(val);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={{ wrapper: 'w-full' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
|
||||||
<Button
|
|
||||||
variant='soft'
|
|
||||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
|
||||||
onClick={handleResetFilters}
|
|
||||||
>
|
|
||||||
Reset Filter
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
|
||||||
onClick={handleApplyFilters}
|
|
||||||
disabled={hasDateError}
|
|
||||||
>
|
|
||||||
Apply Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import Card from '@/components/Card';
|
|||||||
import Dropdown from '@/components/Dropdown';
|
import Dropdown from '@/components/Dropdown';
|
||||||
import DateInput from '@/components/input/DateInput';
|
import DateInput from '@/components/input/DateInput';
|
||||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
|
||||||
import Modal, { useModal } from '@/components/Modal';
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
@@ -33,7 +31,7 @@ import { Color } from '@/types/theme';
|
|||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
import StatusBadge from '@/components/helper/StatusBadge';
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
|
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
|
||||||
|
|
||||||
@@ -271,13 +269,13 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
|||||||
}, [debtSupplierExport]);
|
}, [debtSupplierExport]);
|
||||||
|
|
||||||
// ===== REGISTER TAB ACTIONS TO STORE =====
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTabActions(
|
setTabActions(
|
||||||
tabId,
|
tabId,
|
||||||
<div className='flex flex-row gap-3 '>
|
<div className='flex flex-row gap-3'>
|
||||||
<ButtonFilter
|
<ButtonFilter
|
||||||
values={formik.values}
|
values={formik.values}
|
||||||
onClick={handleFilterModalOpen}
|
onClick={handleFilterModalOpen}
|
||||||
@@ -286,42 +284,55 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='none'
|
color='none'
|
||||||
isLoading={isAnyExportLoading}
|
isLoading={isAnyExportLoading}
|
||||||
className={cn(
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
'px-3 py-2.5',
|
|
||||||
'rounded-lg font-semibold text-sm gap-1.5',
|
|
||||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
Export
|
<Icon
|
||||||
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
|
icon='heroicons:cloud-arrow-down'
|
||||||
<Icon width={14} height={14} icon='heroicons:chevron-down' />
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
align='end'
|
|
||||||
className={{
|
|
||||||
content:
|
|
||||||
'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Menu className='p-0 w-full'>
|
<Button
|
||||||
<MenuItem
|
variant='ghost'
|
||||||
className='text-sm p-3'
|
color='none'
|
||||||
title='Excel'
|
onClick={handleExportExcel}
|
||||||
onClick={handleExportExcel}
|
isLoading={isExcelExportLoading}
|
||||||
/>
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
<MenuItem
|
>
|
||||||
className='text-sm p-3'
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
title='PDF'
|
Export to Excel
|
||||||
onClick={handleExportPdf}
|
</Button>
|
||||||
/>
|
<Button
|
||||||
</Menu>
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Tabs from '@/components/Tabs';
|
import Tabs from '@/components/Tabs';
|
||||||
import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab';
|
import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
|
||||||
const LogisticStockTabs = () => {
|
const LogisticStockTabs = () => {
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
label: 'Rekapitulasi Pembelian Per Supplier',
|
label: 'Rekapitulasi Pembelian Per Supplier',
|
||||||
content: <PurchasesPerSupplierTab />,
|
content: <PurchasesPerSupplierTab tabId='1' />,
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// id: '2',
|
// id: '2',
|
||||||
@@ -23,8 +28,20 @@ const LogisticStockTabs = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className='w-full p-4'>
|
<section className='w-full'>
|
||||||
<Tabs tabs={tabs} variant='lifted' />
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
variant='boxed'
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onTabChange={setActiveTabId}
|
||||||
|
className={{
|
||||||
|
tabHeaderWrapper:
|
||||||
|
'justify-between items-center p-3 border-b border-base-content/10',
|
||||||
|
tab: 'w-fit',
|
||||||
|
content: 'p-0 m-0',
|
||||||
|
}}
|
||||||
|
sideContent={tabActions[activeTabId] || null}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type PurchasesPerSupplierFilterType = {
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
area_ids: string | null;
|
||||||
|
supplier_ids: string | null;
|
||||||
|
product_ids: string | null;
|
||||||
|
product_category_ids: string | null;
|
||||||
|
filter_by: string | null;
|
||||||
|
sort_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PurchasesPerSupplierFilterSchema = yup.object({
|
||||||
|
start_date: yup.string().optional().nullable(),
|
||||||
|
end_date: yup
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.test(
|
||||||
|
'is-greater-than-start',
|
||||||
|
'Tanggal akhir tidak boleh masa lampau',
|
||||||
|
function (value) {
|
||||||
|
const { start_date } = this.parent;
|
||||||
|
if (!start_date || !value) return true;
|
||||||
|
return new Date(value) >= new Date(start_date);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
area_ids: yup.string().nullable(),
|
||||||
|
supplier_ids: yup.string().nullable(),
|
||||||
|
product_ids: yup.string().nullable(),
|
||||||
|
product_category_ids: yup.string().nullable(),
|
||||||
|
filter_by: yup.string().nullable(),
|
||||||
|
sort_by: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PurchasesPerSupplierFilterValues = yup.InferType<
|
||||||
|
typeof PurchasesPerSupplierFilterSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const PurchasePerSupplierSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<LogisticPurchasePerSupplierReport['rows'][0]>[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchasePerSupplierSkeleton;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,289 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Table from '@/components/Table';
|
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
import Collapse from '@/components/Collapse';
|
|
||||||
|
|
||||||
import {
|
|
||||||
cn,
|
|
||||||
formatCurrency,
|
|
||||||
formatDate,
|
|
||||||
formatNumber,
|
|
||||||
formatVechicleNumber,
|
|
||||||
} from '@/lib/helper';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { DailyMarketingRow } from '@/types/api/report/marketing';
|
|
||||||
import { MarketingReportApi } from '@/services/api/report/marketing-report';
|
|
||||||
|
|
||||||
interface DailyMarketingsTableProps {
|
|
||||||
dailyMarketingsReportUrl: string;
|
|
||||||
onSetPage: (page: number) => void;
|
|
||||||
pageSize: number;
|
|
||||||
onSetPageSize: (pageSize: number) => void;
|
|
||||||
searchValue: string;
|
|
||||||
onSearchChange: ChangeEventHandler<HTMLInputElement>;
|
|
||||||
onFilterByChange: (filterBy: string) => void;
|
|
||||||
onSortByChange: (sort: 'asc' | 'desc' | '') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DailyMarketingsTable = ({
|
|
||||||
dailyMarketingsReportUrl,
|
|
||||||
onSetPage,
|
|
||||||
pageSize,
|
|
||||||
onSetPageSize,
|
|
||||||
searchValue,
|
|
||||||
onSearchChange,
|
|
||||||
onFilterByChange,
|
|
||||||
onSortByChange,
|
|
||||||
}: DailyMarketingsTableProps) => {
|
|
||||||
const { data: dailyMarketings, isLoading: isLoadingDailyMarketings } = useSWR(
|
|
||||||
dailyMarketingsReportUrl,
|
|
||||||
MarketingReportApi.getAllDailyMarketingFetcher,
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
|
|
||||||
const dailyMarketingColumns: ColumnDef<DailyMarketingRow>[] = [
|
|
||||||
{
|
|
||||||
header: 'No',
|
|
||||||
cell: (props) => props.row.index + 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'so_date',
|
|
||||||
header: 'Tanggal Jual',
|
|
||||||
cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'),
|
|
||||||
footer: 'Total',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'realization_date',
|
|
||||||
header: 'Tanggal Realisasi',
|
|
||||||
cell: (props) =>
|
|
||||||
formatDate(props.row.original.realization_date, 'DD-MMM-YYYY'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'aging_days',
|
|
||||||
header: 'Aging',
|
|
||||||
cell: (props) => `${props.row.original.aging_days} hari`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'warehouse',
|
|
||||||
header: 'Gudang',
|
|
||||||
cell: ({ row }) => row.original.warehouse.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'customer',
|
|
||||||
header: 'Pelanggan',
|
|
||||||
cell: ({ row }) => row.original.customer.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'do_number',
|
|
||||||
header: 'No. DO',
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'sales_person',
|
|
||||||
header: 'Sales/Marketing',
|
|
||||||
cell: (props) => props.row.original.sales.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'vehicle_number',
|
|
||||||
header: 'No. Polisi',
|
|
||||||
cell: (props) => (
|
|
||||||
<span className='text-nowrap'>
|
|
||||||
{formatVechicleNumber(props.row.original.vehicle_number)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'marketing_type',
|
|
||||||
header: 'Marketing Type',
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'product',
|
|
||||||
header: 'Produk',
|
|
||||||
cell: ({ row }) => row.original.product.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'qty',
|
|
||||||
header: 'Kuantitas',
|
|
||||||
cell: (props) => formatNumber(props.row.original.qty),
|
|
||||||
footer: () => {
|
|
||||||
const totalQty = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.total_qty
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalQty ? formatNumber(totalQty) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'average_weight',
|
|
||||||
header: 'Bobot Rata-Rata (Kg)',
|
|
||||||
cell: (props) => formatNumber(props.row.original.average_weight_kg),
|
|
||||||
footer: () => {
|
|
||||||
const totalAverageWeightKg = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.average_weight_kg
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalAverageWeightKg ? formatNumber(totalAverageWeightKg) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'total_weight',
|
|
||||||
header: 'Bobot Total (Kg)',
|
|
||||||
cell: (props) => formatNumber(props.row.original.total_weight_kg),
|
|
||||||
footer: () => {
|
|
||||||
const totalWeightKg = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.total_weight_kg
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalWeightKg ? formatNumber(totalWeightKg) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'sales_price',
|
|
||||||
header: 'Harga Jual (Rp)',
|
|
||||||
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
|
|
||||||
footer: () => {
|
|
||||||
const totalSalesPrice = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.average_sales_price
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalSalesPrice ? formatNumber(totalSalesPrice) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'hpp_price',
|
|
||||||
header: 'HPP (Rp)',
|
|
||||||
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
|
|
||||||
footer: () => {
|
|
||||||
const totalHppPricePerKg = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.total_hpp_price_per_kg
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalHppPricePerKg ? formatCurrency(totalHppPricePerKg) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'sales_amount',
|
|
||||||
header: 'Total (Rp)',
|
|
||||||
cell: (props) => formatCurrency(props.row.original.sales_amount),
|
|
||||||
footer: () => {
|
|
||||||
const totalSalesAmount = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.total_sales_amount
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalSalesAmount ? formatCurrency(totalSalesAmount) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sorting.length === 1) {
|
|
||||||
onFilterByChange(sorting[0].id);
|
|
||||||
onSortByChange(sorting[0].desc ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
onFilterByChange('');
|
|
||||||
onSortByChange('');
|
|
||||||
}
|
|
||||||
}, [sorting]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setOpen(
|
|
||||||
isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings.data.length > 0
|
|
||||||
: false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [dailyMarketings, isResponseSuccess]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
body: 'p-4 shadow',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Collapse
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
title={
|
|
||||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
|
||||||
<div className='card-title'>Penjualan Harian</div>
|
|
||||||
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:keyboard-arrow-down'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className={cn('text-primary transition-transform', {
|
|
||||||
'-rotate-180': open,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className='w-full!'
|
|
||||||
titleClassName='w-full p-0!'
|
|
||||||
>
|
|
||||||
<div className='w-full p-0'>
|
|
||||||
<div className='flex flex-col gap-2 mb-4'>
|
|
||||||
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
|
||||||
<DebouncedTextInput
|
|
||||||
name='search'
|
|
||||||
placeholder='Cari Penjualan Harian'
|
|
||||||
value={searchValue}
|
|
||||||
onChange={onSearchChange}
|
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Table<DailyMarketingRow>
|
|
||||||
data={
|
|
||||||
isResponseSuccess(dailyMarketings) ? dailyMarketings?.data : []
|
|
||||||
}
|
|
||||||
columns={dailyMarketingColumns}
|
|
||||||
pageSize={pageSize}
|
|
||||||
onPageSizeChange={onSetPageSize}
|
|
||||||
rowOptions={[10, 20, 50, 100]}
|
|
||||||
page={
|
|
||||||
isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.meta?.page
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
totalItems={
|
|
||||||
isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.meta?.total_results
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onPageChange={onSetPage}
|
|
||||||
isLoading={isLoadingDailyMarketings}
|
|
||||||
sorting={sorting}
|
|
||||||
setSorting={setSorting}
|
|
||||||
renderFooter={true}
|
|
||||||
className={{
|
|
||||||
containerClassName: cn({
|
|
||||||
'w-full mb-20':
|
|
||||||
isResponseSuccess(dailyMarketings) &&
|
|
||||||
dailyMarketings?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Collapse>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DailyMarketingsTable;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { JSX, useState } from 'react';
|
|
||||||
|
|
||||||
import Tabs from '@/components/Tabs';
|
|
||||||
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingReportContent';
|
|
||||||
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab';
|
|
||||||
|
|
||||||
type MarketingReportTabType =
|
|
||||||
| 'daily'
|
|
||||||
| 'transaction'
|
|
||||||
| 'hpp-comparison'
|
|
||||||
| 'daily-hpp';
|
|
||||||
|
|
||||||
const marketingReportTabs: {
|
|
||||||
id: MarketingReportTabType;
|
|
||||||
label: string;
|
|
||||||
content: JSX.Element;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
id: 'daily',
|
|
||||||
label: 'Penjualan Harian',
|
|
||||||
content: <DailyMarketingReportContent />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'daily-hpp',
|
|
||||||
label: 'HPP Harian Kandang',
|
|
||||||
content: <HppPerKandangTab />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const MarketingReportContent = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState<string>('daily');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className='w-full max-w-full pb-16'>
|
|
||||||
<Tabs
|
|
||||||
activeTabId={activeTab}
|
|
||||||
onTabChange={setActiveTab}
|
|
||||||
tabs={marketingReportTabs}
|
|
||||||
variant='lifted'
|
|
||||||
className={{
|
|
||||||
content: '-m-px pl-px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MarketingReportContent;
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Tabs from '@/components/Tabs';
|
||||||
|
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab';
|
||||||
|
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
|
||||||
|
const MarketingReportContent = () => {
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Penjualan Harian',
|
||||||
|
content: <DailyMarketingReportContent tabId={'1'} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
label: 'HPP Harian Kandang',
|
||||||
|
content: <HppPerKandangTab tabId={'2'} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className='w-full'>
|
||||||
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
variant='boxed'
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onTabChange={setActiveTabId}
|
||||||
|
className={{
|
||||||
|
tabHeaderWrapper:
|
||||||
|
'justify-between items-center p-3 border-b border-base-content/10',
|
||||||
|
tab: 'w-fit',
|
||||||
|
content: 'p-0 m-0',
|
||||||
|
}}
|
||||||
|
sideContent={tabActions[activeTabId] || null}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarketingReportContent;
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatNumber,
|
||||||
|
formatDate,
|
||||||
|
formatVechicleNumber,
|
||||||
|
} from '@/lib/helper';
|
||||||
|
import { DailyMarketingRow, SalesSummary } from '@/types/api/report/marketing';
|
||||||
|
|
||||||
|
interface DailyMarketingExportExcelParams {
|
||||||
|
data: DailyMarketingRow[];
|
||||||
|
summaryTotal?: SalesSummary;
|
||||||
|
period?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateDailyMarketingExcel = async (
|
||||||
|
params: DailyMarketingExportExcelParams
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!params.data || params.data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
|
||||||
|
// ===== DAILY MARKETING WORKSHEET =====
|
||||||
|
const columns = [
|
||||||
|
{ header: 'No', key: 'no', width: 5 },
|
||||||
|
{ header: 'Tanggal Jual', key: 'soDate', width: 15 },
|
||||||
|
{ header: 'Tanggal Realisasi', key: 'realizationDate', width: 18 },
|
||||||
|
{ header: 'Aging', key: 'aging', width: 10 },
|
||||||
|
{ header: 'Gudang', key: 'warehouse', width: 25 },
|
||||||
|
{ header: 'Pelanggan', key: 'customer', width: 25 },
|
||||||
|
{ header: 'No. DO', key: 'doNumber', width: 15 },
|
||||||
|
{ header: 'Sales/Marketing', key: 'sales', width: 20 },
|
||||||
|
{ header: 'No. Polisi', key: 'vehicleNumber', width: 15 },
|
||||||
|
{ header: 'Marketing Type', key: 'marketingType', width: 15 },
|
||||||
|
{ header: 'Produk', key: 'product', width: 20 },
|
||||||
|
{ header: 'Kuantitas', key: 'qty', width: 12 },
|
||||||
|
{ header: 'Bobot Rata-Rata (Kg)', key: 'averageWeight', width: 20 },
|
||||||
|
{ header: 'Bobot Total (Kg)', key: 'totalWeight', width: 18 },
|
||||||
|
{ header: 'Harga Jual (Rp)', key: 'salesPrice', width: 18 },
|
||||||
|
{ header: 'HPP (Rp)', key: 'hppPrice', width: 15 },
|
||||||
|
{ header: 'HPP Amount (Rp)', key: 'hppAmount', width: 20 },
|
||||||
|
{ header: 'Total (Rp)', key: 'salesAmount', width: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const worksheet = workbook.addWorksheet('Laporan Marketing Harian');
|
||||||
|
worksheet.columns = columns;
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
params.data.forEach((item: DailyMarketingRow, index: number) => {
|
||||||
|
worksheet.addRow({
|
||||||
|
no: index + 1,
|
||||||
|
soDate: formatDate(item.so_date, 'DD-MMM-YYYY'),
|
||||||
|
realizationDate: formatDate(item.realization_date, 'DD-MMM-YYYY'),
|
||||||
|
aging: `${item.aging_days} hari`,
|
||||||
|
warehouse: item.warehouse?.name || '',
|
||||||
|
customer: item.customer?.name || '',
|
||||||
|
doNumber: item.do_number || '',
|
||||||
|
sales: item.sales?.name || '',
|
||||||
|
vehicleNumber: formatVechicleNumber(item.vehicle_number),
|
||||||
|
marketingType: item.marketing_type || '',
|
||||||
|
product: item.product?.name || '',
|
||||||
|
qty: formatNumber(item.qty || 0),
|
||||||
|
averageWeight: formatNumber(item.average_weight_kg || 0),
|
||||||
|
totalWeight: formatNumber(item.total_weight_kg || 0),
|
||||||
|
salesPrice: formatCurrency(item.sales_price_per_kg || 0),
|
||||||
|
hppPrice: formatCurrency(item.hpp_price_per_kg || 0),
|
||||||
|
hppAmount: formatCurrency(item.hpp_amount || 0),
|
||||||
|
salesAmount: formatCurrency(item.sales_amount || 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add TOTAL row if summary data is available
|
||||||
|
if (params.summaryTotal) {
|
||||||
|
worksheet.addRow({
|
||||||
|
no: 'TOTAL',
|
||||||
|
soDate: '',
|
||||||
|
realizationDate: '',
|
||||||
|
aging: '',
|
||||||
|
warehouse: '',
|
||||||
|
customer: '',
|
||||||
|
doNumber: '',
|
||||||
|
sales: '',
|
||||||
|
vehicleNumber: '',
|
||||||
|
marketingType: '',
|
||||||
|
product: '',
|
||||||
|
qty: formatNumber(params.summaryTotal.total_qty || 0),
|
||||||
|
averageWeight: formatNumber(params.summaryTotal.average_weight_kg || 0),
|
||||||
|
totalWeight: formatNumber(params.summaryTotal.total_weight_kg || 0),
|
||||||
|
salesPrice: formatCurrency(params.summaryTotal.average_sales_price || 0),
|
||||||
|
hppPrice: formatCurrency(params.summaryTotal.total_hpp_price_per_kg || 0),
|
||||||
|
hppAmount: formatCurrency(params.summaryTotal.total_hpp_amount || 0),
|
||||||
|
salesAmount: formatCurrency(params.summaryTotal.total_sales_amount || 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
worksheet.columns.forEach((column) => {
|
||||||
|
if (column.width && column.width < 10) {
|
||||||
|
column.width = 10;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentDate = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = params.period
|
||||||
|
? `laporan-marketing-harian-${params.period}.xlsx`
|
||||||
|
: `laporan-marketing-harian-${currentDate}.xlsx`;
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type DailyMarketingReportFilterType = {
|
||||||
|
search: string | null;
|
||||||
|
area_id: string | null;
|
||||||
|
location_id: string | null;
|
||||||
|
warehouse_id: string | null;
|
||||||
|
customer_id: string | null;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
marketing_type: string | null;
|
||||||
|
filter_by: string | null;
|
||||||
|
sort_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DailyMarketingReportFilterSchema = yup.object({
|
||||||
|
search: yup.string().nullable(),
|
||||||
|
area_id: yup.string().nullable(),
|
||||||
|
location_id: yup.string().nullable(),
|
||||||
|
warehouse_id: yup.string().nullable(),
|
||||||
|
customer_id: yup.string().nullable(),
|
||||||
|
start_date: yup.string().nullable(),
|
||||||
|
end_date: yup
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.test(
|
||||||
|
'is-greater-than-start',
|
||||||
|
'Tanggal akhir tidak boleh masa lampau',
|
||||||
|
function (value) {
|
||||||
|
const { start_date } = this.parent;
|
||||||
|
if (!start_date || !value) return true;
|
||||||
|
return new Date(value) >= new Date(start_date);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
marketing_type: yup.string().nullable(),
|
||||||
|
filter_by: yup.string().nullable(),
|
||||||
|
sort_by: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DailyMarketingReportFilterValues = yup.InferType<
|
||||||
|
typeof DailyMarketingReportFilterSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type HppPerKandangFilterType = {
|
||||||
|
area_id: string | null;
|
||||||
|
location_id: string | null;
|
||||||
|
kandang_id: string | null;
|
||||||
|
weight_min: string | null;
|
||||||
|
weight_max: string | null;
|
||||||
|
period: string | null;
|
||||||
|
sort_by: string | null;
|
||||||
|
show_unrecorded: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HppPerKandangFilterSchema = yup.object({
|
||||||
|
area_id: yup.string().nullable(),
|
||||||
|
location_id: yup.string().nullable(),
|
||||||
|
kandang_id: yup.string().nullable(),
|
||||||
|
weight_min: yup.string().nullable(),
|
||||||
|
weight_max: yup
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.test(
|
||||||
|
'is-greater-than-min',
|
||||||
|
'Rentang bobot max tidak boleh lebih kecil dari min',
|
||||||
|
function (value) {
|
||||||
|
const { weight_min } = this.parent;
|
||||||
|
if (!weight_min || !value) return true;
|
||||||
|
const weightMinNum = parseFloat(weight_min) || 0;
|
||||||
|
const weightMaxNum = parseFloat(value) || 0;
|
||||||
|
return weightMaxNum >= weightMinNum;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
period: yup.string().required('Periode wajib diisi'),
|
||||||
|
sort_by: yup.string().nullable(),
|
||||||
|
show_unrecorded: yup.boolean().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HppPerKandangFilterValues = yup.InferType<
|
||||||
|
typeof HppPerKandangFilterSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { DailyMarketingRow } from '@/types/api/report/marketing.d';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const DailyMarketingReportSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<DailyMarketingRow>[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DailyMarketingReportSkeleton;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const HppPerKandangSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<HppPerKandangReport['rows'][0]>[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HppPerKandangSkeleton;
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
|
||||||
import { pdf } from '@react-pdf/renderer';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import Dropdown from '@/components/dropdown/Dropdown';
|
|
||||||
import DateInput from '@/components/input/DateInput';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
|
||||||
import DailyMarketingsTable from '@/components/pages/report/marketing/DailyMarketingsTable';
|
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
|
||||||
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingReportPDF';
|
|
||||||
|
|
||||||
import { Area } from '@/types/api/master-data/area';
|
|
||||||
import {
|
|
||||||
AreaApi,
|
|
||||||
CustomerApi,
|
|
||||||
LocationApi,
|
|
||||||
WarehouseApi,
|
|
||||||
} from '@/services/api/master-data';
|
|
||||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
|
||||||
import { Customer } from '@/types/api/master-data/customer';
|
|
||||||
import { MarketingReportApi } from '@/services/api/report/marketing-report';
|
|
||||||
import {
|
|
||||||
MARKETING_DATE_FILTER_TYPE_OPTIONS,
|
|
||||||
MARKETING_TYPE_OPTIONS,
|
|
||||||
} from '@/config/constant';
|
|
||||||
import { httpClient } from '@/services/http/client';
|
|
||||||
import { BaseApiResponse } from '@/types/api/api-general';
|
|
||||||
import {
|
|
||||||
DailyMarketingReport,
|
|
||||||
DailyMarketingReportResponse,
|
|
||||||
} from '@/types/api/report/marketing';
|
|
||||||
import { isResponseError } from '@/lib/api-helper';
|
|
||||||
|
|
||||||
const DailyMarketingReportContent = () => {
|
|
||||||
const {
|
|
||||||
state: tableFilterState,
|
|
||||||
updateFilter,
|
|
||||||
setPage,
|
|
||||||
setPageSize,
|
|
||||||
toQueryString: getTableFilterQueryString,
|
|
||||||
reset: resetFilter,
|
|
||||||
} = useTableFilter({
|
|
||||||
initial: {
|
|
||||||
search: '',
|
|
||||||
area_id: '',
|
|
||||||
location_id: '',
|
|
||||||
warehouse_id: '',
|
|
||||||
customer_id: '',
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
marketing_type: '',
|
|
||||||
filter_by: '',
|
|
||||||
sort_by: '',
|
|
||||||
},
|
|
||||||
paramMap: {
|
|
||||||
page: 'page',
|
|
||||||
pageSize: 'limit',
|
|
||||||
area_id: 'area_id',
|
|
||||||
location_id: 'location_id',
|
|
||||||
warehouse_id: 'warehouse_id',
|
|
||||||
customer_id: 'customer_id',
|
|
||||||
start_date: 'start_date',
|
|
||||||
end_date: 'end_date',
|
|
||||||
marketing_type: 'marketing_type',
|
|
||||||
filter_by: 'filter_by',
|
|
||||||
sort_by: 'sort_by',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const dailyMarketingsReportUrl = `${MarketingReportApi.basePath}${getTableFilterQueryString()}`;
|
|
||||||
|
|
||||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
|
||||||
useState(false);
|
|
||||||
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
|
|
||||||
|
|
||||||
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
|
|
||||||
const {
|
|
||||||
setInputValue: setAreaInputValue,
|
|
||||||
options: areaOptions,
|
|
||||||
isLoadingOptions: isLoadingAreaOptions,
|
|
||||||
loadMore: loadMoreAreas,
|
|
||||||
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedArea(val as OptionType);
|
|
||||||
updateFilter('area_id', val ? ((val as OptionType).value as string) : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
setInputValue: setLocationInputValue,
|
|
||||||
options: locationOptions,
|
|
||||||
isLoadingOptions: isLoadingLocationOptions,
|
|
||||||
loadMore: loadMoreLocations,
|
|
||||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedLocation(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'location_id',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedWarehouse, setSelectedWarehouse] = useState<OptionType | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
setInputValue: setWarehouseInputValue,
|
|
||||||
options: warehouseOptions,
|
|
||||||
isLoadingOptions: isLoadingWarehouseOptions,
|
|
||||||
loadMore: loadMoreWarehouses,
|
|
||||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedWarehouse(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'warehouse_id',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedCustomer, setSelectedCustomer] = useState<OptionType | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
setInputValue: setCustomerInputValue,
|
|
||||||
options: customerOptions,
|
|
||||||
isLoadingOptions: isLoadingCustomerOptions,
|
|
||||||
loadMore: loadMoreCustomers,
|
|
||||||
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedCustomer(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'customer_id',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
updateFilter('start_date', e.target.value ? e.target.value : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const endDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
updateFilter('end_date', e.target.value ? e.target.value : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedMarketingDateFilterType, setSelectedMarketingDateFilterType] =
|
|
||||||
useState<OptionType | null>(null);
|
|
||||||
const marketingDateFilterTypeChangeHandler = (
|
|
||||||
val: OptionType | OptionType[] | null
|
|
||||||
) => {
|
|
||||||
setSelectedMarketingDateFilterType(val as OptionType);
|
|
||||||
updateFilter('filter_by', val ? ((val as OptionType).value as string) : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedMarketingType, setSelectedMarketingType] =
|
|
||||||
useState<OptionType | null>(null);
|
|
||||||
const marketingTypeChangeHandler = (
|
|
||||||
val: OptionType | OptionType[] | null
|
|
||||||
) => {
|
|
||||||
setSelectedMarketingType(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'marketing_type',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
updateFilter('search', e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterByChangeHandler = (filterBy: string) => {
|
|
||||||
updateFilter('filter_by', filterBy);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortByChangeHandler = (sort: 'asc' | 'desc' | '') => {
|
|
||||||
updateFilter('sort_by', sort);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToExcelHandler = async () => {
|
|
||||||
setIsLoadingExportingToExcel(true);
|
|
||||||
|
|
||||||
await MarketingReportApi.exportDailyMarketingToExcel(
|
|
||||||
getTableFilterQueryString()
|
|
||||||
);
|
|
||||||
|
|
||||||
setIsLoadingExportingToExcel(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToPdfHandler = async () => {
|
|
||||||
setIsLoadingExportingToPdf(true);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(getTableFilterQueryString());
|
|
||||||
|
|
||||||
params.set('limit', '9999999');
|
|
||||||
|
|
||||||
const queryString = `?${params.toString()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dailyMarketingsReport =
|
|
||||||
await httpClient<DailyMarketingReportResponse>(
|
|
||||||
`${MarketingReportApi.basePath}${queryString}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isResponseError(dailyMarketingsReport)) {
|
|
||||||
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPdf = async () => {
|
|
||||||
const dailyMarketingReportPdfBlob = await pdf(
|
|
||||||
<DailyMarketingReportPDF
|
|
||||||
data={dailyMarketingsReport.data}
|
|
||||||
total={dailyMarketingsReport.total}
|
|
||||||
/>
|
|
||||||
).toBlob();
|
|
||||||
|
|
||||||
const dailyMarketingReportPdfUrl = URL.createObjectURL(
|
|
||||||
dailyMarketingReportPdfBlob
|
|
||||||
);
|
|
||||||
window.open(dailyMarketingReportPdfUrl, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadPdf = async () => {
|
|
||||||
const blob = await pdf(
|
|
||||||
<DailyMarketingReportPDF
|
|
||||||
data={dailyMarketingsReport.data}
|
|
||||||
total={dailyMarketingsReport.total}
|
|
||||||
/>
|
|
||||||
).toBlob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = 'laporan-penjualan-harian.pdf';
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
await openPdf();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingExportingToPdf(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setSelectedArea(null);
|
|
||||||
setSelectedLocation(null);
|
|
||||||
setSelectedWarehouse(null);
|
|
||||||
setSelectedCustomer(null);
|
|
||||||
setSelectedMarketingType(null);
|
|
||||||
resetFilter();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
tableFilterState.filter_by === 'realization_date' ||
|
|
||||||
tableFilterState.filter_by === 'so_date'
|
|
||||||
) {
|
|
||||||
setSelectedMarketingDateFilterType({
|
|
||||||
label:
|
|
||||||
tableFilterState.filter_by === 'realization_date'
|
|
||||||
? 'Tanggal Realisasi'
|
|
||||||
: 'Tanggal SO',
|
|
||||||
value: tableFilterState.filter_by,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSelectedMarketingDateFilterType(null);
|
|
||||||
}
|
|
||||||
}, [tableFilterState.filter_by]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full border border-gray-200 p-4'>
|
|
||||||
<div>
|
|
||||||
<h2 className='text-xl font-bold text-center'>Penjualan Harian</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className='flex flex-col gap-4 mb-6'>
|
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
label='Area'
|
|
||||||
placeholder='Pilih Area'
|
|
||||||
options={areaOptions}
|
|
||||||
isLoading={isLoadingAreaOptions}
|
|
||||||
value={selectedArea}
|
|
||||||
onChange={areaChangeHandler}
|
|
||||||
onInputChange={setAreaInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreAreas}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Lokasi'
|
|
||||||
placeholder='Pilih Lokasi'
|
|
||||||
options={locationOptions}
|
|
||||||
isLoading={isLoadingLocationOptions}
|
|
||||||
value={selectedLocation}
|
|
||||||
onChange={locationChangeHandler}
|
|
||||||
onInputChange={setLocationInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreLocations}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Gudang'
|
|
||||||
placeholder='Pilih Gudang'
|
|
||||||
options={warehouseOptions}
|
|
||||||
isLoading={isLoadingWarehouseOptions}
|
|
||||||
value={selectedWarehouse}
|
|
||||||
onChange={warehouseChangeHandler}
|
|
||||||
onInputChange={setWarehouseInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreWarehouses}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Customer'
|
|
||||||
placeholder='Pilih Customer'
|
|
||||||
options={customerOptions}
|
|
||||||
isLoading={isLoadingCustomerOptions}
|
|
||||||
value={selectedCustomer}
|
|
||||||
onChange={customerChangeHandler}
|
|
||||||
onInputChange={setCustomerInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreCustomers}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DateInput
|
|
||||||
name='startDate'
|
|
||||||
label='Tanggal Awal'
|
|
||||||
placeholder='Tanggal Realisasi'
|
|
||||||
value={tableFilterState.start_date}
|
|
||||||
onChange={startDateChangeHandler}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DateInput
|
|
||||||
name='endDate'
|
|
||||||
label='Tanggal Akhir'
|
|
||||||
placeholder='Tanggal Realisasi'
|
|
||||||
value={tableFilterState.end_date}
|
|
||||||
onChange={endDateChangeHandler}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
label='Filter Tanggal'
|
|
||||||
placeholder='Pilih Filter Tanggal'
|
|
||||||
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
|
|
||||||
value={selectedMarketingDateFilterType}
|
|
||||||
onChange={marketingDateFilterTypeChangeHandler}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Tipe Marketing'
|
|
||||||
placeholder='Pilih Tipe Marketing'
|
|
||||||
options={MARKETING_TYPE_OPTIONS}
|
|
||||||
value={selectedMarketingType}
|
|
||||||
onChange={marketingTypeChangeHandler}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='col-span-12 sm:col-span-6 lg:col-span-8 flex flex-wrap sm:justify-end items-end gap-2'>
|
|
||||||
<Button
|
|
||||||
color='primary'
|
|
||||||
// onClick={handleSearch}
|
|
||||||
className='flex-1 sm:flex-none'
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
|
|
||||||
Cari
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color='warning'
|
|
||||||
onClick={handleReset}
|
|
||||||
className='flex-1 sm:flex-none'
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons-outline:refresh' width={20} height={20} />
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dropdown
|
|
||||||
align='end'
|
|
||||||
direction='bottom'
|
|
||||||
trigger={
|
|
||||||
<Button>
|
|
||||||
Export{' '}
|
|
||||||
<Icon
|
|
||||||
icon='heroicons-outline:download'
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Menu>
|
|
||||||
<MenuItem
|
|
||||||
title='Export to Excel'
|
|
||||||
icon='icon-park-outline:excel'
|
|
||||||
isLoading={isLoadingExportingToExcel}
|
|
||||||
onClick={exportToExcelHandler}
|
|
||||||
className='text-nowrap'
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
title='Export to PDF'
|
|
||||||
icon='icon-park-outline:file-pdf-one'
|
|
||||||
onClick={exportToPdfHandler}
|
|
||||||
className='text-nowrap'
|
|
||||||
/>
|
|
||||||
</Menu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DailyMarketingsTable
|
|
||||||
dailyMarketingsReportUrl={dailyMarketingsReportUrl}
|
|
||||||
onSetPage={setPage}
|
|
||||||
pageSize={tableFilterState.pageSize}
|
|
||||||
onSetPageSize={setPageSize}
|
|
||||||
searchValue={tableFilterState.search}
|
|
||||||
onSearchChange={searchChangeHandler}
|
|
||||||
onFilterByChange={filterByChangeHandler}
|
|
||||||
onSortByChange={sortByChangeHandler}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DailyMarketingReportContent;
|
|
||||||
@@ -0,0 +1,939 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import { AreaApi } from '@/services/api/master-data';
|
||||||
|
import { LocationApi } from '@/services/api/master-data';
|
||||||
|
import { WarehouseApi } from '@/services/api/master-data';
|
||||||
|
import { CustomerApi } from '@/services/api/master-data';
|
||||||
|
import { MarketingReportApi } from '@/services/api/report/marketing-report';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatNumber,
|
||||||
|
formatDate,
|
||||||
|
formatVechicleNumber,
|
||||||
|
formatTitleCase,
|
||||||
|
} from '@/lib/helper';
|
||||||
|
import {
|
||||||
|
DailyMarketingRow,
|
||||||
|
DailyMarketingReportResponse,
|
||||||
|
} from '@/types/api/report/marketing';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Dropdown from '@/components/Dropdown';
|
||||||
|
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF';
|
||||||
|
import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import {
|
||||||
|
DailyMarketingReportFilterSchema,
|
||||||
|
DailyMarketingReportFilterType,
|
||||||
|
} from '@/components/pages/report/marketing/filter/DailyMarketingFilter';
|
||||||
|
import SelectInput from '@/components/input/SelectInput';
|
||||||
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
|
||||||
|
import { useEffect as useEffectHook } from 'react';
|
||||||
|
import { httpClient } from '@/services/http/client';
|
||||||
|
import { isResponseError } from '@/lib/api-helper';
|
||||||
|
import {
|
||||||
|
MARKETING_DATE_FILTER_TYPE_OPTIONS,
|
||||||
|
MARKETING_TYPE_OPTIONS,
|
||||||
|
} from '@/config/constant';
|
||||||
|
import Badge from '@/components/Badge';
|
||||||
|
|
||||||
|
interface DailyMarketingTabProps {
|
||||||
|
tabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterParams {
|
||||||
|
area_id?: string;
|
||||||
|
location_id?: string;
|
||||||
|
warehouse_id?: string;
|
||||||
|
customer_id?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
filter_by?: string;
|
||||||
|
marketing_type?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||||
|
// ===== STATE MANAGEMENT =====
|
||||||
|
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||||
|
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||||
|
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||||
|
|
||||||
|
// ===== SUBMISSION STATE =====
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
|
||||||
|
// ===== SEARCH STATE =====
|
||||||
|
const [searchValue, setSearchValue] = useState<string>('');
|
||||||
|
|
||||||
|
// ===== FILTER STATE =====
|
||||||
|
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||||
|
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== OPTIONS =====
|
||||||
|
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
|
||||||
|
AreaApi.basePath,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { options: locationOptions, isLoadingOptions: isLoadingLocations } =
|
||||||
|
useSelect(LocationApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const { options: warehouseOptions, isLoadingOptions: isLoadingWarehouses } =
|
||||||
|
useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } =
|
||||||
|
useSelect(CustomerApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<DailyMarketingReportFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
search: null,
|
||||||
|
area_id: null,
|
||||||
|
location_id: null,
|
||||||
|
warehouse_id: null,
|
||||||
|
customer_id: null,
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
filter_by: null,
|
||||||
|
marketing_type: null,
|
||||||
|
sort_by: null,
|
||||||
|
},
|
||||||
|
validationSchema: DailyMarketingReportFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
setFilterParams({
|
||||||
|
area_id: values.area_id || undefined,
|
||||||
|
location_id: values.location_id || undefined,
|
||||||
|
warehouse_id: values.warehouse_id || undefined,
|
||||||
|
customer_id: values.customer_id || undefined,
|
||||||
|
start_date: values.start_date || undefined,
|
||||||
|
end_date: values.end_date || undefined,
|
||||||
|
filter_by: values.filter_by || undefined,
|
||||||
|
marketing_type: values.marketing_type || undefined,
|
||||||
|
sort_by: values.sort_by || undefined,
|
||||||
|
});
|
||||||
|
filterModal.closeModal();
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
setFilterParams({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== SEARCH CHANGE HANDLER =====
|
||||||
|
const searchChangeHandler = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== DERIVED VALUES =====
|
||||||
|
const areaValue = useMemo(() => {
|
||||||
|
if (!formik.values.area_id) return null;
|
||||||
|
return (
|
||||||
|
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.area_id, areaOptions]);
|
||||||
|
|
||||||
|
const locationValue = useMemo(() => {
|
||||||
|
if (!formik.values.location_id) return null;
|
||||||
|
return (
|
||||||
|
locationOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.location_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.location_id, locationOptions]);
|
||||||
|
|
||||||
|
const warehouseValue = useMemo(() => {
|
||||||
|
if (!formik.values.warehouse_id) return null;
|
||||||
|
return (
|
||||||
|
warehouseOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.warehouse_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.warehouse_id, warehouseOptions]);
|
||||||
|
|
||||||
|
const customerValue = useMemo(() => {
|
||||||
|
if (!formik.values.customer_id) return null;
|
||||||
|
return (
|
||||||
|
customerOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.customer_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.customer_id, customerOptions]);
|
||||||
|
|
||||||
|
const filterByValue = useMemo(() => {
|
||||||
|
if (!formik.values.filter_by) return null;
|
||||||
|
return (
|
||||||
|
MARKETING_DATE_FILTER_TYPE_OPTIONS.find(
|
||||||
|
(opt) => opt.value === formik.values.filter_by
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.filter_by]);
|
||||||
|
|
||||||
|
const marketingTypeValue = useMemo(() => {
|
||||||
|
if (!formik.values.marketing_type) return null;
|
||||||
|
return (
|
||||||
|
MARKETING_TYPE_OPTIONS.find(
|
||||||
|
(opt) => opt.value === formik.values.marketing_type
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.marketing_type]);
|
||||||
|
|
||||||
|
// ===== ACTIVE FILTERS COUNT =====
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (filterParams.area_id) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.location_id) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.warehouse_id) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.customer_id) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.start_date || filterParams.end_date) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.filter_by) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.marketing_type) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.sort_by) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
const hasFilters = activeFiltersCount > 0;
|
||||||
|
|
||||||
|
// ===== DATA FETCHING =====
|
||||||
|
const { data: dailyMarketings, isLoading } = useSWR(
|
||||||
|
isSubmitted
|
||||||
|
? () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (searchValue) params.set('search', searchValue);
|
||||||
|
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||||
|
if (filterParams.location_id)
|
||||||
|
params.set('location_id', filterParams.location_id);
|
||||||
|
if (filterParams.warehouse_id)
|
||||||
|
params.set('warehouse_id', filterParams.warehouse_id);
|
||||||
|
if (filterParams.customer_id)
|
||||||
|
params.set('customer_id', filterParams.customer_id);
|
||||||
|
if (filterParams.start_date)
|
||||||
|
params.set('start_date', filterParams.start_date);
|
||||||
|
if (filterParams.end_date)
|
||||||
|
params.set('end_date', filterParams.end_date);
|
||||||
|
if (filterParams.filter_by)
|
||||||
|
params.set('filter_by', filterParams.filter_by);
|
||||||
|
if (filterParams.marketing_type)
|
||||||
|
params.set('marketing_type', filterParams.marketing_type);
|
||||||
|
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||||
|
|
||||||
|
return ['daily-marketing-report', params.toString()];
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
([, params]) =>
|
||||||
|
MarketingReportApi.getAllDailyMarketingFetcher(
|
||||||
|
`${MarketingReportApi.basePath}?${params}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: DailyMarketingRow[] = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(dailyMarketings)
|
||||||
|
? (dailyMarketings?.data as DailyMarketingRow[]) || []
|
||||||
|
: [],
|
||||||
|
[dailyMarketings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(dailyMarketings) && dailyMarketings?.total
|
||||||
|
? dailyMarketings.total
|
||||||
|
: undefined,
|
||||||
|
[dailyMarketings]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== EXPORT DATA FETCHER =====
|
||||||
|
const dailyMarketingsExport = useCallback(async (): Promise<
|
||||||
|
DailyMarketingRow[] | null
|
||||||
|
> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (searchValue) params.set('search', searchValue);
|
||||||
|
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||||
|
if (filterParams.location_id)
|
||||||
|
params.set('location_id', filterParams.location_id);
|
||||||
|
if (filterParams.warehouse_id)
|
||||||
|
params.set('warehouse_id', filterParams.warehouse_id);
|
||||||
|
if (filterParams.customer_id)
|
||||||
|
params.set('customer_id', filterParams.customer_id);
|
||||||
|
if (filterParams.start_date)
|
||||||
|
params.set('start_date', filterParams.start_date);
|
||||||
|
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||||
|
if (filterParams.filter_by) params.set('filter_by', filterParams.filter_by);
|
||||||
|
if (filterParams.marketing_type)
|
||||||
|
params.set('marketing_type', filterParams.marketing_type);
|
||||||
|
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||||
|
params.set('limit', '9999999');
|
||||||
|
|
||||||
|
const queryString = `?${params.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient<DailyMarketingReportResponse>(
|
||||||
|
`${MarketingReportApi.basePath}${queryString}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseError(response)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data || [];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [filterParams, searchValue]);
|
||||||
|
|
||||||
|
// ===== EXPORT HANDLERS =====
|
||||||
|
const handleExportExcel = useCallback(async () => {
|
||||||
|
setIsExcelExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allDataForExport = await dailyMarketingsExport();
|
||||||
|
|
||||||
|
if (!allDataForExport || allDataForExport.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const period =
|
||||||
|
filterParams.start_date && filterParams.end_date
|
||||||
|
? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await generateDailyMarketingExcel({
|
||||||
|
data: allDataForExport,
|
||||||
|
summaryTotal: summaryTotal,
|
||||||
|
period: period,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsExcelExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterParams, dailyMarketingsExport, summaryTotal]);
|
||||||
|
|
||||||
|
const handleExportPDF = useCallback(async () => {
|
||||||
|
setIsPdfExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allDataForExport = await dailyMarketingsExport();
|
||||||
|
|
||||||
|
if (!allDataForExport || allDataForExport.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyMarketingReportPdfBlob = await pdf(
|
||||||
|
<DailyMarketingReportPDF data={allDataForExport} total={summaryTotal} />
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
|
const dailyMarketingReportPdfUrl = URL.createObjectURL(
|
||||||
|
dailyMarketingReportPdfBlob
|
||||||
|
);
|
||||||
|
window.open(dailyMarketingReportPdfUrl, '_blank');
|
||||||
|
|
||||||
|
toast.success('PDF berhasil dibuat.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsPdfExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [dailyMarketingsExport, summaryTotal]);
|
||||||
|
|
||||||
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
|
useEffectHook(() => {
|
||||||
|
setTabActions(
|
||||||
|
tabId,
|
||||||
|
<div className='flex flex-row gap-3 items-center'>
|
||||||
|
<DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Search'
|
||||||
|
value={searchValue}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
startAdornment={
|
||||||
|
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
|
||||||
|
}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full min-w-48 max-w-3xs',
|
||||||
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
|
input: 'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||||
|
{
|
||||||
|
'border-primary-gradient text-primary': hasFilters,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
Filter
|
||||||
|
{hasFilters && (
|
||||||
|
<Badge
|
||||||
|
className={{
|
||||||
|
badge:
|
||||||
|
'p-1.5 bg-[#FF3535] text-xs text-base-100 border border-base-300 rounded-lg',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeFiltersCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
isLoading={isAnyExportLoading}
|
||||||
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
|
>
|
||||||
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:cloud-arrow-down'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
isLoading={isExcelExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
|
Export to Excel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
tabId,
|
||||||
|
searchValue,
|
||||||
|
hasFilters,
|
||||||
|
activeFiltersCount,
|
||||||
|
isAnyExportLoading,
|
||||||
|
filterModal.open,
|
||||||
|
setTabActions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffectHook(() => {
|
||||||
|
return () => {
|
||||||
|
clearTabActions(tabId);
|
||||||
|
};
|
||||||
|
}, [tabId, clearTabActions]);
|
||||||
|
|
||||||
|
const getTableColumns = (): ColumnDef<DailyMarketingRow>[] => {
|
||||||
|
const tableColumns: ColumnDef<DailyMarketingRow>[] = [
|
||||||
|
{
|
||||||
|
id: 'no',
|
||||||
|
header: 'No',
|
||||||
|
cell: (props) => props.row.index + 1,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>TOTAL</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'so_date',
|
||||||
|
header: 'Tanggal Jual',
|
||||||
|
accessorKey: 'so_date',
|
||||||
|
cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'),
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>ALL</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realization_date',
|
||||||
|
header: 'Tanggal Realisasi',
|
||||||
|
accessorKey: 'realization_date',
|
||||||
|
cell: (props) =>
|
||||||
|
formatDate(props.row.original.realization_date, 'DD-MMM-YYYY'),
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aging_days',
|
||||||
|
header: 'Aging',
|
||||||
|
accessorKey: 'aging_days',
|
||||||
|
cell: (props) => `${props.row.original.aging_days} hari`,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'warehouse',
|
||||||
|
header: 'Gudang',
|
||||||
|
accessorKey: 'warehouse',
|
||||||
|
cell: ({ row }) => row.original.warehouse.name,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customer',
|
||||||
|
header: 'Pelanggan',
|
||||||
|
accessorKey: 'customer',
|
||||||
|
cell: ({ row }) => row.original.customer.name,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'do_number',
|
||||||
|
header: 'No. DO',
|
||||||
|
accessorKey: 'do_number',
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales_person',
|
||||||
|
header: 'Sales/Marketing',
|
||||||
|
accessorKey: 'sales',
|
||||||
|
cell: (props) => props.row.original.sales.name,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vehicle_number',
|
||||||
|
header: 'No. Polisi',
|
||||||
|
accessorKey: 'vehicle_number',
|
||||||
|
cell: (props) => (
|
||||||
|
<span className='text-nowrap'>
|
||||||
|
{formatVechicleNumber(props.row.original.vehicle_number)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'marketing_type',
|
||||||
|
header: 'Marketing Type',
|
||||||
|
accessorKey: 'marketing_type',
|
||||||
|
cell: (props) => (
|
||||||
|
<span className='text-nowrap'>
|
||||||
|
{formatTitleCase(props.row.original.marketing_type || '-')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'product',
|
||||||
|
header: 'Produk',
|
||||||
|
accessorKey: 'product',
|
||||||
|
cell: ({ row }) => row.original.product.name,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qty',
|
||||||
|
header: 'Kuantitas',
|
||||||
|
accessorKey: 'qty',
|
||||||
|
cell: (props) => formatNumber(props.row.original.qty),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.total_qty
|
||||||
|
? formatNumber(summaryTotal.total_qty)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'average_weight',
|
||||||
|
header: 'Bobot Rata-Rata (Kg)',
|
||||||
|
accessorKey: 'average_weight_kg',
|
||||||
|
cell: (props) => formatNumber(props.row.original.average_weight_kg),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.average_weight_kg
|
||||||
|
? formatNumber(summaryTotal.average_weight_kg)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'total_weight',
|
||||||
|
header: 'Bobot Total (Kg)',
|
||||||
|
accessorKey: 'total_weight_kg',
|
||||||
|
cell: (props) => formatNumber(props.row.original.total_weight_kg),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.total_weight_kg
|
||||||
|
? formatNumber(summaryTotal.total_weight_kg)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales_price',
|
||||||
|
header: 'Harga Jual (Rp)',
|
||||||
|
accessorKey: 'sales_price_per_kg',
|
||||||
|
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.average_sales_price
|
||||||
|
? formatNumber(summaryTotal.average_sales_price)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hpp_price',
|
||||||
|
header: 'HPP (Rp)',
|
||||||
|
accessorKey: 'hpp_price_per_kg',
|
||||||
|
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.total_hpp_price_per_kg
|
||||||
|
? formatCurrency(summaryTotal.total_hpp_price_per_kg)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales_amount',
|
||||||
|
header: 'Total (Rp)',
|
||||||
|
accessorKey: 'sales_amount',
|
||||||
|
cell: (props) => formatCurrency(props.row.original.sales_amount),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.total_sales_amount
|
||||||
|
? formatCurrency(summaryTotal.total_sales_amount)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return tableColumns;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||||
|
{!isSubmitted ? (
|
||||||
|
<DailyMarketingReportSkeleton
|
||||||
|
columns={getTableColumns()}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:funnel'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='No Filters Selected'
|
||||||
|
subtitle='Please choose filters to narrow down your results and make your search easier.'
|
||||||
|
/>
|
||||||
|
) : isLoading ? (
|
||||||
|
<DailyMarketingReportSkeleton
|
||||||
|
columns={getTableColumns()}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-report'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='Memuat Data Penjualan Harian'
|
||||||
|
subtitle='Silakan tunggu sebentar...'
|
||||||
|
/>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<DailyMarketingReportSkeleton
|
||||||
|
columns={getTableColumns()}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:chart-bar'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='Data Not Yet Available'
|
||||||
|
subtitle='Please change your filters to get the data.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
data={data}
|
||||||
|
columns={getTableColumns()}
|
||||||
|
renderFooter={data.length > 0}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName: 'overflow-x-auto',
|
||||||
|
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 text-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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-3'>
|
||||||
|
{/* Area Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Area'
|
||||||
|
placeholder='Pilih Area'
|
||||||
|
options={areaOptions}
|
||||||
|
isLoading={isLoadingAreas}
|
||||||
|
value={areaValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'area_id',
|
||||||
|
val && !Array.isArray(val) ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Location Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
isLoading={isLoadingLocations}
|
||||||
|
value={locationValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'location_id',
|
||||||
|
val && !Array.isArray(val) ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Warehouse Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Gudang'
|
||||||
|
placeholder='Pilih Gudang'
|
||||||
|
options={warehouseOptions}
|
||||||
|
isLoading={isLoadingWarehouses}
|
||||||
|
value={warehouseValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'warehouse_id',
|
||||||
|
val && !Array.isArray(val) ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Customer Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Customer'
|
||||||
|
placeholder='Pilih Customer'
|
||||||
|
options={customerOptions}
|
||||||
|
isLoading={isLoadingCustomers}
|
||||||
|
value={customerValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'customer_id',
|
||||||
|
val && !Array.isArray(val) ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Date Range Filter */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-semibold text-base-content py-2'>
|
||||||
|
Rentang Tanggal
|
||||||
|
</label>
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<DateInput
|
||||||
|
name='start_date'
|
||||||
|
label='Tanggal Awal'
|
||||||
|
placeholder='Pilih Tanggal Awal'
|
||||||
|
value={formik.values.start_date || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
formik.setFieldValue('start_date', e.target.value || null);
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
isError={
|
||||||
|
!!formik.errors.start_date && formik.touched.start_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{formik.errors.start_date && formik.touched.start_date && (
|
||||||
|
<div className='text-error text-xs mt-1'>
|
||||||
|
{formik.errors.start_date}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DateInput
|
||||||
|
name='end_date'
|
||||||
|
label='Tanggal Akhir'
|
||||||
|
placeholder='Pilih Tanggal Akhir'
|
||||||
|
value={formik.values.end_date || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
formik.setFieldValue('end_date', e.target.value || null);
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
isError={!!formik.errors.end_date && formik.touched.end_date}
|
||||||
|
/>
|
||||||
|
{formik.errors.end_date && formik.touched.end_date && (
|
||||||
|
<div className='text-error text-xs mt-1'>
|
||||||
|
{formik.errors.end_date}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter By Date Type */}
|
||||||
|
<SelectInput
|
||||||
|
label='Filter Tanggal'
|
||||||
|
placeholder='Pilih Filter Tanggal'
|
||||||
|
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
|
||||||
|
value={filterByValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'filter_by',
|
||||||
|
val && !Array.isArray(val) ? (val.value as string) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Marketing Type Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Tipe Marketing'
|
||||||
|
placeholder='Pilih Tipe Marketing'
|
||||||
|
options={MARKETING_TYPE_OPTIONS}
|
||||||
|
value={marketingTypeValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'marketing_type',
|
||||||
|
val && !Array.isArray(val) ? (val.value as string) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='reset'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DailyMarketingTab;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,527 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import Dropdown from '@/components/dropdown/Dropdown';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/ProductionResultProjectFlockKandangTable';
|
|
||||||
|
|
||||||
import { BaseKandang } from '@/types/api/master-data/kandang';
|
|
||||||
import { AreaApi, LocationApi } from '@/services/api/master-data';
|
|
||||||
import {
|
|
||||||
ProjectFlockApi,
|
|
||||||
ProjectFlockKandangApi,
|
|
||||||
} from '@/services/api/production';
|
|
||||||
import {
|
|
||||||
BaseProjectFlockKandang,
|
|
||||||
ProjectFlockKandang,
|
|
||||||
} from '@/types/api/production/project-flock-kandang';
|
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import Pagination from '@/components/Pagination';
|
|
||||||
import { ProductionResultReportApi } from '@/services/api/report/production-result';
|
|
||||||
import { BaseApiResponse } from '@/types/api/api-general';
|
|
||||||
import { httpClient } from '@/services/http/client';
|
|
||||||
import { ProductionResult } from '@/types/api/report/production-result';
|
|
||||||
import ProductionResultReportPDF from './ProductionResultReportPDF';
|
|
||||||
import { pdf } from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
const ProductionResultContent = () => {
|
|
||||||
const [projectFlockKandangs, setProjectFlockKandangs] = useState<
|
|
||||||
ProjectFlockKandang[] | null
|
|
||||||
>(null);
|
|
||||||
const [projectFlockKandangMetadata, setProjectFlockKandangMetadata] =
|
|
||||||
useState<
|
|
||||||
| {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total_pages: number;
|
|
||||||
total_results: number;
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(10);
|
|
||||||
|
|
||||||
const [isLoadingSearch, setIsLoadingSearch] = useState(false);
|
|
||||||
|
|
||||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
|
|
||||||
|
|
||||||
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [selectedProjectFlock, setSelectedProjectFlock] =
|
|
||||||
useState<OptionType | null>(null);
|
|
||||||
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
|
|
||||||
useState<OptionType | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setAreaInputValue,
|
|
||||||
options: areaOptions,
|
|
||||||
isLoadingOptions: isLoadingAreaOptions,
|
|
||||||
loadMore: loadMoreAreas,
|
|
||||||
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedArea(val as OptionType);
|
|
||||||
|
|
||||||
setSelectedLocation(null);
|
|
||||||
|
|
||||||
setSelectedProjectFlock(null);
|
|
||||||
|
|
||||||
setSelectedProjectFlockKandang(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setLocationInputValue,
|
|
||||||
options: locationOptions,
|
|
||||||
isLoadingOptions: isLoadingLocationOptions,
|
|
||||||
loadMore: loadMoreLocations,
|
|
||||||
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
|
|
||||||
area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedLocation(val as OptionType);
|
|
||||||
|
|
||||||
setSelectedProjectFlock(null);
|
|
||||||
|
|
||||||
setSelectedProjectFlockKandang(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setProjectFlockInputValue,
|
|
||||||
options: projectFlockOptions,
|
|
||||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
|
||||||
loadMore: loadMoreProjectFlocks,
|
|
||||||
} = useSelect<BaseKandang>(
|
|
||||||
ProjectFlockApi.basePath,
|
|
||||||
'id',
|
|
||||||
'flock_name',
|
|
||||||
'search',
|
|
||||||
{
|
|
||||||
area_id: selectedArea
|
|
||||||
? ((selectedArea as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
location_id: selectedLocation
|
|
||||||
? ((selectedLocation as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
category: 'LAYING',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedProjectFlock(val as OptionType);
|
|
||||||
|
|
||||||
setSelectedProjectFlockKandang(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setProjectFlockKandangInputValue,
|
|
||||||
options: projectFlockKandangOptions,
|
|
||||||
isLoadingOptions: isLoadingProjectFlockKandangOptions,
|
|
||||||
loadMore: loadMoreProjectFlockKandangs,
|
|
||||||
} = useSelect<BaseKandang>(
|
|
||||||
ProjectFlockKandangApi.basePath,
|
|
||||||
'id',
|
|
||||||
'kandang.name',
|
|
||||||
'search',
|
|
||||||
{
|
|
||||||
area_id: selectedArea
|
|
||||||
? ((selectedArea as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
location_id: selectedLocation
|
|
||||||
? ((selectedLocation as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
project_flock_id: selectedProjectFlock
|
|
||||||
? ((selectedProjectFlock as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectFlockKandangChangeHandler = (
|
|
||||||
val: OptionType | OptionType[] | null
|
|
||||||
) => {
|
|
||||||
setSelectedProjectFlockKandang(val as OptionType);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToExcelHandler = async () => {
|
|
||||||
setIsLoadingExportingToExcel(true);
|
|
||||||
|
|
||||||
await ProductionResultReportApi.exportProductionResultToExcel(
|
|
||||||
projectFlockKandangs
|
|
||||||
);
|
|
||||||
|
|
||||||
setIsLoadingExportingToExcel(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToPdfHandler = async () => {
|
|
||||||
setIsLoadingExportingToPdf(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let projectFlockKandangsData: BaseProjectFlockKandang[] = [];
|
|
||||||
|
|
||||||
if (selectedProjectFlockKandang) {
|
|
||||||
const projectFlockKandangResponse =
|
|
||||||
await ProjectFlockKandangApi.getSingle(
|
|
||||||
selectedProjectFlockKandang?.value as number
|
|
||||||
);
|
|
||||||
|
|
||||||
projectFlockKandangsData = isResponseSuccess(
|
|
||||||
projectFlockKandangResponse
|
|
||||||
)
|
|
||||||
? [projectFlockKandangResponse.data]
|
|
||||||
: [];
|
|
||||||
} else {
|
|
||||||
const projectFlockKandangsResponse =
|
|
||||||
await ProjectFlockKandangApi.getAll({
|
|
||||||
area_id: selectedArea?.value,
|
|
||||||
project_flock_id: selectedProjectFlock?.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
projectFlockKandangsData = isResponseSuccess(
|
|
||||||
projectFlockKandangsResponse
|
|
||||||
)
|
|
||||||
? projectFlockKandangsResponse.data
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const mappedProductionResults: {
|
|
||||||
projectFlockKandang: BaseProjectFlockKandang;
|
|
||||||
productionResult: ProductionResult[] | null;
|
|
||||||
}[] = await Promise.all(
|
|
||||||
projectFlockKandangsData.map(async (projectFlockKandang) => {
|
|
||||||
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`;
|
|
||||||
const getProductionResultRes = await httpClient<
|
|
||||||
BaseApiResponse<ProductionResult[]>
|
|
||||||
>(getProductionResultPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
projectFlockKandang,
|
|
||||||
productionResult: isResponseSuccess(getProductionResultRes)
|
|
||||||
? getProductionResultRes.data
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mappedProductionResults.length === 0) {
|
|
||||||
toast.error('Tidak ada data untuk diexport.');
|
|
||||||
setIsLoadingExportingToPdf(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPdf = async () => {
|
|
||||||
const productionResultPdfBlob = await pdf(
|
|
||||||
<ProductionResultReportPDF
|
|
||||||
mappedProductionResults={mappedProductionResults}
|
|
||||||
/>
|
|
||||||
).toBlob();
|
|
||||||
|
|
||||||
const productionResultReportPdfUrl = URL.createObjectURL(
|
|
||||||
productionResultPdfBlob
|
|
||||||
);
|
|
||||||
window.open(productionResultReportPdfUrl, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
await openPdf();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingExportingToPdf(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchHandler = async () => {
|
|
||||||
setProjectFlockKandangs(null);
|
|
||||||
setIsLoadingSearch(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (selectedProjectFlockKandang) {
|
|
||||||
const projectFlockKandangResponse =
|
|
||||||
await ProjectFlockKandangApi.getSingle(
|
|
||||||
selectedProjectFlockKandang?.value as number
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!projectFlockKandangResponse ||
|
|
||||||
isResponseError(projectFlockKandangResponse)
|
|
||||||
) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProjectFlockKandangs([projectFlockKandangResponse.data]);
|
|
||||||
setProjectFlockKandangMetadata({
|
|
||||||
page: 1,
|
|
||||||
limit: 10,
|
|
||||||
total_pages: 1,
|
|
||||||
total_results: 1,
|
|
||||||
});
|
|
||||||
setIsLoadingSearch(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectFlockKandangsResponse = await ProjectFlockKandangApi.getAll({
|
|
||||||
area_id: selectedArea?.value,
|
|
||||||
project_flock_id: selectedProjectFlock?.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
!projectFlockKandangsResponse ||
|
|
||||||
isResponseError(projectFlockKandangsResponse)
|
|
||||||
) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProjectFlockKandangs(projectFlockKandangsResponse.data);
|
|
||||||
setProjectFlockKandangMetadata(projectFlockKandangsResponse.meta);
|
|
||||||
setIsLoadingSearch(false);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Gagal mencari data! Coba lagi.');
|
|
||||||
setProjectFlockKandangs(null);
|
|
||||||
setProjectFlockKandangMetadata(undefined);
|
|
||||||
setIsLoadingSearch(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetHandler = () => {
|
|
||||||
setProjectFlockKandangs(null);
|
|
||||||
setSelectedArea(null);
|
|
||||||
setSelectedLocation(null);
|
|
||||||
setSelectedProjectFlock(null);
|
|
||||||
setSelectedProjectFlockKandang(null);
|
|
||||||
// resetFilter();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full p-4'>
|
|
||||||
<Card
|
|
||||||
variant='bordered'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h2 className='text-xl font-bold text-center'>
|
|
||||||
Laporan Hasil Produksi
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className='flex flex-col gap-4 mb-6 mt-4'>
|
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
label='Area'
|
|
||||||
placeholder='Pilih Area'
|
|
||||||
options={areaOptions}
|
|
||||||
isLoading={isLoadingAreaOptions}
|
|
||||||
value={selectedArea}
|
|
||||||
onChange={areaChangeHandler}
|
|
||||||
onInputChange={setAreaInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreAreas}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Lokasi'
|
|
||||||
placeholder={
|
|
||||||
selectedArea ? 'Pilih Lokasi' : 'Pilih Area terlebih dahulu'
|
|
||||||
}
|
|
||||||
options={locationOptions}
|
|
||||||
isLoading={isLoadingLocationOptions}
|
|
||||||
value={selectedLocation}
|
|
||||||
onChange={locationChangeHandler}
|
|
||||||
onInputChange={setLocationInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreLocations}
|
|
||||||
isClearable
|
|
||||||
isDisabled={!selectedArea}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Project Flock'
|
|
||||||
placeholder={
|
|
||||||
selectedArea && selectedLocation
|
|
||||||
? 'Pilih Project Flock'
|
|
||||||
: 'Pilih Area dan Lokasi terlebih dahulu'
|
|
||||||
}
|
|
||||||
options={projectFlockOptions}
|
|
||||||
isLoading={isLoadingProjectFlockOptions}
|
|
||||||
value={selectedProjectFlock}
|
|
||||||
onChange={projectFlockChangeHandler}
|
|
||||||
onInputChange={setProjectFlockInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
|
||||||
isClearable
|
|
||||||
isDisabled={!selectedArea || !selectedLocation}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Project Flock Kandang'
|
|
||||||
placeholder={
|
|
||||||
selectedProjectFlock
|
|
||||||
? 'Pilih Project Flock Kandang'
|
|
||||||
: 'Pilih Project Flock terlebih dahulu'
|
|
||||||
}
|
|
||||||
options={projectFlockKandangOptions}
|
|
||||||
isLoading={isLoadingProjectFlockKandangOptions}
|
|
||||||
value={selectedProjectFlockKandang}
|
|
||||||
onChange={projectFlockKandangChangeHandler}
|
|
||||||
onInputChange={setProjectFlockKandangInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
|
|
||||||
isClearable
|
|
||||||
isDisabled={!selectedProjectFlock}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
|
||||||
<div className='col-span-12 flex flex-wrap sm:justify-end items-end gap-2'>
|
|
||||||
<Button
|
|
||||||
onClick={searchHandler}
|
|
||||||
isLoading={isLoadingSearch}
|
|
||||||
disabled={
|
|
||||||
!selectedArea || !selectedLocation || !selectedProjectFlock
|
|
||||||
}
|
|
||||||
className='flex-1 sm:flex-none'
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons-outline:search' width={20} height={20} />
|
|
||||||
Cari
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color='warning'
|
|
||||||
onClick={resetHandler}
|
|
||||||
className='flex-1 sm:flex-none'
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons-outline:refresh' width={20} height={20} />
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dropdown
|
|
||||||
align='end'
|
|
||||||
direction='bottom'
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
disabled={
|
|
||||||
!selectedArea ||
|
|
||||||
!selectedLocation ||
|
|
||||||
!selectedProjectFlock
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Export{' '}
|
|
||||||
<Icon
|
|
||||||
icon='heroicons-outline:download'
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Menu>
|
|
||||||
<MenuItem
|
|
||||||
title='Export to Excel'
|
|
||||||
icon='icon-park-outline:excel'
|
|
||||||
isLoading={isLoadingExportingToExcel}
|
|
||||||
onClick={exportToExcelHandler}
|
|
||||||
className='text-nowrap'
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
title='Export to PDF'
|
|
||||||
icon='icon-park-outline:file-pdf-one'
|
|
||||||
isLoading={isLoadingExportingToPdf}
|
|
||||||
onClick={exportToPdfHandler}
|
|
||||||
className='text-nowrap'
|
|
||||||
/>
|
|
||||||
</Menu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className='mt-4'>
|
|
||||||
{isLoadingSearch && (
|
|
||||||
<span className='loading loading-dots loading-xl block mx-auto text-gray-400' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingSearch && !projectFlockKandangs && (
|
|
||||||
<p className='text-center text-gray-500'>
|
|
||||||
Silakan pilih filter dan klik tombol Cari untuk menampilkan data.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingSearch && projectFlockKandangs?.length === 0 && (
|
|
||||||
<p className='text-center text-gray-500'>
|
|
||||||
Tidak ada data kandang project flock yang dapat ditampilkan.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingSearch && projectFlockKandangs && (
|
|
||||||
<Card
|
|
||||||
variant='bordered'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{projectFlockKandangs.map((projectFlockKandang) => (
|
|
||||||
<ProductionResultProjectFlockKandangTable
|
|
||||||
key={projectFlockKandang.id}
|
|
||||||
projectFlockKandangId={projectFlockKandang.id}
|
|
||||||
kandangName={projectFlockKandang.kandang.name}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className='max-w-sm ml-auto mt-5'>
|
|
||||||
<Pagination
|
|
||||||
totalItems={projectFlockKandangMetadata?.total_results || 0}
|
|
||||||
itemsPerPage={projectFlockKandangMetadata?.limit || 0}
|
|
||||||
currentPage={projectFlockKandangMetadata?.page || 0}
|
|
||||||
onPrevPage={() =>
|
|
||||||
setPage((currPage) =>
|
|
||||||
currPage > 1 ? currPage - 1 : currPage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onNextPage={() =>
|
|
||||||
setPage((currPage) =>
|
|
||||||
projectFlockKandangMetadata?.total_pages &&
|
|
||||||
currPage < projectFlockKandangMetadata.total_pages
|
|
||||||
? currPage + 1
|
|
||||||
: currPage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onPageChange={(pageNumber) => setPage(pageNumber)}
|
|
||||||
rowOptions={[10, 20, 50, 100]}
|
|
||||||
onRowChange={setPageSize}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductionResultContent;
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Tabs from '@/components/Tabs';
|
||||||
|
import ProductionResultTab from '@/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
|
||||||
|
const ProductionResultTabs = () => {
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Hasil Produksi',
|
||||||
|
content: <ProductionResultTab tabId={'1'} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className='w-full'>
|
||||||
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
variant='boxed'
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onTabChange={setActiveTabId}
|
||||||
|
className={{
|
||||||
|
tabHeaderWrapper:
|
||||||
|
'justify-between items-center p-3 border-b border-base-content/10',
|
||||||
|
tab: 'w-fit',
|
||||||
|
content: 'p-0 m-0',
|
||||||
|
}}
|
||||||
|
sideContent={tabActions[activeTabId] || null}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionResultTabs;
|
||||||
+7
-7
@@ -66,7 +66,7 @@ const getBwTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'woa',
|
key: 'woa',
|
||||||
@@ -114,7 +114,7 @@ const getDepTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'dep_kum',
|
key: 'dep_kum',
|
||||||
@@ -141,7 +141,7 @@ const getButiranTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'butiran_utuh',
|
key: 'butiran_utuh',
|
||||||
@@ -196,7 +196,7 @@ const getKgTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'kg_utuh',
|
key: 'kg_utuh',
|
||||||
@@ -251,7 +251,7 @@ const getPersenTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'persen_utuh',
|
key: 'persen_utuh',
|
||||||
@@ -292,7 +292,7 @@ const getProduksi1TableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'hd',
|
key: 'hd',
|
||||||
@@ -361,7 +361,7 @@ const getProduksi2TableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'fcr',
|
key: 'fcr',
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import { formatNumber } from '@/lib/helper';
|
||||||
|
import { ProductionResult } from '@/types/api/report/production-result';
|
||||||
|
|
||||||
|
interface ProductionResultExportExcelParams {
|
||||||
|
data: ProductionResult[];
|
||||||
|
period?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateProductionResultExcel = async (
|
||||||
|
params: ProductionResultExportExcelParams
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!params.data || params.data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
|
||||||
|
// ===== PRODUCTION RESULT WORKSHEET =====
|
||||||
|
const columns = [
|
||||||
|
{ header: 'No', key: 'no', width: 6 },
|
||||||
|
{ header: 'Project Flock', key: 'projectFlockName', width: 25 },
|
||||||
|
{
|
||||||
|
header: 'Category',
|
||||||
|
key: 'projectFlockCategory',
|
||||||
|
width: 18,
|
||||||
|
},
|
||||||
|
{ header: 'Kandang', key: 'kandangName', width: 18 },
|
||||||
|
{ header: 'Week of Age (WOA)', key: 'woa', width: 20 },
|
||||||
|
{ header: 'Body Weight (BW)', key: 'bw', width: 18 },
|
||||||
|
{ header: 'Body Weight (Std BW)', key: 'stdBw', width: 22 },
|
||||||
|
{ header: 'Uniformity (%)', key: 'uniformity', width: 16 },
|
||||||
|
{ header: 'Uniformity Std (%)', key: 'stdUniformity', width: 20 },
|
||||||
|
{ header: 'Depletion Cumulative', key: 'depKum', width: 22 },
|
||||||
|
{ header: 'Depletion Standard', key: 'depStd', width: 20 },
|
||||||
|
{ header: 'Telur Utuh', key: 'butiranUtuh', width: 14 },
|
||||||
|
{ header: 'Telur Putih', key: 'butiranPutih', width: 14 },
|
||||||
|
{ header: 'Telur Retak', key: 'butiranRetak', width: 14 },
|
||||||
|
{ header: 'Telur Pecah', key: 'butiranPecah', width: 14 },
|
||||||
|
{ header: 'Jumlah Telur', key: 'butiranJumlah', width: 16 },
|
||||||
|
{ header: 'Total Telur', key: 'totalButir', width: 14 },
|
||||||
|
{ header: 'Utuh (Kg)', key: 'kgUtuh', width: 12 },
|
||||||
|
{ header: 'Putih (Kg)', key: 'kgPutih', width: 12 },
|
||||||
|
{ header: 'Retak (Kg)', key: 'kgRetak', width: 12 },
|
||||||
|
{ header: 'Pecah (Kg)', key: 'kgPecah', width: 12 },
|
||||||
|
{ header: 'Jumlah (Kg)', key: 'kgJumlah', width: 14 },
|
||||||
|
{ header: 'Total Weight (Kg)', key: 'totalKg', width: 20 },
|
||||||
|
{ header: 'Utuh (%)', key: 'persenUtuh', width: 12 },
|
||||||
|
{ header: 'Putih (%)', key: 'persenPutih', width: 12 },
|
||||||
|
{ header: 'Retak (%)', key: 'persenRetak', width: 12 },
|
||||||
|
{ header: 'Pecah (%)', key: 'persenPecah', width: 12 },
|
||||||
|
{ header: 'Hen Day (HD)', key: 'hd', width: 15 },
|
||||||
|
{ header: 'Hen Day Std (HD Std)', key: 'hdStd', width: 22 },
|
||||||
|
{ header: 'Feed Intake (FI)', key: 'fi', width: 18 },
|
||||||
|
{ header: 'Feed Intake Std (FI Std)', key: 'fiStd', width: 25 },
|
||||||
|
{ header: 'Egg Mass (EM)', key: 'em', width: 16 },
|
||||||
|
{ header: 'Egg Mass Std (EM Std)', key: 'emStd', width: 23 },
|
||||||
|
{ header: 'Egg Weight (EW)', key: 'ew', width: 18 },
|
||||||
|
{ header: 'Egg Weight Std (EW Std)', key: 'ewStd', width: 25 },
|
||||||
|
{ header: 'Feed Conversion Ratio (FCR)', key: 'fcr', width: 30 },
|
||||||
|
{
|
||||||
|
header: 'Feed Conversion Ratio Std (FCR Std)',
|
||||||
|
key: 'fcrStd',
|
||||||
|
width: 35,
|
||||||
|
},
|
||||||
|
{ header: 'Hen House (HH)', key: 'hh', width: 18 },
|
||||||
|
{ header: 'Hen House Std (HH Std)', key: 'hhStd', width: 25 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const worksheet = workbook.addWorksheet('Production Result');
|
||||||
|
worksheet.columns = columns;
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
params.data.forEach((item: ProductionResult, index: number) => {
|
||||||
|
worksheet.addRow({
|
||||||
|
no: index + 1,
|
||||||
|
projectFlockName: item.project_flock?.name || '',
|
||||||
|
projectFlockCategory: item.project_flock?.category || '',
|
||||||
|
kandangName: item.project_flock?.kandang?.name || '',
|
||||||
|
woa: formatNumber(item.woa || 0),
|
||||||
|
bw: formatNumber(item.bw || 0),
|
||||||
|
stdBw: formatNumber(item.std_bw || 0),
|
||||||
|
uniformity: formatNumber(item.uniformity || 0),
|
||||||
|
stdUniformity: item.std_uniformity || '',
|
||||||
|
depKum: formatNumber(item.dep_kum || 0),
|
||||||
|
depStd: formatNumber(item.dep_std || 0),
|
||||||
|
butiranUtuh: formatNumber(item.butiran_utuh || 0),
|
||||||
|
butiranPutih: formatNumber(item.butiran_putih || 0),
|
||||||
|
butiranRetak: formatNumber(item.butiran_retak || 0),
|
||||||
|
butiranPecah: formatNumber(item.butiran_pecah || 0),
|
||||||
|
butiranJumlah: formatNumber(item.butiran_jumlah || 0),
|
||||||
|
totalButir: formatNumber(item.total_butir || 0),
|
||||||
|
kgUtuh: formatNumber(item.kg_utuh || 0),
|
||||||
|
kgPutih: formatNumber(item.kg_putih || 0),
|
||||||
|
kgRetak: formatNumber(item.kg_retak || 0),
|
||||||
|
kgPecah: formatNumber(item.kg_pecah || 0),
|
||||||
|
kgJumlah: formatNumber(item.kg_jumlah || 0),
|
||||||
|
totalKg: formatNumber(item.total_kg || 0),
|
||||||
|
persenUtuh: formatNumber(item.persen_utuh || 0),
|
||||||
|
persenPutih: formatNumber(item.persen_putih || 0),
|
||||||
|
persenRetak: formatNumber(item.persen_retak || 0),
|
||||||
|
persenPecah: formatNumber(item.persen_pecah || 0),
|
||||||
|
hd: formatNumber(item.hd || 0),
|
||||||
|
hdStd: formatNumber(item.hd_std || 0),
|
||||||
|
fi: formatNumber(item.fi || 0),
|
||||||
|
fiStd: formatNumber(item.fi_std || 0),
|
||||||
|
em: formatNumber(item.em || 0),
|
||||||
|
emStd: formatNumber(item.em_std || 0),
|
||||||
|
ew: formatNumber(item.ew || 0),
|
||||||
|
ewStd: formatNumber(item.ew_std || 0),
|
||||||
|
fcr: formatNumber(item.fcr || 0),
|
||||||
|
fcrStd: formatNumber(item.fcr_std || 0),
|
||||||
|
hh: formatNumber(item.hh || 0),
|
||||||
|
hhStd: formatNumber(item.hh_std || 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentDate = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = params.period
|
||||||
|
? `laporan-hasil-produksi-${params.period}.xlsx`
|
||||||
|
: `laporan-hasil-produksi-${currentDate}.xlsx`;
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type ProductionResultFilterProps = {
|
||||||
|
area_id: string | null;
|
||||||
|
location_id: string | null;
|
||||||
|
project_flock_id: string | null;
|
||||||
|
kandang_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductionResultFilterFormType = {
|
||||||
|
area_id: OptionType | null;
|
||||||
|
location_id: OptionType | null;
|
||||||
|
project_flock_id: OptionType | null;
|
||||||
|
kandang_id: OptionType | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProductionResultFilterSchema = yup.object({
|
||||||
|
area_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.required('Area wajib dipilih')
|
||||||
|
.test('is-not-empty', 'Area wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return !!value;
|
||||||
|
}),
|
||||||
|
location_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.required('Lokasi wajib dipilih')
|
||||||
|
.test('is-not-empty', 'Lokasi wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return !!value;
|
||||||
|
}),
|
||||||
|
project_flock_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.required('Project Flock wajib dipilih')
|
||||||
|
.test('is-not-empty', 'Project Flock wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return !!value;
|
||||||
|
}),
|
||||||
|
kandang_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.required('Kandang wajib dipilih')
|
||||||
|
.test('is-not-empty', 'Kandang wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return !!value;
|
||||||
|
}),
|
||||||
|
}) as yup.ObjectSchema<ProductionResultFilterFormType>;
|
||||||
|
|
||||||
|
export type ProductionResultFilterValues = yup.InferType<
|
||||||
|
typeof ProductionResultFilterSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { ProductionResult } from '@/types/api/report/production-result';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
type ProductionResultColumn =
|
||||||
|
| ColumnDef<ProductionResult>
|
||||||
|
| {
|
||||||
|
header: string;
|
||||||
|
columns: Array<{
|
||||||
|
header: string;
|
||||||
|
accessorKey?: string;
|
||||||
|
cell?: (props: {
|
||||||
|
row: { original: ProductionResult };
|
||||||
|
}) => React.ReactNode;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductionResultSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ProductionResultColumn[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionResultSkeleton;
|
||||||
+842
@@ -0,0 +1,842 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { generateProductionResultExcel } from '../export/ProductionResultExportXLSX';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Dropdown from '@/components/dropdown/Dropdown';
|
||||||
|
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTable';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import {
|
||||||
|
ProductionResultFilterSchema,
|
||||||
|
type ProductionResultFilterValues,
|
||||||
|
} from '@/components/pages/report/production-result/filter/ProductionResultFilter';
|
||||||
|
|
||||||
|
import { BaseKandang } from '@/types/api/master-data/kandang';
|
||||||
|
import { AreaApi, LocationApi } from '@/services/api/master-data';
|
||||||
|
import {
|
||||||
|
ProjectFlockApi,
|
||||||
|
ProjectFlockKandangApi,
|
||||||
|
} from '@/services/api/production';
|
||||||
|
import {
|
||||||
|
BaseProjectFlockKandang,
|
||||||
|
ProjectFlockKandang,
|
||||||
|
} from '@/types/api/production/project-flock-kandang';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { ProductionResultReportApi } from '@/services/api/report/production-result';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
import { httpClient } from '@/services/http/client';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { ProductionResult } from '@/types/api/report/production-result';
|
||||||
|
import ProductionResultReportPDF from '../export/ProductionResultExportPDF';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
|
import { cn, formatNumber } from '@/lib/helper';
|
||||||
|
import Pagination from '@/components/Pagination';
|
||||||
|
import ProductionResultSkeleton from '@/components/pages/report/production-result/skeleton/ProductionResultSkeleton';
|
||||||
|
|
||||||
|
interface ProductionResultTabProps {
|
||||||
|
tabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterParams {
|
||||||
|
area_id?: string;
|
||||||
|
location_id?: string;
|
||||||
|
project_flock_id?: string;
|
||||||
|
project_flock_kandang_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
|
||||||
|
// ===== STATE MANAGEMENT =====
|
||||||
|
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||||
|
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||||
|
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||||
|
|
||||||
|
// ===== SUBMISSION STATE =====
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||||
|
|
||||||
|
// ===== PAGINATION STATE =====
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== TABLE COLUMNS =====
|
||||||
|
const productionResultColumns: ColumnDef<ProductionResult>[] = [
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
cell: (props) => props.row.index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'woa',
|
||||||
|
header: 'WOA',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'bw',
|
||||||
|
header: 'BW',
|
||||||
|
cell: (props) => formatNumber(props.row.original.bw),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'std_bw',
|
||||||
|
header: 'STD BW',
|
||||||
|
cell: (props) => formatNumber(props.row.original.std_bw),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'uniformity',
|
||||||
|
header: 'Uniformity',
|
||||||
|
cell: (props) => formatNumber(props.row.original.uniformity),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'std_uniformity',
|
||||||
|
header: 'STD Uniformity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'dep_kum',
|
||||||
|
header: 'Dep Kum',
|
||||||
|
cell: (props) => formatNumber(props.row.original.dep_kum),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'dep_std',
|
||||||
|
header: 'Dep STD',
|
||||||
|
cell: (props) => formatNumber(props.row.original.dep_std),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Butiran',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_utuh',
|
||||||
|
header: 'Utuh',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_utuh),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_putih',
|
||||||
|
header: 'Putih',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_putih),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_retak',
|
||||||
|
header: 'Retak',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_retak),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_pecah',
|
||||||
|
header: 'Pecah',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_pecah),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_jumlah',
|
||||||
|
header: 'Jumlah (Butir)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_jumlah),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'total_butir',
|
||||||
|
header: 'Total Butir',
|
||||||
|
cell: (props) => formatNumber(props.row.original.total_butir),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Kg',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_utuh',
|
||||||
|
header: 'Utuh (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_utuh),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_putih',
|
||||||
|
header: 'Putih (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_putih),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_retak',
|
||||||
|
header: 'Retak (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_retak),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_pecah',
|
||||||
|
header: 'Pecah (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_pecah),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_jumlah',
|
||||||
|
header: 'Jumlah (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_jumlah),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'total_kg',
|
||||||
|
header: 'Total Kg',
|
||||||
|
cell: (props) => formatNumber(props.row.original.total_kg),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '%',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
accessorKey: 'persen_utuh',
|
||||||
|
header: 'Utuh',
|
||||||
|
cell: (props) => formatNumber(props.row.original.persen_utuh),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'persen_putih',
|
||||||
|
header: 'Putih',
|
||||||
|
cell: (props) => formatNumber(props.row.original.persen_putih),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'persen_retak',
|
||||||
|
header: 'Retak',
|
||||||
|
cell: (props) => formatNumber(props.row.original.persen_retak),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'persen_pecah',
|
||||||
|
header: 'Pecah',
|
||||||
|
cell: (props) => formatNumber(props.row.original.persen_pecah),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<ProductionResultFilterValues>({
|
||||||
|
initialValues: {
|
||||||
|
area_id: null,
|
||||||
|
location_id: null,
|
||||||
|
project_flock_id: null,
|
||||||
|
kandang_id: null,
|
||||||
|
},
|
||||||
|
validationSchema: ProductionResultFilterSchema,
|
||||||
|
validateOnBlur: true,
|
||||||
|
validateOnChange: true,
|
||||||
|
onSubmit: (values) => {
|
||||||
|
setFilterParams({
|
||||||
|
area_id: values.area_id?.value
|
||||||
|
? String(values.area_id.value)
|
||||||
|
: undefined,
|
||||||
|
location_id: values.location_id?.value
|
||||||
|
? String(values.location_id.value)
|
||||||
|
: undefined,
|
||||||
|
project_flock_id: values.project_flock_id?.value
|
||||||
|
? String(values.project_flock_id.value)
|
||||||
|
: undefined,
|
||||||
|
project_flock_kandang_id: values.kandang_id?.value
|
||||||
|
? String(values.kandang_id.value)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
filterModal.closeModal();
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
setFilterParams({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setAreaInputValue,
|
||||||
|
options: areaOptions,
|
||||||
|
isLoadingOptions: isLoadingAreas,
|
||||||
|
loadMore: loadMoreAreas,
|
||||||
|
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocations,
|
||||||
|
loadMore: loadMoreLocations,
|
||||||
|
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
|
||||||
|
area_id: formik.values.area_id?.value
|
||||||
|
? String(formik.values.area_id.value)
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setProjectFlockInputValue,
|
||||||
|
options: projectFlockOptions,
|
||||||
|
isLoadingOptions: isLoadingProjectFlocks,
|
||||||
|
loadMore: loadMoreProjectFlocks,
|
||||||
|
} = useSelect<BaseKandang>(
|
||||||
|
ProjectFlockApi.basePath,
|
||||||
|
'id',
|
||||||
|
'flock_name',
|
||||||
|
'search',
|
||||||
|
{
|
||||||
|
area_id: formik.values.area_id?.value
|
||||||
|
? String(formik.values.area_id.value)
|
||||||
|
: '',
|
||||||
|
location_id: formik.values.location_id?.value
|
||||||
|
? String(formik.values.location_id.value)
|
||||||
|
: '',
|
||||||
|
category: 'LAYING',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setProjectFlockKandangInputValue,
|
||||||
|
options: projectFlockKandangOptions,
|
||||||
|
isLoadingOptions: isLoadingProjectFlockKandangs,
|
||||||
|
loadMore: loadMoreProjectFlockKandangs,
|
||||||
|
} = useSelect<BaseKandang>(
|
||||||
|
ProjectFlockKandangApi.basePath,
|
||||||
|
'id',
|
||||||
|
'kandang.name',
|
||||||
|
'search',
|
||||||
|
{
|
||||||
|
area_id: formik.values.area_id?.value
|
||||||
|
? String(formik.values.area_id.value)
|
||||||
|
: '',
|
||||||
|
location_id: formik.values.location_id?.value
|
||||||
|
? String(formik.values.location_id.value)
|
||||||
|
: '',
|
||||||
|
project_flock_id: formik.values.project_flock_id?.value
|
||||||
|
? String(formik.values.project_flock_id.value)
|
||||||
|
: '',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const areaValue = useMemo(
|
||||||
|
() => formik.values.area_id,
|
||||||
|
[formik.values.area_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationValue = useMemo(
|
||||||
|
() => formik.values.location_id,
|
||||||
|
[formik.values.location_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFlockValue = useMemo(
|
||||||
|
() => formik.values.project_flock_id,
|
||||||
|
[formik.values.project_flock_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFlockKandangValue = useMemo(
|
||||||
|
() => formik.values.kandang_id,
|
||||||
|
[formik.values.kandang_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== ACTIVE FILTERS COUNT =====
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (filterParams.area_id) count += 1;
|
||||||
|
if (filterParams.location_id) count += 1;
|
||||||
|
if (filterParams.project_flock_id) count += 1;
|
||||||
|
if (filterParams.project_flock_kandang_id) count += 1;
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
const hasFilters = activeFiltersCount > 0;
|
||||||
|
|
||||||
|
// ===== DATA FETCHING =====
|
||||||
|
const { data: projectFlockKandangsData, isLoading } = useSWR<
|
||||||
|
BaseApiResponse<ProjectFlockKandang[]>
|
||||||
|
>(
|
||||||
|
isSubmitted
|
||||||
|
? () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterParams.area_id)
|
||||||
|
params.append('area_id', filterParams.area_id);
|
||||||
|
if (filterParams.project_flock_id)
|
||||||
|
params.append('project_flock_id', filterParams.project_flock_id);
|
||||||
|
params.append('page', String(page));
|
||||||
|
params.append('limit', String(pageSize));
|
||||||
|
|
||||||
|
return [`/production/project-flock-kandangs?${params.toString()}`];
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
([url]: string[]) => httpClient<BaseApiResponse<ProjectFlockKandang[]>>(url)
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFlockKandangs = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(projectFlockKandangsData)
|
||||||
|
? projectFlockKandangsData.data
|
||||||
|
: null,
|
||||||
|
[projectFlockKandangsData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFlockKandangMetadata = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(projectFlockKandangsData)
|
||||||
|
? projectFlockKandangsData.meta
|
||||||
|
: undefined,
|
||||||
|
[projectFlockKandangsData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== EXPORT HANDLERS =====
|
||||||
|
const exportToExcelHandler = useCallback(async () => {
|
||||||
|
setIsExcelExportLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let projectFlockKandangsFetch: BaseProjectFlockKandang[] = [];
|
||||||
|
|
||||||
|
if (filterParams.project_flock_kandang_id) {
|
||||||
|
const projectFlockKandangResponse =
|
||||||
|
await ProjectFlockKandangApi.getSingle(
|
||||||
|
Number(filterParams.project_flock_kandang_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
projectFlockKandangsFetch = isResponseSuccess(
|
||||||
|
projectFlockKandangResponse
|
||||||
|
)
|
||||||
|
? [projectFlockKandangResponse.data]
|
||||||
|
: [];
|
||||||
|
} else {
|
||||||
|
const projectFlockKandangsResponse =
|
||||||
|
await ProjectFlockKandangApi.getAll({
|
||||||
|
area_id: filterParams.area_id,
|
||||||
|
project_flock_id: filterParams.project_flock_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
projectFlockKandangsFetch = isResponseSuccess(
|
||||||
|
projectFlockKandangsResponse
|
||||||
|
)
|
||||||
|
? projectFlockKandangsResponse.data
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const productionResultData: ProductionResult[] = [];
|
||||||
|
|
||||||
|
for (const kandang of projectFlockKandangsFetch) {
|
||||||
|
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${kandang.id}?page=1&limit=100`;
|
||||||
|
const getProductionResultRes = await httpClient<
|
||||||
|
BaseApiResponse<ProductionResult[]>
|
||||||
|
>(getProductionResultPath);
|
||||||
|
|
||||||
|
if (isResponseSuccess(getProductionResultRes)) {
|
||||||
|
productionResultData.push(
|
||||||
|
...(getProductionResultRes.data?.map((result) => ({
|
||||||
|
...result,
|
||||||
|
project_flock: {
|
||||||
|
...result.project_flock,
|
||||||
|
name:
|
||||||
|
projectFlockValue?.label ||
|
||||||
|
kandang.project_flock?.name ||
|
||||||
|
`Project Flock #${kandang.project_flock_id}`,
|
||||||
|
category: kandang.project_flock?.category || '',
|
||||||
|
kandang: {
|
||||||
|
...result.project_flock?.kandang,
|
||||||
|
name:
|
||||||
|
kandang.kandang?.name || `Kandang #${kandang.kandang_id}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})) || [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productionResultData.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diexport.');
|
||||||
|
setIsExcelExportLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateProductionResultExcel({
|
||||||
|
data: productionResultData,
|
||||||
|
period: '',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsExcelExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterParams, projectFlockValue]);
|
||||||
|
|
||||||
|
const exportToPdfHandler = useCallback(async () => {
|
||||||
|
setIsPdfExportLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let projectFlockKandangsFetch: BaseProjectFlockKandang[] = [];
|
||||||
|
|
||||||
|
if (filterParams.project_flock_kandang_id) {
|
||||||
|
const projectFlockKandangResponse =
|
||||||
|
await ProjectFlockKandangApi.getSingle(
|
||||||
|
Number(filterParams.project_flock_kandang_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
projectFlockKandangsFetch = isResponseSuccess(
|
||||||
|
projectFlockKandangResponse
|
||||||
|
)
|
||||||
|
? [projectFlockKandangResponse.data]
|
||||||
|
: [];
|
||||||
|
} else {
|
||||||
|
const projectFlockKandangsResponse =
|
||||||
|
await ProjectFlockKandangApi.getAll({
|
||||||
|
area_id: filterParams.area_id,
|
||||||
|
project_flock_id: filterParams.project_flock_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
projectFlockKandangsFetch = isResponseSuccess(
|
||||||
|
projectFlockKandangsResponse
|
||||||
|
)
|
||||||
|
? projectFlockKandangsResponse.data
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedProductionResults: {
|
||||||
|
projectFlockKandang: BaseProjectFlockKandang;
|
||||||
|
productionResult: ProductionResult[] | null;
|
||||||
|
}[] = await Promise.all(
|
||||||
|
projectFlockKandangsFetch.map(async (projectFlockKandang) => {
|
||||||
|
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`;
|
||||||
|
const getProductionResultRes = await httpClient<
|
||||||
|
BaseApiResponse<ProductionResult[]>
|
||||||
|
>(getProductionResultPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectFlockKandang,
|
||||||
|
productionResult: isResponseSuccess(getProductionResultRes)
|
||||||
|
? getProductionResultRes.data
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mappedProductionResults.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diexport.');
|
||||||
|
setIsPdfExportLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPdf = async () => {
|
||||||
|
const productionResultPdfBlob = await pdf(
|
||||||
|
<ProductionResultReportPDF
|
||||||
|
mappedProductionResults={mappedProductionResults}
|
||||||
|
/>
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
|
const productionResultReportPdfUrl = URL.createObjectURL(
|
||||||
|
productionResultPdfBlob
|
||||||
|
);
|
||||||
|
window.open(productionResultReportPdfUrl, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
await openPdf();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPdfExportLoading(false);
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTabActions(
|
||||||
|
tabId,
|
||||||
|
<div className='flex flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
onClick={() => filterModal.openModal()}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||||
|
{
|
||||||
|
'border-primary-gradient text-primary': hasFilters,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
Filter
|
||||||
|
{hasFilters && (
|
||||||
|
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||||
|
{activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
isLoading={isAnyExportLoading}
|
||||||
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
|
>
|
||||||
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:cloud-arrow-down'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={exportToExcelHandler}
|
||||||
|
isLoading={isExcelExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
|
Export to Excel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={exportToPdfHandler}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
tabId,
|
||||||
|
hasFilters,
|
||||||
|
activeFiltersCount,
|
||||||
|
isAnyExportLoading,
|
||||||
|
exportToExcelHandler,
|
||||||
|
exportToPdfHandler,
|
||||||
|
setTabActions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTabActions(tabId);
|
||||||
|
};
|
||||||
|
}, [tabId, clearTabActions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||||
|
{!isSubmitted ? (
|
||||||
|
<ProductionResultSkeleton
|
||||||
|
columns={productionResultColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:funnel'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='No Filters Selected'
|
||||||
|
subtitle='Please choose filters to narrow down your results and make your search easier.'
|
||||||
|
/>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !projectFlockKandangs || projectFlockKandangs.length === 0 ? (
|
||||||
|
<ProductionResultSkeleton
|
||||||
|
columns={productionResultColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:chart-bar'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='Data Not Yet Available'
|
||||||
|
subtitle='Please change your filters to get the data.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{projectFlockKandangs.map(
|
||||||
|
(projectFlockKandang: ProjectFlockKandang) => (
|
||||||
|
<ProductionResultProjectFlockKandangTable
|
||||||
|
key={projectFlockKandang.id}
|
||||||
|
projectFlockKandangId={projectFlockKandang.id}
|
||||||
|
kandangName={projectFlockKandang.kandang.name}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='max-w-sm ml-auto'>
|
||||||
|
<Pagination
|
||||||
|
totalItems={projectFlockKandangMetadata?.total_results || 0}
|
||||||
|
itemsPerPage={projectFlockKandangMetadata?.limit || 0}
|
||||||
|
currentPage={projectFlockKandangMetadata?.page || 0}
|
||||||
|
onPrevPage={() =>
|
||||||
|
setPage((currPage) =>
|
||||||
|
currPage > 1 ? currPage - 1 : currPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onNextPage={() =>
|
||||||
|
setPage((currPage) =>
|
||||||
|
projectFlockKandangMetadata?.total_pages &&
|
||||||
|
currPage < projectFlockKandangMetadata.total_pages
|
||||||
|
? currPage + 1
|
||||||
|
: currPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPageChange={(pageNumber) => setPage(pageNumber)}
|
||||||
|
rowOptions={[10, 20, 50, 100]}
|
||||||
|
onRowChange={setPageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Area'
|
||||||
|
placeholder='Pilih Area'
|
||||||
|
options={areaOptions}
|
||||||
|
isLoading={isLoadingAreas}
|
||||||
|
value={areaValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('area_id', val);
|
||||||
|
formik.setFieldValue('location_id', null);
|
||||||
|
formik.setFieldValue('project_flock_id', null);
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}}
|
||||||
|
onInputChange={setAreaInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreAreas}
|
||||||
|
isClearable
|
||||||
|
isError={formik.touched.area_id && Boolean(formik.errors.area_id)}
|
||||||
|
errorMessage={formik.errors.area_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
isLoading={isLoadingLocations}
|
||||||
|
value={locationValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('location_id', val);
|
||||||
|
formik.setFieldValue('project_flock_id', null);
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreLocations}
|
||||||
|
isClearable
|
||||||
|
isDisabled={!formik.values.area_id}
|
||||||
|
isError={
|
||||||
|
formik.touched.location_id && Boolean(formik.errors.location_id)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.location_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Project Flock'
|
||||||
|
placeholder='Pilih Project Flock'
|
||||||
|
options={projectFlockOptions}
|
||||||
|
isLoading={isLoadingProjectFlocks}
|
||||||
|
value={projectFlockValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('project_flock_id', val);
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}}
|
||||||
|
onInputChange={setProjectFlockInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||||
|
isClearable
|
||||||
|
isDisabled={!formik.values.location_id}
|
||||||
|
isError={
|
||||||
|
formik.touched.project_flock_id &&
|
||||||
|
Boolean(formik.errors.project_flock_id)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.project_flock_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Kandang'
|
||||||
|
placeholder='Pilih Kandang'
|
||||||
|
options={projectFlockKandangOptions}
|
||||||
|
isLoading={isLoadingProjectFlockKandangs}
|
||||||
|
value={projectFlockKandangValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('kandang_id', val);
|
||||||
|
}}
|
||||||
|
onInputChange={setProjectFlockKandangInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
|
||||||
|
isClearable
|
||||||
|
isDisabled={!formik.values.project_flock_id}
|
||||||
|
isError={
|
||||||
|
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.kandang_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='reset'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionResultContent;
|
||||||
+50
-87
@@ -4,12 +4,10 @@ import { useEffect, useState } from 'react';
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Collapse from '@/components/Collapse';
|
|
||||||
|
|
||||||
import { cn, formatNumber } from '@/lib/helper';
|
import { formatNumber } from '@/lib/helper';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { ProductionResult } from '@/types/api/report/production-result';
|
import { ProductionResult } from '@/types/api/report/production-result';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
@@ -52,8 +50,6 @@ const ProductionResultProjectFlockKandangTable = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const productionResultColumns: ColumnDef<ProductionResult>[] = [
|
const productionResultColumns: ColumnDef<ProductionResult>[] = [
|
||||||
@@ -270,93 +266,60 @@ const ProductionResultProjectFlockKandangTable = ({
|
|||||||
}
|
}
|
||||||
}, [sorting]);
|
}, [sorting]);
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
if (!open) {
|
<Card
|
||||||
setOpen(
|
title={kandangName}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
|
collapsible={true}
|
||||||
|
defaultCollapsed={
|
||||||
isResponseSuccess(productionResults)
|
isResponseSuccess(productionResults)
|
||||||
? productionResults.data.length > 0
|
? productionResults.data.length > 0
|
||||||
: false
|
: false
|
||||||
);
|
}
|
||||||
}
|
|
||||||
}, [productionResults, isResponseSuccess]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
body: 'p-4 shadow',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Collapse
|
<Table<ProductionResult>
|
||||||
open={open}
|
data={
|
||||||
onOpenChange={setOpen}
|
isResponseSuccess(productionResults) ? productionResults?.data : []
|
||||||
title={
|
|
||||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
|
||||||
<div className='card-title'>{kandangName}</div>
|
|
||||||
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:keyboard-arrow-down'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className={cn('text-primary transition-transform', {
|
|
||||||
'-rotate-180': open,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
className='w-full!'
|
columns={productionResultColumns}
|
||||||
titleClassName='w-full p-0!'
|
pageSize={tableFilterState.pageSize}
|
||||||
>
|
onPageSizeChange={setPageSize}
|
||||||
<div className='w-full p-0'>
|
rowOptions={[10, 20, 50, 100]}
|
||||||
{/* <div className='flex flex-col gap-2 mb-4'>
|
page={
|
||||||
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
isResponseSuccess(productionResults)
|
||||||
<DebouncedTextInput
|
? productionResults?.meta?.page
|
||||||
name='search'
|
: 0
|
||||||
placeholder='Cari Record'
|
}
|
||||||
value={tableFilterState.search}
|
totalItems={
|
||||||
onChange={searchChangeHandler}
|
isResponseSuccess(productionResults)
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
? productionResults?.meta?.total_results
|
||||||
/>
|
: 0
|
||||||
</div>
|
}
|
||||||
</div> */}
|
onPageChange={setPage}
|
||||||
|
isLoading={isLoadingProductionResults}
|
||||||
<Table<ProductionResult>
|
sorting={sorting}
|
||||||
data={
|
setSorting={setSorting}
|
||||||
isResponseSuccess(productionResults)
|
renderFooter={false}
|
||||||
? productionResults?.data
|
className={{
|
||||||
: []
|
containerClassName: 'w-full mb-0!',
|
||||||
}
|
tableWrapperClassName:
|
||||||
columns={productionResultColumns}
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
pageSize={tableFilterState.pageSize}
|
tableClassName: 'w-full table-auto text-sm',
|
||||||
onPageSizeChange={setPageSize}
|
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
|
||||||
rowOptions={[10, 20, 50, 100]}
|
headerColumnClassName:
|
||||||
page={
|
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
|
||||||
isResponseSuccess(productionResults)
|
bodyRowClassName:
|
||||||
? productionResults?.meta?.page
|
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
|
||||||
: 0
|
bodyColumnClassName:
|
||||||
}
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
totalItems={
|
}}
|
||||||
isResponseSuccess(productionResults)
|
/>
|
||||||
? productionResults?.meta?.total_results
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
|
||||||
isLoading={isLoadingProductionResults}
|
|
||||||
sorting={sorting}
|
|
||||||
setSorting={setSorting}
|
|
||||||
renderFooter={false}
|
|
||||||
className={{
|
|
||||||
containerClassName: cn({
|
|
||||||
'w-full mb-20':
|
|
||||||
isResponseSuccess(productionResults) &&
|
|
||||||
productionResults?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
headerColumnClassName:
|
|
||||||
'px-4 py-3 border-x border-base-content/10 text-base-content/50',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Collapse>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import {
|
||||||
|
createClosingTabSlice,
|
||||||
|
ClosingTabSlice,
|
||||||
|
} from '@/stores/closing/slices/closing-tab.slice';
|
||||||
|
|
||||||
|
export type ClosingTabStore = ClosingTabSlice;
|
||||||
|
|
||||||
|
export const useClosingTabStore = create<ClosingTabStore>()(
|
||||||
|
devtools(
|
||||||
|
(...args) => ({
|
||||||
|
...createClosingTabSlice(...args),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'ClosingTabStore',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { StateCreator } from 'zustand';
|
||||||
|
|
||||||
|
export type ClosingTabSlice = {
|
||||||
|
// State - actions per tab ID
|
||||||
|
tabActions: Record<string, ReactNode>;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setTabActions: (tabId: string, actions: ReactNode) => void;
|
||||||
|
clearTabActions: (tabId: string) => void;
|
||||||
|
clearAllTabActions: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createClosingTabSlice: StateCreator<
|
||||||
|
ClosingTabSlice,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ClosingTabSlice
|
||||||
|
> = (set) => ({
|
||||||
|
tabActions: {},
|
||||||
|
|
||||||
|
setTabActions: (tabId, actions) =>
|
||||||
|
set((state) => ({
|
||||||
|
tabActions: {
|
||||||
|
...state.tabActions,
|
||||||
|
[tabId]: actions,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearTabActions: (tabId) =>
|
||||||
|
set((state) => {
|
||||||
|
const { [tabId]: _, ...rest } = state.tabActions;
|
||||||
|
return { tabActions: rest };
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearAllTabActions: () => set({ tabActions: {} }),
|
||||||
|
});
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
import { create } from 'zustand';
|
|
||||||
import { devtools } from 'zustand/middleware';
|
|
||||||
|
|
||||||
export type FinanceTabActionsSlice = {
|
|
||||||
// State - actions per tab ID
|
|
||||||
tabActions: Record<string, ReactNode>;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
setTabActions: (tabId: string, actions: ReactNode) => void;
|
|
||||||
clearTabActions: (tabId: string) => void;
|
|
||||||
clearAllTabActions: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useFinanceTabStore = create<FinanceTabActionsSlice>()(
|
|
||||||
devtools(
|
|
||||||
(set) => ({
|
|
||||||
tabActions: {},
|
|
||||||
|
|
||||||
setTabActions: (tabId, actions) =>
|
|
||||||
set(
|
|
||||||
(state) => ({
|
|
||||||
tabActions: {
|
|
||||||
...state.tabActions,
|
|
||||||
[tabId]: actions,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
'setTabActions'
|
|
||||||
),
|
|
||||||
|
|
||||||
clearTabActions: (tabId) =>
|
|
||||||
set(
|
|
||||||
(state) => {
|
|
||||||
const { [tabId]: _, ...rest } = state.tabActions;
|
|
||||||
return { tabActions: rest };
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
'clearTabActions'
|
|
||||||
),
|
|
||||||
|
|
||||||
clearAllTabActions: () =>
|
|
||||||
set({ tabActions: {} }, false, 'clearAllTabActions'),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'FinanceTabStore',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import { createChickinApprovalSlice } from '@/stores/production/chickin/slices/chickin-approval.slice';
|
||||||
|
import { ChickinApprovalSlice } from '@/stores/production/chickin/slices/chickin-approval.slice';
|
||||||
|
|
||||||
|
export type ChickinStore = ChickinApprovalSlice;
|
||||||
|
|
||||||
|
export const useChickinStore = create<ChickinStore>()(
|
||||||
|
devtools(
|
||||||
|
(...args) => ({
|
||||||
|
...createChickinApprovalSlice(...args),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'ChickinStore',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { StateCreator } from 'zustand';
|
||||||
|
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
||||||
|
|
||||||
|
export type ChickinApprovalSlice = {
|
||||||
|
// State
|
||||||
|
isChickinApproveModalOpen: boolean;
|
||||||
|
selectedChickinForApproval: ProjectFlockKandang | null;
|
||||||
|
isChickinApproveLoading: boolean;
|
||||||
|
chickinApproveCallback: ((notes?: string) => Promise<void>) | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
openChickinApproveModal: (
|
||||||
|
data: ProjectFlockKandang,
|
||||||
|
callback: (notes?: string) => Promise<void>
|
||||||
|
) => void;
|
||||||
|
closeChickinApproveModal: () => void;
|
||||||
|
setChickinApproveLoading: (loading: boolean) => void;
|
||||||
|
resetChickinApproval: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createChickinApprovalSlice: StateCreator<
|
||||||
|
ChickinApprovalSlice,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ChickinApprovalSlice
|
||||||
|
> = (set) => ({
|
||||||
|
// Initial state
|
||||||
|
isChickinApproveModalOpen: false,
|
||||||
|
selectedChickinForApproval: null,
|
||||||
|
isChickinApproveLoading: false,
|
||||||
|
chickinApproveCallback: null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
openChickinApproveModal: (data, callback) =>
|
||||||
|
set({
|
||||||
|
isChickinApproveModalOpen: true,
|
||||||
|
selectedChickinForApproval: data,
|
||||||
|
chickinApproveCallback: callback,
|
||||||
|
}),
|
||||||
|
|
||||||
|
closeChickinApproveModal: () =>
|
||||||
|
set({
|
||||||
|
isChickinApproveModalOpen: false,
|
||||||
|
selectedChickinForApproval: null,
|
||||||
|
chickinApproveCallback: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
setChickinApproveLoading: (loading) =>
|
||||||
|
set({ isChickinApproveLoading: loading }),
|
||||||
|
|
||||||
|
resetChickinApproval: () =>
|
||||||
|
set({
|
||||||
|
isChickinApproveModalOpen: false,
|
||||||
|
selectedChickinForApproval: null,
|
||||||
|
isChickinApproveLoading: false,
|
||||||
|
chickinApproveCallback: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import { createProjectFlockSlice } from '@/stores/project-flock/slices/project-flock.slice';
|
import { createProjectFlockSlice } from '@/stores/production/project-flock/slices/project-flock.slice';
|
||||||
import { ProjectFlockSlice } from '@/types/stores';
|
import { ProjectFlockSlice } from '@/types/stores';
|
||||||
|
|
||||||
export type ProjectFlockStore = ProjectFlockSlice;
|
export type ProjectFlockStore = ProjectFlockSlice;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user