Merge branch 'development' into 'staging'

Development

See merge request mbugroup/lti-web-client!328
This commit is contained in:
Adnan Zahir
2026-02-20 09:53:46 +07:00
105 changed files with 8465 additions and 5989 deletions
+2 -40
View File
@@ -3,7 +3,7 @@
import { useRouter, useSearchParams } from 'next/navigation';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -34,33 +34,6 @@ const ClosingDetailPage = () => {
() => 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) {
router.back();
@@ -76,12 +49,7 @@ const ClosingDetailPage = () => {
return;
}
const isLoading =
isLoadingClosing ||
isLoadingSales ||
isLoadingHppEkspedisi ||
isLoadingProject ||
isLoadingKandang;
const isLoading = isLoadingClosing || isLoadingProject || isLoadingKandang;
return (
<div className='w-full p-4 flex flex-row justify-center'>
@@ -91,12 +59,6 @@ const ClosingDetailPage = () => {
<ClosingDetail
id={Number(closingId)}
initialValue={closing.data}
salesData={isResponseSuccess(salesData) ? salesData.data : undefined}
hppExpeditionData={
isResponseSuccess(hppEkspedisiData)
? hppEkspedisiData.data
: undefined
}
projectData={
isResponseSuccess(projectData) ? projectData.data : undefined
}
+1 -1
View File
@@ -2,7 +2,7 @@ import ClosingsTable from '@/components/pages/closing/ClosingsTable';
const Closing = () => {
return (
<section className='w-full p-4'>
<section className='w-full p-3'>
<ClosingsTable />
</section>
);
+2 -6
View File
@@ -1,13 +1,9 @@
'use client';
import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable';
import ReportExpenseTabs from '@/components/pages/report/expense/ReportExpenseTabs';
const ReportExpense = () => {
return (
<div className='w-full p-4'>
<ReportExpenseTable />
</div>
);
return <ReportExpenseTabs />;
};
export default ReportExpense;
+2 -6
View File
@@ -1,11 +1,7 @@
import MarketingReportContent from '@/components/pages/report/marketing/MarketingReportContent';
import MarketingReportContent from '@/components/pages/report/marketing/MarketingTabs';
const MarketingReportPage = () => {
return (
<section className='w-full p-4'>
<MarketingReportContent />
</section>
);
return <MarketingReportContent />;
};
export default MarketingReportPage;
+2 -2
View File
@@ -1,9 +1,9 @@
import ProductionResultContent from '@/components/pages/report/production-result/ProductionResultContent';
import ProductionResultTabs from '@/components/pages/report/production-result/ProductionResultTabs';
const ProductionResultReportPage = () => {
return (
<section className='w-full max-w-full'>
<ProductionResultContent />
<ProductionResultTabs />
</section>
);
};
+16 -6
View File
@@ -85,8 +85,8 @@ const DUMMY_SKELETON_DATA = Array.from({ length: 10 }, (_, index) => ({
}));
const emptyContentDefaultValue = (
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
<div className='w-full text-center py-4'>
<span className='text-sm opacity-50'>
Tidak ada data yang dapat ditampilkan...
</span>
</div>
@@ -452,6 +452,20 @@ const Table = <TData extends object>({
</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>
<tfoot
className={cn(
@@ -489,10 +503,6 @@ const Table = <TData extends object>({
</table>
</div>
{(data.length === 0 || table.getRowModel().rows.length === 0) &&
!isLoading &&
emptyContent}
{data.length > 0 &&
table.getRowModel().rows.length > 0 &&
!isLoading &&
+16 -23
View File
@@ -284,23 +284,22 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'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':
isFocused && !startAdornment,
isFocused && !startAdornment && !isError,
'border-base-content/10!': !isError && !isFocused,
'rounded-l-none!': inputPrefix && !startAdornment,
'rounded-r-none!': inputSuffix && !startAdornment,
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
placeholder: () =>
cn({
'text-gray-400 text-sm leading-tight': !isError,
'text-red-300!': isError,
cn('text-gray-400 text-sm leading-tight', {
'text-error!': isError,
}),
singleValue: () =>
cn({
'm-0! text-gray-900 text-sm leading-tight': !isError,
'text-error!': isError,
cn('m-0! text-gray-900 text-sm leading-tight', {
'text-error!': isError && !readOnly,
'text-gray-900!': readOnly,
}),
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)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn(
'w-full border transition-shadow',
// Gunakan rounded-lg untuk semua kasus
'rounded-lg!',
{
cn('w-full border transition-shadow rounded-lg!', {
'bg-base-100!': !isDisabled && !readOnly,
'bg-base-200! text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'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':
isFocused && !startAdornment,
isFocused && !startAdornment && !isError,
'border-base-content/10!': !isError && !isFocused,
}
),
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
placeholder: () =>
cn({
'text-gray-400 text-sm leading-tight': !isError,
'text-red-300!': isError,
cn('text-gray-400 text-sm leading-tight', {
'text-error!': isError,
}),
singleValue: () =>
cn({
'm-0! text-gray-900 text-sm leading-tight': !isError,
'text-error!': isError,
cn('m-0! text-gray-900 text-sm leading-tight', {
'text-error!': isError && !readOnly,
'text-gray-900!': readOnly,
}),
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
@@ -5,28 +5,23 @@ import { useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Tabs from '@/components/Tabs';
import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable';
import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent';
import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent';
import ClosingGeneralInformationTable from '@/components/pages/closing/table/ClosingGeneralInformationTable';
import SapronakClosingTab from '@/components/pages/closing/tab/SapronakClosingTab';
import ProductionDataClosingTab from '@/components/pages/closing/tab/ProductionDataClosingTab';
import {
ClosingGeneralInformation,
BaseClosingSales,
ClosingHppExpedition,
} from '@/types/api/closing';
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
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 { ClosingGeneralInformation } from '@/types/api/closing';
import SapronakCalculationClosingTab from '@/components/pages/closing/tab/SapronakCalculationClosingTab';
import OverheadClosingTab from '@/components/pages/closing/tab/OverheadClosingTab';
import FinanceClosingTab from '@/components/pages/closing/tab/FinanceClosingTab';
import SalesClosingTab from '@/components/pages/closing/tab/SalesClosingTab';
import HppExpeditionClosingTab from '@/components/pages/closing/tab/HppExpeditionClosingTab';
import ClosingKandangList from '@/components/pages/closing/ClosingKandangList';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { useClosingTabStore } from '@/stores/closing/closing-tab.store';
interface ClosingDetailProps {
id: number;
initialValue?: ClosingGeneralInformation;
salesData?: BaseClosingSales;
hppExpeditionData?: ClosingHppExpedition;
projectData?: ProjectFlock;
kandangData?: ProjectFlockKandang;
}
@@ -34,25 +29,24 @@ interface ClosingDetailProps {
const ClosingDetail: React.FC<ClosingDetailProps> = ({
id,
initialValue,
salesData,
hppExpeditionData,
projectData,
kandangData,
}) => {
const [activeTab, setActiveTab] = useState<string>('sapronak');
const [activeTabId, setActiveTabId] = useState<string>('sapronak');
const tabActions = useClosingTabStore((state) => state.tabActions);
const closingDetailTabs = useMemo(() => {
const validTabs = [
{
id: 'sapronak',
label: 'Sapronak',
content: <ClosingSapronakTabContent projectFlockId={id} />,
content: <SapronakClosingTab projectFlockId={id} />,
},
{
id: 'perhitunganSapronak',
label: 'Perhitungan Sapronak',
content: (
<ClosingSapronakCalculationTabContent
<SapronakCalculationClosingTab
closingGeneralInformation={initialValue}
projectFlockId={id}
/>
@@ -61,13 +55,13 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
{
id: 'penjualan',
label: 'Penjualan',
content: <SalesReportTable initialValues={salesData} />,
content: <SalesClosingTab projectFlockId={id} />,
},
{
id: 'overhead',
label: 'Overhead',
content: (
<ClosingOverheadTabContent
<OverheadClosingTab
projectFlockId={id}
generalInformation={initialValue}
kandangData={kandangData}
@@ -77,26 +71,26 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
{
id: 'hppEkspedisi',
label: 'HPP Ekspedisi',
content: <HppExpeditionReportTable initialValues={hppExpeditionData} />,
content: <HppExpeditionClosingTab projectFlockId={id} />,
},
{
id: 'dataProduksi',
label: 'Data Produksi',
content: <ClosingProductionDataTabContent projectFlockId={id} />,
content: <ProductionDataClosingTab projectFlockId={id} />,
},
{
id: 'keuangan',
label: 'Keuangan',
content: <ClosingFinanceTabContent projectFlockId={id} />,
content: <FinanceClosingTab projectFlockId={id} />,
},
];
return validTabs;
}, [initialValue]);
}, [initialValue, kandangData, id]);
return (
<>
<section className='w-full max-w-7xl pb-16'>
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href={
@@ -126,13 +120,17 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
)}
<Tabs
activeTabId={activeTab}
onTabChange={setActiveTab}
activeTabId={activeTabId}
onTabChange={setActiveTabId}
tabs={closingDetailTabs}
variant='lifted'
variant='boxed'
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>
</>
@@ -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;
}) => {
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='w-full'>
<div className='overflow-x-auto'>
<h1 className='font-bold my-4'>Kandang</h1>
<div className='flex flex-wrap gap-2 mb-4'>
<h1 className='font-bold mb-3'>Kandang</h1>
<div className='flex flex-wrap gap-2'>
{projectData?.kandangs?.map((kandang) => (
<Button
key={kandang.id}
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}`}
className='min-w-32'
>
{kandang.name}
</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;
+333 -113
View File
@@ -1,68 +1,116 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { ChangeEventHandler, useEffect, useState, useMemo } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { LocationApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { ClosingApi } from '@/services/api/closing';
import { Closing } from '@/types/api/closing';
const PROJECT_STATUS_OPTIONS = [
{
value: 1,
label: 'Pengajuan',
},
{
value: 2,
label: 'Aktif',
},
];
import { Color } from '@/types/theme';
import {
ClosingFilterSchema,
ClosingFilterType,
} from '@/components/pages/closing/filter/ClosingFilter';
import ClosingTableSkeleton from '@/components/pages/closing/skeleton/ClosingTableSkeleton';
const RowOptionsMenu = ({
type = 'dropdown',
props,
popoverPosition = 'bottom',
detailClickHandler,
}: {
type: 'dropdown' | 'collapse';
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 (
<RowOptionsMenuWrapper type={type}>
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
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
href={`/closing/detail/?closingId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
color='none'
onClick={detailClickHandlerWrapper}
className='p-3 justify-start text-sm font-semibold w-full'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
<Icon icon='heroicons:eye' width={20} height={20} />
View Details
</Button>
</RequirePermission>
</div>
</RowOptionsMenuWrapper>
</PopoverContent>
</div>
);
};
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 {
state: tableFilterState,
updateFilter,
@@ -72,36 +120,67 @@ const ClosingsTable = () => {
} = useTableFilter({
initial: {
search: '',
nameSort: '',
transactionDate: '',
realizationDate: '',
locationId: '',
projectStatus: '',
userId: '',
// nameSort: '',
// transactionDate: '',
// realizationDate: '',
location_id: '',
project_status: '',
// userId: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
transactionDate: 'transaction_date',
realizationDate: 'realization_date',
locationId: 'location_id',
projectStatus: 'project_status',
userId: 'user_id',
// nameSort: 'sort_name',
// transactionDate: 'transaction_date',
// realizationDate: 'realization_date',
// locationId: 'location_id',
// projectStatus: 'project_status',
// 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(
`${ClosingApi.basePath}${getTableFilterQueryString()}`,
ClosingApi.getAllFetcher
);
const data = useMemo(
() =>
isResponseSuccess(closings) ? (closings?.data as Closing[]) || [] : [],
[closings]
);
// ===== PAGINATION & STATE =====
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
// ===== TABLE COLUMNS =====
const closingsColumns: ColumnDef<Closing>[] = [
{
header: '#',
header: 'No',
cell: (props) => props.row.index + 1,
},
{
@@ -133,6 +212,19 @@ const ClosingsTable = () => {
{
accessorKey: 'project_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',
@@ -142,27 +234,24 @@ const ClosingsTable = () => {
const currentRowRelativeIndex =
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 (
<>
{currentPageSize > 3 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 3 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
<RowOptionsMenu
props={props}
detailClickHandler={detailClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
];
// ===== LOCATION OPTIONS =====
const {
setInputValue: setLocationInputValue,
options: locationOptions,
@@ -170,91 +259,140 @@ const ClosingsTable = () => {
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
// ===== PROJECT STATUS OPTIONS =====
const projectStatusOptions = useMemo(
() => [
{ value: '1', label: 'Pengajuan' },
{ value: '2', label: 'Aktif' },
],
[]
);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'locationId',
val ? ((val as OptionType).value as string) : ''
// ===== FILTER HELPERS =====
const locationIdValue = 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 [selectedProjectStatus, setSelectedProjectStatus] =
useState<OptionType | null>(null);
const projectStatusChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedProjectStatus(val as OptionType);
updateFilter(
'projectStatus',
val ? ((val as OptionType).value as string) : ''
const projectStatusValue = useMemo(() => {
if (!formik.values.project_status) return null;
return (
projectStatusOptions.find(
(opt) => opt.value === formik.values.project_status
) || null
);
};
}, [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) => {
updateFilter('search', e.target.value);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
// updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
// updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
}, [sorting]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-end items-end sm:items-center gap-4'>
<div className='w-full'>
<div className='flex flex-col 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='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Cari Closing'
value={tableFilterState.search}
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
startAdornment={
<Icon
icon='heroicons:magnifying-glass'
width={20}
height={20}
/>
</div>
<div className='grid grid-cols-12 justify-end gap-2'>
<SelectInput
label='Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
}
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
label='Status Project'
placeholder='Pilih Status'
options={PROJECT_STATUS_OPTIONS}
value={selectedProjectStatus}
onChange={projectStatusChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
</div>
<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 && (
<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>
{isLoadingClosings ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<ClosingTableSkeleton
columns={closingsColumns}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Closing Belum Tersedia'
subtitle='Tidak ada data closing untuk saat ini.'
/>
) : (
<Table<Closing>
data={isResponseSuccess(closings) ? closings?.data : []}
columns={closingsColumns}
@@ -272,13 +410,95 @@ const ClosingsTable = () => {
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
containerClassName: cn('mt-3', {
'w-full mb-0':
isResponseSuccess(closings) && closings?.data?.length === 0,
}),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</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;
@@ -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 { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
interface ClosingOverheadTabContentProps {
interface OverheadClosingTabProps {
projectFlockId: number;
generalInformation?: ClosingGeneralInformation;
kandangData?: ProjectFlockKandang;
}
const ClosingOverheadTabContent = ({
const OverheadClosingTab = ({
projectFlockId,
generalInformation,
kandangData,
}: ClosingOverheadTabContentProps) => {
}: OverheadClosingTabProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<ClosingOverheadTable
<OverheadClosingTable
projectFlockId={projectFlockId}
generalInformation={generalInformation}
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;
@@ -1,22 +1,22 @@
'use client';
import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable';
import SapronakCalculationClosingTable from '@/components/pages/closing/table/SapronakCalculationClosingTable';
import { ClosingGeneralInformation } from '@/types/api/closing';
interface ClosingSapronakCalculationTabContentProps {
interface SapronakCalculationClosingTabProps {
projectFlockId?: number;
closingGeneralInformation?: ClosingGeneralInformation;
}
const ClosingSapronakCalculationTabContent = ({
const SapronakCalculationClosingTab = ({
projectFlockId,
closingGeneralInformation,
}: ClosingSapronakCalculationTabContentProps) => {
}: SapronakCalculationClosingTabProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<>
<ClosingSapronakCalculationTable
<SapronakCalculationClosingTable
closingGeneralInformation={closingGeneralInformation}
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;
@@ -14,18 +14,19 @@ import { ColumnDef } from '@tanstack/react-table';
import { useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import useSWR from 'swr';
import OverheadClosingSkeleton from '@/components/pages/closing/skeleton/OverheadClosingSkeleton';
interface ClosingOverheadTableProps {
interface OverheadClosingTableProps {
projectFlockId: number;
generalInformation?: ClosingGeneralInformation;
kandangData?: ProjectFlockKandang;
}
const ClosingOverheadTable = ({
const OverheadClosingTable = ({
projectFlockId,
generalInformation,
kandangData,
}: ClosingOverheadTableProps) => {
}: OverheadClosingTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
@@ -208,16 +209,44 @@ const ClosingOverheadTable = ({
);
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='Pengeluaran Overhead'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
{isLoadingOverhead ? (
<OverheadClosingSkeleton columns={columns} />
) : !isResponseSuccess(overhead) ? (
<OverheadClosingSkeleton
columns={columns}
iconName='heroicons:chart-bar'
title='Data Overhead Tidak Ditemukan'
subtitle='Tidak ada data overhead untuk periode ini.'
/>
) : kandangId && !isResponseSuccess(overheadKandang) ? (
<OverheadClosingSkeleton
columns={columns}
iconName='heroicons:chart-bar'
title='Data Overhead Tidak Ditemukan'
subtitle='Tidak ada data overhead untuk periode ini.'
/>
) : (!kandangId && overhead.data?.overheads.length === 0) ||
(kandangId &&
isResponseSuccess(overheadKandang) &&
overheadKandang.data?.overheads.length === 0) ? (
<OverheadClosingSkeleton
columns={columns}
iconName='heroicons:chart-bar'
/>
) : (
<Table<Overhead>
data={
kandangId
@@ -230,11 +259,24 @@ const ClosingOverheadTable = ({
}
columns={columns}
className={{
containerClassName: 'my-4',
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(
TABLE_DEFAULT_STYLING.headerColumnClassName,
'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={
@@ -243,7 +285,8 @@ const ClosingOverheadTable = ({
: false
}
/>
{kandangId && (
)}
{kandangId && !isLoadingOverhead && isResponseSuccess(overhead) && (
<Card
className={{
wrapper: 'w-full',
@@ -298,8 +341,8 @@ const ClosingOverheadTable = ({
</Card>
)}
</Card>
</>
</div>
);
};
export default ClosingOverheadTable;
export default OverheadClosingTable;
@@ -5,28 +5,47 @@ import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table';
import Card from '@/components/Card';
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
import {
BaseClosingSales,
BaseSales,
ClosingSalesSummary,
} from '@/types/api/closing';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseSales, ClosingSalesSummary } from '@/types/api/closing';
import { Product } from '@/types/api/master-data/product';
import { Customer } from '@/types/api/master-data/customer';
import { Kandang } from '@/types/api/master-data/kandang';
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 {
type?: 'detail';
initialValues?: BaseClosingSales;
interface SalesClosingTableProps {
projectFlockId: number;
}
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(() => {
return initialValues?.sales || [];
}, [initialValues]);
if (isResponseSuccess(sales)) {
return sales.data.sales || [];
}
return [];
}, [sales]);
const summary: ClosingSalesSummary | undefined = useMemo(() => {
return initialValues?.summary;
}, [initialValues]);
if (isResponseSuccess(sales)) {
return sales.data.summary;
}
return undefined;
}, [sales]);
const totals = useMemo(() => {
if (salesData.length === 0) {
@@ -293,27 +312,42 @@ const SalesReportTable = ({ initialValues }: SalesReportTableProps) => {
);
return (
<>
<section className='w-full'>
<div className='p-4'>
<h2 className='text-xl font-semibold mb-4'>Penjualan</h2>
<div className='w-full pt-3'>
<Card
className={{
wrapper: 'w-full bg-base-100',
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='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={{
tableWrapperClassName: 'overflow-x-auto',
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-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',
'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-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200',
'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:
@@ -323,11 +357,10 @@ const SalesReportTable = ({ initialValues }: SalesReportTableProps) => {
'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;
@@ -1,20 +1,20 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import Badge from '@/components/Badge';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingIncomingSapronakSummary } from '@/types/api/closing';
import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton';
interface ClosingIncomingSapronaksSummaryTableProps {
projectFlockId: number;
@@ -55,20 +55,60 @@ const ClosingIncomingSapronaksSummaryTable = ({
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronakSummary>[] =
[
{
header: '#',
header: 'No',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
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',
@@ -78,10 +118,6 @@ const ClosingIncomingSapronaksSummaryTable = ({
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
@@ -93,44 +129,35 @@ const ClosingIncomingSapronaksSummaryTable = ({
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries.data.length > 0
: false
);
}
}, [incomingSapronakSummaries, isResponseSuccess]);
return (
<div className='w-full'>
<Card
className={{
wrapper: 'w-full',
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',
collapsible: 'rounded-lg',
}}
variant='bordered'
title='Ringkasan Sapronak Masuk'
collapsible
defaultCollapsed={false}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Ringkasan Sapronak Masuk</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
{isLoadingIncomingSapronakSummaries ? (
<SapronakClosingSkeleton
type='incoming'
columns={incomingSapronaksColumns}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
) : 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>
data={
isResponseSuccess(incomingSapronakSummaries)
@@ -158,16 +185,21 @@ const ClosingIncomingSapronaksSummaryTable = ({
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronakSummaries) &&
incomingSapronakSummaries?.data?.length === 0,
}),
containerClassName: 'w-full mb-5!',
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-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>
</div>
);
};
@@ -9,13 +9,14 @@ 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 Badge from '@/components/Badge';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingIncomingSapronak } from '@/types/api/closing';
import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton';
interface ClosingIncomingSapronaksTableProps {
projectFlockId: number;
@@ -51,14 +52,12 @@ const ClosingIncomingSapronaksTable = ({
ClosingApi.getAllIncomingSapronakFetcher
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronak>[] = [
{
header: '#',
header: 'No',
cell: (props) => props.row.index + 1,
},
{
@@ -81,6 +80,48 @@ const ClosingIncomingSapronaksTable = ({
{
accessorKey: 'product_category',
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',
@@ -117,56 +158,59 @@ const ClosingIncomingSapronaksTable = ({
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(incomingSapronaks)
? incomingSapronaks.data.length > 0
: false
);
}
}, [incomingSapronaks, isResponseSuccess]);
return (
<div className='w-full pt-3'>
<Card
className={{
wrapper: 'w-full',
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',
collapsible: 'rounded-lg',
}}
variant='bordered'
title='Sapronak Masuk'
collapsible
defaultCollapsed={false}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Sapronak Masuk</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='flex flex-col gap-2 my-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' }}
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>
{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>
data={
isResponseSuccess(incomingSapronaks)
@@ -194,16 +238,21 @@ const ClosingIncomingSapronaksTable = ({
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronaks) &&
incomingSapronaks?.data?.length === 0,
}),
containerClassName: 'w-full mb-5!',
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-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>
</div>
);
};
@@ -1,20 +1,20 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import Badge from '@/components/Badge';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingOutgoingSapronakSummary } from '@/types/api/closing';
import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton';
interface ClosingOutgoingSapronaksSummaryTableProps {
projectFlockId: number;
@@ -55,20 +55,60 @@ const ClosingOutgoingSapronaksSummaryTable = ({
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronakSummary>[] =
[
{
header: '#',
header: 'No',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
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',
@@ -78,10 +118,6 @@ const ClosingOutgoingSapronaksSummaryTable = ({
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
@@ -93,44 +129,35 @@ const ClosingOutgoingSapronaksSummaryTable = ({
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries.data.length > 0
: false
);
}
}, [outgoingSapronakSummaries, isResponseSuccess]);
return (
<div className='w-full'>
<Card
className={{
wrapper: 'w-full',
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',
collapsible: 'rounded-lg',
}}
variant='bordered'
title='Ringkasan Sapronak Keluar'
collapsible
defaultCollapsed={false}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Ringkasan Sapronak Keluar</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
{isLoadingOutgoingSapronakSummaries ? (
<SapronakClosingSkeleton
type='outgoing'
columns={outgoingSapronaksColumns}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
) : 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>
data={
isResponseSuccess(outgoingSapronakSummaries)
@@ -158,16 +185,21 @@ const ClosingOutgoingSapronaksSummaryTable = ({
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronakSummaries) &&
outgoingSapronakSummaries?.data?.length === 0,
}),
containerClassName: 'w-full mb-5!',
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-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>
</div>
);
};
@@ -9,13 +9,16 @@ 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 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingOutgoingSapronak } from '@/types/api/closing';
import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton';
interface ClosingOutgoingSapronaksTableProps {
projectFlockId: number;
@@ -51,14 +54,12 @@ const ClosingOutgoingSapronaksTable = ({
ClosingApi.getAllOutgoingSapronakFetcher
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronak>[] = [
{
header: '#',
header: 'No',
cell: (props) => props.row.index + 1,
},
{
@@ -81,6 +82,48 @@ const ClosingOutgoingSapronaksTable = ({
{
accessorKey: 'product_category',
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',
@@ -117,56 +160,59 @@ const ClosingOutgoingSapronaksTable = ({
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks.data.length > 0
: false
);
}
}, [outgoingSapronaks, isResponseSuccess]);
return (
<div className='w-full'>
<Card
className={{
wrapper: 'w-full',
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',
collapsible: 'rounded-lg',
}}
variant='bordered'
title='Sapronak Keluar'
collapsible
defaultCollapsed={false}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Sapronak Keluar</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='flex flex-col gap-2 my-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' }}
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>
{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>
data={
isResponseSuccess(outgoingSapronaks)
@@ -194,16 +240,21 @@ const ClosingOutgoingSapronaksTable = ({
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronaks) &&
outgoingSapronaks?.data?.length === 0,
}),
containerClassName: 'w-full mb-5!',
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-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>
</div>
);
};
@@ -35,6 +35,13 @@ const StockLogTable = ({
header: 'Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Stock Akhir',
accessorKey: 'stock',
cell: (props) => {
return formatNumber(props.row.original.stock);
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
@@ -49,6 +49,7 @@ const SalesOrderProductTable = ({
>
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
{data.length > 1 && (
<Button
type='button'
variant='ghost'
@@ -60,6 +61,7 @@ const SalesOrderProductTable = ({
>
<Icon icon='heroicons:trash' width={20} height={20} />
</Button>
)}
</div>
)}
</div>
@@ -16,6 +16,7 @@ import ChickinLogsView from '@/components/pages/production/chickin/form/tabs/Chi
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { Icon } from '@iconify/react';
import Badge from '@/components/Badge';
import StatusBadge from '@/components/helper/StatusBadge';
import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line';
import RequirePermission from '@/components/helper/RequirePermission';
import { BaseApproval } from '@/types/api/api-general';
@@ -53,88 +54,83 @@ const ChickinFormKandang = ({
};
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
subtitle={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
leftIcon='mdi:arrow-left'
leftIcon='heroicons:chevron-left'
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 */}
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-xl font-semibold'>Informasi Kandang</h2>
{approvals && !approvalsLoading && (
<div className='mb-3 text-sm'>
<ApprovalSteps approvals={approvals} />
</div>
)}
{/* Informasi Kandang */}
<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'>
Informasi Kandang
</h4>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color='primary'
<StatusBadge
color='success'
text='Active'
className={{ badge: 'w-fit text-nowrap' }}
/>
<div className='divider divider-horizontal p-0 m-0'></div>
<StatusBadge
color='neutral'
text={` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`}
className={{ badge: 'w-fit text-nowrap' }}
/>
</div>
{/* Information Card */}
<Card
variant='bordered'
className={{
badge: 'rounded-lg px-2',
wrapper: 'w-full rounded-lg',
body: 'p-3',
}}
>
<Icon icon='mdi:circle' width={12} height={12} color='primary' />{' '}
Aktif
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:home' width={12} height={12} />
{` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`}
</Badge>
<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>
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
{/* Area */}
<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' /> Area
</div>
<div className='col-span-2'>
<div className='text-end text-gray-500'>
{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'>
<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>
{/* 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 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='col-span-2'>
<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,
@@ -145,43 +141,39 @@ const ChickinFormKandang = ({
</div>
</div>
</div>
</Card>
</div>
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-xl font-semibold'>Informasi Chick In</h2>
{/* Informasi Chick In */}
<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'>
Informasi Chick In
</h4>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<RequirePermission permissions='lti.production.chickins.create'>
<Badge
variant='soft'
color={'success'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'success'}
/>{' '}
Perlu Chick In ({initialValues.available_qtys?.length ?? 0})
</Badge>
<StatusBadge
color='success'
text={`Perlu Chick In (${initialValues.available_qtys?.length ?? 0})`}
className={{ badge: 'w-fit text-nowrap' }}
/>
<div className='divider divider-horizontal p-0 m-0'></div>
</RequirePermission>
<Badge
<StatusBadge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2 cursor-pointer' }}
onClick={() => setOpenChickin(!openChickin)}
>
text={
<>
{`Riwayat Chick In ${formatNumber(initialValues.chickins?.length ?? 0)}`}
<Icon
icon={`mdi:${openChickin ? 'eye' : 'eye-off'}`}
width={12}
height={12}
/>
</Badge>
</>
}
className={{ badge: 'w-fit text-nowrap cursor-pointer' }}
/>
</div>
</div>
{openChickin && (
@@ -198,7 +190,7 @@ const ChickinFormKandang = ({
afterSubmit={afterSubmitFormChickin}
/>
</RequirePermission>
</section>
</div>
);
};
@@ -2,8 +2,6 @@ import Alert from '@/components/Alert';
import Button from '@/components/Button';
import Card from '@/components/Card';
import RequirePermission from '@/components/helper/RequirePermission';
import { useModal } from '@/components/Modal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import PillBadge from '@/components/PillBadge';
import { isResponseError, isResponseSuccess } from '@/lib/api-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 { useState } from 'react';
import toast from 'react-hot-toast';
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
const ChickinLogsView = ({
initialValues,
@@ -23,17 +22,12 @@ const ChickinLogsView = ({
afterSubmit?: () => void;
rawDataApprovals: BaseApproval[];
}) => {
const confirmModal = useModal();
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
const { openChickinApproveModal } = useChickinStore();
const handleClickApprove = () => {
confirmModal.openModal();
};
const confirmationModalApproveClickHandler = async (notes?: string) => {
openChickinApproveModal(initialValues, async (notes?: string) => {
setChickinErrorMessage('');
setIsApproveLoading(true);
const approveChickinRes = await ChickinApi.singleApproval(
initialValues?.id as number,
'APPROVED',
@@ -46,9 +40,8 @@ const ChickinLogsView = ({
toast.error(approveChickinRes?.message as string);
setChickinErrorMessage(approveChickinRes?.message as string);
}
confirmModal.closeModal();
setIsApproveLoading(false);
afterSubmit && afterSubmit();
});
};
return (
@@ -83,7 +76,7 @@ const ChickinLogsView = ({
key={chickin.id || index}
variant='bordered'
className={{
wrapper: 'w-full',
wrapper: 'w-full mt-3',
body: 'p-3',
}}
>
@@ -176,23 +169,6 @@ const ChickinLogsView = ({
</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 PopoverContent from '@/components/popover/PopoverContent';
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 { useChickinStore } from '@/stores/production/chickin/chickin.store';
const RowOptionsMenu = ({
props,
@@ -193,6 +194,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const deleteModal = useModal();
const confirmModal = useModal();
const successModal = useModal();
const chickinApproveModal = useModal();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED'
);
@@ -200,6 +202,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const {
isChickinApproveModalOpen,
isChickinApproveLoading,
chickinApproveCallback,
closeChickinApproveModal,
setChickinApproveLoading,
} = useChickinStore();
// ===== Fetch Data =====
const {
@@ -271,7 +280,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
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();
}
if (isResponseError(approveProjectFlockRes)) {
@@ -288,6 +301,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
refreshProjectFlocks();
}, [refresh]);
useEffect(() => {
if (isChickinApproveModalOpen) {
chickinApproveModal.openModal();
} else {
chickinApproveModal.closeModal();
}
}, [isChickinApproveModalOpen, chickinApproveModal]);
useEffect(() => {
if (isSuccess) {
successModal.openModal();
@@ -384,7 +405,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
id: 'select',
header: ({ table }) => {
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 =
selectableRows.every((row) => row.getIsSelected()) &&
@@ -398,6 +425,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
selectableRows.forEach((row) => row.toggleSelected(shouldSelect));
};
const hasNoSelectableRows = selectableRows.length === 0;
return (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
@@ -405,6 +434,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
disabled={hasNoSelectableRows}
/>
</div>
);
@@ -845,6 +875,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={(row) => {
const projectFlock = row.original;
return (
projectFlock.approval?.step_number === 1 &&
projectFlock.approval?.action !== 'REJECTED'
);
}}
withCheckbox
className={{
containerClassName: cn('p-3', {
@@ -954,6 +991,40 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
onClose={handleSuccessModalClose}
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,
}}
/>
</>
);
};
@@ -1,10 +1,12 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import Table from '@/components/Table';
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 {
ClosingExpense,
@@ -20,9 +22,28 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ApprovalApi } from '@/services/api/approval';
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 = ({
projectFlock,
@@ -49,6 +70,10 @@ const ProjectFlockClosingForm = ({
)
);
const isKandangClosed = useMemo(() => {
return projectFlockKandang.kandang?.status === 'NON_ACTIVE';
}, [projectFlockKandang]);
const isCanClose = useMemo(() => {
return isResponseSuccess(projectFlockKandangApprovals)
? projectFlockKandangApprovals?.data?.[0]?.step_number <= 2
@@ -60,8 +85,10 @@ const ProjectFlockClosingForm = ({
const deleteProjectFlockRes = await ProjectFlockKandangApi.closing(
projectFlockKandang?.id as number,
{
closed_date: isCanClose ? formatDate(new Date(), 'YYYY-MM-DD') : '',
action: isCanClose ? 'close' : 'unclose',
closed_date: !isKandangClosed
? formatDate(new Date(), 'YYYY-MM-DD')
: '',
action: !isKandangClosed ? 'close' : 'unclose',
}
);
@@ -78,97 +105,95 @@ const ProjectFlockClosingForm = ({
closeModal.closeModal();
};
const errorStock = useMemo(() => {
return isResponseSuccess(closingData)
? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0)
: true;
}, [closingData]);
// const errorStock = useMemo(() => {
// return isResponseSuccess(closingData)
// ? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0)
// : true;
// }, [closingData]);
const errorExpense = useMemo(() => {
return isResponseSuccess(closingData)
? closingData?.data?.expenses.every((expense) => expense.step < 5)
: true;
}, [closingData]);
// const errorExpense = useMemo(() => {
// return isResponseSuccess(closingData)
// ? closingData?.data?.expenses.every((expense) => expense.step < 5)
// : true;
// }, [closingData]);
const isCanCloseValid = true;
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
leftIcon='mdi:arrow-left'
leftIcon='heroicons:chevron-left'
leftIconHref={`/production/project-flock/detail?projectFlockId=${projectFlock.id}`}
subtitle={`Close ${projectFlock.flock_name}`}
></DrawerHeader>
leftIconClassName='hover:text-gray-400'
subtitle={isKandangClosed ? 'Unclose Flock' : 'Close Flock'}
className='sticky top-0 z-10 bg-base-100'
/>
{/* Informasi Kandang */}
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Kandang</h2>
<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'>
Informasi Kandang
</h4>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color='success'
<StatusBadge
color={isKandangClosed ? 'error' : 'success'}
text={isKandangClosed ? 'Closed' : 'Active'}
className={{ badge: 'w-fit text-nowrap' }}
/>
<div className='divider divider-horizontal p-0 m-0'></div>
<StatusBadge
color='neutral'
text={` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`}
className={{ badge: 'w-fit text-nowrap' }}
/>
</div>
{/* Information Card */}
<Card
variant='bordered'
className={{
badge: 'rounded-lg px-2',
wrapper: 'w-full rounded-lg',
body: 'p-3',
}}
>
<Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
Aktif
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:home' width={12} height={12} />
{` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`}
</Badge>
<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>
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
{/* Area */}
<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' /> Area
<div className='text-end text-gray-500'>
{projectFlock.area?.name}
</div>
<div className='col-span-2'>{projectFlock.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'>{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 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='col-span-2'>
<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>
{/* 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'>
<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,
@@ -179,59 +204,68 @@ const ProjectFlockClosingForm = ({
</div>
</div>
</div>
</Card>
</div>
{/* Table Biaya */}
<div className='divider'></div>
<div className='px-4 pb-4'>
<h2 className='text-2xl font-semibold'>Biaya</h2>
<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'>
Biaya
</h4>
<Table<ClosingExpense>
data={
isResponseSuccess(closingData) ? closingData.data?.expenses : []
}
columns={[
{
header: 'Ref Number',
accessorKey: 'reference_number',
cell(props) {
return props.row.original.reference_number || '-';
},
},
{
header: 'PO Number',
accessorKey: 'po_number',
cell(props) {
return props.row.original.po_number || '-';
},
},
{
header: 'Total',
accessorKey: 'total',
cell(props) {
return formatNumber(props.row.original.total);
},
},
{
header: 'Status',
accessorKey: 'status',
cell(props) {
return (
<Badge
<StatusBadge
color={getExpenseStatusBadgeColor(
props.row.original.step
)}
text={formatTitleCase(props.row.original.step_name)}
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={{
containerClassName: cn('my-4'),
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
tableClassName: 'font-inter w-full table-sm min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
containerClassName: 'mb-0',
tableWrapperClassName: 'overflow-x-auto max-w-120',
tableClassName: 'font-inter w-full table-sm',
headerRowClassName: 'border-b border-base-content/10',
headerColumnClassName:
'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:
'px-3 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{/* {errorExpense && (
@@ -242,9 +276,10 @@ const ProjectFlockClosingForm = ({
</div>
{/* Table Persediaan Gudang */}
<div className='divider'></div>
<div className='px-4 pb-4'>
<h2 className='text-2xl font-semibold'>Persediaan Gudang</h2>
<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'>
Persediaan Gudang
</h4>
<Table<StockItem>
data={
isResponseSuccess(closingData)
@@ -263,6 +298,9 @@ const ProjectFlockClosingForm = ({
{
header: 'Quantity',
accessorKey: 'quantity',
cell(props) {
return formatNumber(props.row.original.quantity);
},
},
{
header: 'UOM',
@@ -270,16 +308,15 @@ const ProjectFlockClosingForm = ({
},
]}
className={{
containerClassName: cn('my-4'),
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
tableClassName: 'font-inter w-full table-sm min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
containerClassName: 'mb-0',
tableWrapperClassName: 'overflow-x-auto max-w-120',
tableClassName: 'font-inter w-full table-sm',
headerRowClassName: 'border-b border-base-content/10',
headerColumnClassName:
'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:
'px-3 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{/* {errorStock && (
@@ -289,17 +326,24 @@ const ProjectFlockClosingForm = ({
)} */}
</div>
<div className='p-4 mt-6'>
<div className='p-4'>
<RequirePermission permissions='lti.production.project_flock_kandangs.closing'>
<Button
className='w-full'
variant='outline'
color='error'
isLoading={isLoading}
disabled={!isCanCloseValid}
onClick={() => closeModal.openModal()}
>
<Icon icon='mdi:checkbox-marked-circle-outline' />{' '}
{isCanClose ? 'Close' : 'Unclose'}
<Icon
icon={
isKandangClosed
? 'mdi:lock-open-variant'
: 'mdi:checkbox-marked-circle-outline'
}
/>{' '}
{isKandangClosed ? 'Unclose' : 'Close'}
</Button>
</RequirePermission>
</div>
@@ -308,7 +352,7 @@ const ProjectFlockClosingForm = ({
ref={closeModal.ref}
type='error'
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 membuka kembali project ini ? *Project ini akan kembali ke status aktif'
}
@@ -322,7 +366,7 @@ const ProjectFlockClosingForm = ({
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'>
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
Kandang Aktif
Kandang
</h4>
<div className='flex flex-row gap-2'>
<div className='flex flex-row flex-wrap gap-2 overflow-hidden'>
{projectFlock.kandangs?.filter(
(kandang) => kandang.status !== 'NON_ACTIVE'
).length > 0 && (
<StatusBadge
color='info'
text={`Kandang Aktif (${projectFlock.kandangs?.length})`}
className={{ badge: 'w-fit text-nowrap' }}
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
color='neutral'
@@ -256,7 +278,7 @@ const ProjectFlockDetail = ({
/>
</>
}
className={{ badge: 'w-fit text-nowrap cursor-pointer' }}
className={{ badge: 'w-fit cursor-pointer' }}
/>
</div>
@@ -355,25 +377,34 @@ const ProjectFlockDetail = ({
disabled={projectFlock?.approval?.step_number == 1}
/>
<div className='text-end'>
<Badge
className={{
badge: 'rounded-lg',
}}
>
Kapasitas {kandang?.capacity} Ekor
</Badge>
<StatusBadge
color={
kandang?.status === 'NON_ACTIVE' ? 'error' : 'success'
}
text={<>Kapasitas {kandang?.capacity} Ekor</>}
className={{ badge: 'w-fit text-nowrap' }}
/>
</div>
</div>
))}
</RadioGroup>
</Card>
<div className='-mx-4'>
<ApprovalStepsV2
approvals={projectFlockKandangApproval}
steps={APPROVAL_WORKFLOWS.PROJECT_FLOCK_KANDANGS}
/>
</div>
<div className='grid grid-cols-4 gap-3'>
<div
className={`grid gap-3 ${
selectedKandang?.status !== 'NON_ACTIVE'
? 'grid-cols-2'
: 'grid-cols-1'
}`}
>
{selectedKandang?.status !== 'NON_ACTIVE' && (
<RequirePermission permissions='lti.production.chickins.detail'>
<Link
href={`/production/project-flock/chickin/add/kandang?projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}&projectFlockId=${projectFlock.id}`}
@@ -392,6 +423,7 @@ const ProjectFlockDetail = ({
</Button>
</Link>
</RequirePermission>
)}
<RequirePermission permissions='lti.production.project_flock_kandangs.closing.detail'>
<Link
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
}
>
{selectedKandang?.status === 'NON_ACTIVE' ? (
<>
Unclose <Icon icon='mdi:lock-open-variant' />
</>
) : (
<>
Close <Icon icon='mdi:checkbox-marked-circle-outline' />
</>
)}
</Button>
</Link>
</RequirePermission>
@@ -52,7 +52,7 @@ import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import StatusBadge from '@/components/helper/StatusBadge';
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 {
formType?: 'add' | 'edit' | 'detail';
@@ -38,38 +38,26 @@ import { Color } from '@/types/theme';
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
APPROVED: 'Disetujui',
Disetujui: 'Disetujui',
REJECTED: 'Ditolak',
Ditolak: 'Ditolak',
CREATED: 'Dibuat',
CREATED: 'Pengajuan',
UPDATED: 'Diperbarui',
};
const getStatusText = (status: string): string => {
return statusTextMap[status] || status;
const normalizedStatus = status.toUpperCase();
return statusTextMap[normalizedStatus] || status;
};
const statusBadgeColorMap: Record<string, Color> = {
APPROVED: 'success',
Disetujui: 'success',
approved: 'success',
disetujui: 'success',
REJECTED: 'error',
Ditolak: 'error',
rejected: 'error',
ditolak: 'error',
CREATED: 'neutral',
Dibuat: 'neutral',
created: 'neutral',
dibuat: 'neutral',
UPDATED: 'warning',
Diperbarui: 'warning',
updated: 'warning',
diperbarui: 'warning',
};
const getStatusBadgeColor = (status: string): Color => {
return statusBadgeColorMap[status] || 'neutral';
const normalizedStatus = status.toUpperCase();
return statusBadgeColorMap[normalizedStatus] || 'neutral';
};
const RowOptionsMenu = ({
@@ -852,8 +840,7 @@ const RecordingTable = () => {
const status = approval.action;
const statusColor = getStatusBadgeColor(status);
const statusText = approval.step_name || getStatusText(status);
const statusText = getStatusText(status);
return (
<StatusBadge
@@ -484,6 +484,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
} = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', '', {
location_id: depletionProductsLocationId,
kandang_id: depletionProductsKandangId,
type: 'AYAM',
});
const today = new Date().toISOString().split('T')[0];
@@ -784,18 +785,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isResponseSuccess(depletionProductsData) && selectedKandang) {
const data = depletionProductsData.data as unknown as ProductWarehouse[];
data.forEach((product) => {
const productName = product.product.name;
if (
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 UniformityTableSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityTableSkeleton';
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 Modal from '@/components/Modal';
import SelectInput, {
@@ -7,7 +7,7 @@ import { Icon } from '@iconify/react';
import { toast } from 'react-hot-toast';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
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 DateInput from '@/components/input/DateInput';
@@ -7,7 +7,7 @@ import Button from '@/components/Button';
import Tooltip from '@/components/Tooltip';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
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 Table from '@/components/Table';
import {
@@ -7,7 +7,7 @@ import Button from '@/components/Button';
import Tooltip from '@/components/Tooltip';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
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 Table from '@/components/Table';
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;
@@ -2,6 +2,7 @@ import { ReportExpense } from '@/types/api/report/report-expense';
import { formatCurrency, formatDate } from '@/lib/helper';
import jsPDF from 'jspdf';
import autoTable, { UserOptions } from 'jspdf-autotable';
interface jsPDFWithAutoTable extends jsPDF {
lastAutoTable: {
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 CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
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 [activeTabId, setActiveTabId] = useState<string>('1');
const tabActions = useFinanceTabStore((state) => state.tabActions);
const tabActions = useReportTabStore((state) => state.tabActions);
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 { Icon } from '@iconify/react';
import Card from '@/components/Card';
import Badge from '@/components/Badge';
import StatusBadge from '@/components/helper/StatusBadge';
import { useSelect } from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report';
// import { UserApi } from '@/services/api/user';
import Table from '@/components/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 {
CustomerPaymentReport,
CustomerPaymentSummary,
@@ -20,20 +25,31 @@ import {
import { isResponseSuccess } from '@/lib/api-helper';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
import Modal from '@/components/Modal';
import { useModal } from '@/components/Modal';
import Modal, { useModal } from '@/components/Modal';
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 { 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 { OptionType } from '@/components/table/TableRowSizeSelector';
import { Color } from '@/types/theme';
interface CustomerPaymentTabProps {
tabId: string;
}
interface FilterParams {
customer_ids?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
}
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
@@ -46,31 +62,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== 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 [filterParams, setFilterParams] = useState<FilterParams>({});
const [dateErrorShown, setDateErrorShown] = 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 dataTypeOptions = useMemo(
@@ -81,10 +76,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
[]
);
const [filterByType, setFilterByType] = useState<
(typeof dataTypeOptions)[0] | null
>(null);
const {
options: customerOptions,
setInputValue: setCustomerInputValue,
@@ -92,108 +83,67 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// TODO: Uncomment when BE is ready
// 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);
const handleFilterModalOpen = () => {
filterModal.openModal();
}, [
filterModal,
appliedFilterCustomer,
appliedFilterByType,
appliedFilterStartDate,
appliedFilterEndDate,
]);
formik.validateForm();
};
const handleResetFilters = useCallback(() => {
// ===== FORMIK SETUP =====
const formik = useFormik<CustomerPaymentFilterType>({
initialValues: {
start_date: null,
end_date: null,
customer_ids: null,
filter_by: null,
},
validationSchema: CustomerPaymentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
setFilterParams({
start_date: values.start_date || undefined,
end_date: values.end_date || undefined,
customer_ids: values.customer_ids || undefined,
filter_by: values.filter_by || undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
setCurrentPage(1);
setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
setFilterCustomer([]);
setFilterByType(null);
setFilterStartDate('');
setFilterEndDate('');
setAppliedFilterCustomer([]);
setAppliedFilterByType(null);
setAppliedFilterStartDate('');
setAppliedFilterEndDate('');
setCurrentPage(1);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}, [dateErrorShown]);
},
});
const handleApplyFilters = useCallback(() => {
setAppliedFilterCustomer(filterCustomer);
setAppliedFilterByType(filterByType);
setAppliedFilterStartDate(filterStartDate);
setAppliedFilterEndDate(filterEndDate);
setIsSubmitted(true);
setCurrentPage(1);
filterModal.closeModal();
}, [
filterModal,
filterCustomer,
filterByType,
filterStartDate,
filterEndDate,
]);
const getPaymentStatusBadgeColor = (notes: string): Color => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'primary';
}
if (normalizedValue.includes('belum')) {
return 'warning';
}
return 'neutral';
};
// ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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 endDateObj = new Date(filterEndDate);
const endDateObj = new Date(formik.values.end_date);
if (endDateObj < startDate) {
setHasDateError(true);
@@ -214,16 +164,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
setHasDateError(false);
}
},
[filterEndDate, dateErrorShown]
[formik, dateErrorShown]
);
const handleEndDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setFilterEndDate(value);
formik.setFieldValue('end_date', value || null);
if (value && filterStartDate) {
const startDateObj = new Date(filterStartDate);
if (value && formik.values.start_date) {
const startDateObj = new Date(formik.values.start_date);
const endDate = new Date(value);
if (endDate < startDateObj) {
@@ -244,41 +194,46 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
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 =====
const activeFiltersCount = useMemo(() => {
let count = 0;
// Date filter (start_date + end_date = 1 filter)
if (appliedFilterStartDate || appliedFilterEndDate) {
if (filterParams.start_date || filterParams.end_date) {
count += 1;
}
// Customer filter
if (appliedFilterCustomer.length > 0) {
if (filterParams.customer_ids) {
count += 1;
}
// Filter by type filter (hanya dihitung jika ada nilai yang dipilih)
if (appliedFilterByType) {
if (filterParams.filter_by) {
count += 1;
}
// TODO: Uncomment when BE is ready
// // Sales filter
// if (appliedFilterSales.length > 0) {
// count += 1;
// }
return count;
}, [
appliedFilterStartDate,
appliedFilterEndDate,
appliedFilterCustomer,
appliedFilterByType,
]);
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
@@ -287,21 +242,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
isSubmitted
? () => {
const params = {
customer_ids:
appliedFilterCustomer.length > 0
? 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
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: appliedFilterStartDate || undefined,
end_date: appliedFilterEndDate || undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
page: currentPage,
limit: pageSize,
};
@@ -333,21 +280,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
CustomerPaymentReport[] | null
> => {
const params = {
customer_ids:
appliedFilterCustomer.length > 0
? 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
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: appliedFilterStartDate || undefined,
end_date: appliedFilterEndDate || undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
limit: 100,
page: 1,
};
@@ -364,13 +303,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[])
: null;
}, [
appliedFilterCustomer,
// appliedFilterSales,
appliedFilterStartDate,
appliedFilterEndDate,
appliedFilterByType,
]);
}, [filterParams]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
@@ -410,21 +343,22 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
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({
data: allDataForExport,
params: {
customer_name:
appliedFilterCustomer.length > 0
? appliedFilterCustomer.map((c) => c.label).join(', ')
: undefined,
// 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
customer_name: customerName,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
@@ -436,11 +370,11 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} finally {
setIsPdfExportLoading(false);
}
}, [customerPaymentExport]);
}, [customerPaymentExport, filterParams, customerOptions]);
// ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
const setTabActions = useReportTabStore((state) => state.setTabActions);
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
useEffect(() => {
setTabActions(
@@ -451,13 +385,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
color='none'
onClick={handleFilterModalOpen}
className={cn(
'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',
hasFilters && 'border-primary-gradient text-primary'
'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={18} height={18} />
<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'>
@@ -467,42 +401,55 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
</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={cn(
'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'
)}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
<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>
}
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'>
<MenuItem
className='text-sm p-3'
title='Excel'
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
/>
<MenuItem
className='text-sm p-3'
title='PDF'
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}
/>
</Menu>
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>
);
@@ -517,7 +464,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
setTabActions,
]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearTabActions(tabId);
@@ -735,17 +681,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
}
return (
<Badge
statusIndicator={true}
size='sm'
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>
<StatusBadge
color={getPaymentStatusBadgeColor(value)}
text={formatTitleCase(value)}
/>
);
},
},
@@ -931,6 +870,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<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'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
@@ -939,7 +879,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={filterStartDate}
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
@@ -948,10 +888,11 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<DateInput
name='end_date'
value={filterEndDate}
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
@@ -960,9 +901,14 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={filterCustomer}
value={customerIdsValue}
onChange={(val) => {
setFilterCustomer(Array.isArray(val) ? val : val ? [val] : []);
formik.setFieldValue(
'customer_ids',
Array.isArray(val) && val.length > 0
? val.map((v: OptionType) => String(v.value)).join(',')
: null
);
}}
onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers}
@@ -971,55 +917,39 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
className={{ wrapper: 'w-full' }}
/>
{/* TODO: Uncomment when BE is ready */}
{/* <div>
<SelectInputCheckbox
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}
value={filterByValue}
onChange={(val) => {
if (val && !Array.isArray(val)) {
setFilterByType(val);
if (!Array.isArray(val)) {
formik.setFieldValue('filter_by', val?.value || null);
}
}}
className={{ wrapper: 'w-full' }}
isClearable={true}
/>
{/* Action Buttons */}
</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'
onClick={handleResetFilters}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
onClick={handleApplyFilters}
disabled={hasDateError}
disabled={hasDateError || !formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
@@ -3,8 +3,6 @@ import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown';
import DateInput from '@/components/input/DateInput';
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 Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
@@ -33,7 +31,7 @@ import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
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 DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
@@ -271,13 +269,13 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
}, [debtSupplierExport]);
// ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
const setTabActions = useReportTabStore((state) => state.setTabActions);
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3 '>
<div className='flex flex-row gap-3'>
<ButtonFilter
values={formik.values}
onClick={handleFilterModalOpen}
@@ -286,42 +284,55 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
/>
<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={cn(
'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'
)}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
<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>
}
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'>
<MenuItem
className='text-sm p-3'
title='Excel'
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
/>
<MenuItem
className='text-sm p-3'
title='PDF'
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}
/>
</Menu>
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>
);
@@ -1,14 +1,19 @@
'use client';
import { useState } from 'react';
import Tabs from '@/components/Tabs';
import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab';
import { useReportTabStore } from '@/stores/report/report-tab.store';
const LogisticStockTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const tabActions = useReportTabStore((state) => state.tabActions);
const tabs = [
{
id: '1',
label: 'Rekapitulasi Pembelian Per Supplier',
content: <PurchasesPerSupplierTab />,
content: <PurchasesPerSupplierTab tabId='1' />,
},
// {
// id: '2',
@@ -23,8 +28,20 @@ const LogisticStockTabs = () => {
];
return (
<section className='w-full p-4'>
<Tabs tabs={tabs} variant='lifted' />
<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>
);
};
@@ -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;
@@ -66,7 +66,7 @@ const getBwTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'woa',
@@ -114,7 +114,7 @@ const getDepTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'dep_kum',
@@ -141,7 +141,7 @@ const getButiranTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'butiran_utuh',
@@ -196,7 +196,7 @@ const getKgTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'kg_utuh',
@@ -251,7 +251,7 @@ const getPersenTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'persen_utuh',
@@ -292,7 +292,7 @@ const getProduksi1TableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'hd',
@@ -361,7 +361,7 @@ const getProduksi2TableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
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;
@@ -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;
@@ -4,12 +4,10 @@ import { 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 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 { ProductionResult } from '@/types/api/report/production-result';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -52,8 +50,6 @@ const ProductionResultProjectFlockKandangTable = ({
}
);
const [open, setOpen] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const productionResultColumns: ColumnDef<ProductionResult>[] = [
@@ -270,61 +266,26 @@ const ProductionResultProjectFlockKandangTable = ({
}
}, [sorting]);
useEffect(() => {
if (!open) {
setOpen(
return (
<Card
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)
? productionResults.data.length > 0
: false
);
}
}, [productionResults, 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'>{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!'
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 Record'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div> */}
<Table<ProductionResult>
data={
isResponseSuccess(productionResults)
? productionResults?.data
: []
isResponseSuccess(productionResults) ? productionResults?.data : []
}
columns={productionResultColumns}
pageSize={tableFilterState.pageSize}
@@ -346,17 +307,19 @@ const ProductionResultProjectFlockKandangTable = ({
setSorting={setSorting}
renderFooter={false}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(productionResults) &&
productionResults?.data?.length === 0,
}),
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 border-x border-base-content/10 text-base-content/50',
'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',
}}
/>
</div>
</Collapse>
</Card>
);
};
+21
View File
@@ -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,
}),
});
@@ -2,7 +2,7 @@
import { create } from 'zustand';
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';
export type ProjectFlockStore = ProjectFlockSlice;

Some files were not shown because too many files have changed in this diff Show More