Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/marketing

This commit is contained in:
randy-ar
2026-01-20 10:44:22 +07:00
124 changed files with 6524 additions and 3120 deletions
-2
View File
@@ -24,8 +24,6 @@ const FinanceDetailPage = () => {
);
}
console.log(finance);
// if (!finance || isResponseError(finance)) {
// router.replace('/404');
// return;
+5 -3
View File
@@ -1,5 +1,6 @@
import Button, { ButtonProps } from '@/components/Button';
import { getFilledFormikValuesCount } from '@/lib/formik-helper';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
import { FormikValues } from 'formik';
@@ -13,11 +14,12 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
<Button
{...props}
onClick={onClick}
className={
className={cn(
getFilledFormikValuesCount(values) > 0
? 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: ''
}
: '',
props.className
)}
>
<Icon
icon='heroicons:funnel'
+1 -1
View File
@@ -18,7 +18,7 @@ const AlertErrorList = ({
if (formErrorList.length === 0) return null;
return (
<Alert color='error' className='w-full flex flex-col gap-2 px-4 m-4'>
<Alert color='error' className='w-full flex flex-col gap-2 px-4'>
<div className='flex justify-between items-center gap-2 w-full'>
<div className='flex items-center gap-2'>
<Icon icon='material-symbols:error-outline' width={24} height={24} />
+18 -2
View File
@@ -113,7 +113,15 @@ const DateInput = ({
};
const handleSelectSingle = (selectedDate?: Date) => {
if (!selectedDate) return;
if (!selectedDate) {
setSelected(undefined);
setDisplayValue('');
const syntheticEvent = {
target: { name, value: '' },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
return;
}
if (minDate && selectedDate < minDate) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`);
return;
@@ -136,7 +144,15 @@ const DateInput = ({
};
const handleSelectRange = (range?: { from?: Date; to?: Date }) => {
if (!range) return;
if (!range) {
setSelectedRange({});
setDisplayValue('');
const syntheticEvent = {
target: { name, value: { from: '', to: '' } },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
return;
}
setSelectedRange(range);
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
+2 -2
View File
@@ -325,7 +325,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
};
const useSelect = <T,>(
basePath: string,
basePath: string | null,
valueKey: keyof T | string,
labelKey: keyof T | string,
searchKey: string = 'search',
@@ -354,7 +354,7 @@ const useSelect = <T,>(
[limitKey]: String(limit),
}).toString();
return `${basePath}?${qs}`;
return basePath ? `${basePath}?${qs}` : null;
};
const {
@@ -3,224 +3,82 @@ 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 {
DataSummarySubTotal,
HppPurchaseData,
ProfitLossDataAmount,
} from '@/types/api/closing';
import { HppItem, ProfitLossItem } from '@/types/api/closing';
import { useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import useSWR from 'swr';
type HppTableRow =
| (HppPurchaseData & {
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
budgeting?: never;
realization?: never;
}
| {
type: string;
group_name: string;
group_index: number;
isGroupHeader: false;
budgeting?: { rp_per_bird: number; rp_per_kg: number; amount: number };
realization?: { rp_per_bird: number; rp_per_kg: number; amount: number };
};
type ProfitLossTableRow =
| (DataSummarySubTotal & {
type: string;
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
rp_per_bird?: never;
rp_per_kg?: never;
amount?: never;
};
const ClosingFinanceTable = ({
projectFlockId,
}: {
projectFlockId: number;
}) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}`,
() => ClosingApi.getFinance(projectFlockId)
`/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
() =>
ClosingApi.getFinance(
projectFlockId,
kandangId ? Number(kandangId) : undefined
)
);
const staticHppRows: Array<{
group_name: string;
type: string;
group_index: number;
}> = [
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian PAKAN',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian STARTER',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian DOC',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian PULLET',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian LAYER',
group_index: 0,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Pengeluaran Overhead',
group_index: 1,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Beban Ekspedisi',
group_index: 1,
},
];
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 hppTableData: HppTableRow[] = [
{
group_name: 'HPP dan Pengeluaran',
group_index: 0,
isGroupHeader: true as const,
},
...staticHppRows
.filter((row) => row.group_index === 0)
.map((staticRow) => {
const apiData = isResponseSuccess(finance)
? finance.data.hpp_purchases.hpp
.find((g) => g.group_name === staticRow.group_name)
?.data.find((d) => d.type === staticRow.type)
: null;
return {
group_name: staticRow.group_name,
group_index: staticRow.group_index,
type: staticRow.type,
budgeting: apiData?.budgeting || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
realization: apiData?.realization || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
isGroupHeader: false as const,
};
}),
{
group_name: 'HPP dan Bahan Baku',
group_index: 1,
isGroupHeader: true as const,
},
...staticHppRows
.filter((row) => row.group_index === 1)
.map((staticRow) => {
const apiData = isResponseSuccess(finance)
? finance.data.hpp_purchases.hpp
.find((g) => g.group_name === staticRow.group_name)
?.data.find((d) => d.type === staticRow.type)
: null;
return {
group_name: staticRow.group_name,
group_index: staticRow.group_index,
type: staticRow.type,
budgeting: apiData?.budgeting || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
realization: apiData?.realization || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
isGroupHeader: false as const,
};
}),
{
group_name: 'HPP',
group_index: 2,
isGroupHeader: true as const,
},
];
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
? [
// Pembelian group
...finance.data.profit_loss.data.pembelian.map((item) => ({
label: 'Pembelian',
group_name: 'Pembelian',
group_index: 1,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.gross_profit.label,
group_name: 'Penjualan',
group_index: 0,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.gross_profit.label,
rp_per_bird:
finance.data.profit_loss.data.summary.gross_profit.rp_per_bird,
rp_per_kg:
finance.data.profit_loss.data.summary.gross_profit.rp_per_kg,
amount: finance.data.profit_loss.data.summary.gross_profit.amount,
},
// Penjualan group
...finance.data.profit_loss.data.penjualan.map((item) => ({
label: 'Penjualan',
group_name: 'Penjualan',
group_index: 0,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.sub_total.label,
group_name: 'Pembelian',
group_index: 1,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.sub_total.label,
rp_per_bird:
finance.data.profit_loss.data.summary.sub_total.rp_per_bird,
rp_per_kg: finance.data.profit_loss.data.summary.sub_total.rp_per_kg,
amount: finance.data.profit_loss.data.summary.sub_total.amount,
},
]
: [];
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'>
@@ -233,35 +91,21 @@ const ClosingFinanceTable = ({
>
<div className='grid grid-cols-2 gap-6'>
<div className='flex flex-col gap-1'>
<div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.gross_profit
.label || '-'
)
: 'Laba Rugi Brutto'}
</div>
<div>Laba Rugi Brutto</div>
<div className='text-lg font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.gross_profit.amount
finance.data.profit_loss.summary.gross_profit.amount
)
: '-'}
</div>
</div>
<div className='flex flex-col gap-1'>
<div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit.label ||
'-'
)
: 'Laba Rugi Netto'}
</div>
<div>Laba Rugi Netto</div>
<div className='text-lg font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit.amount
finance.data.profit_loss.summary.net_profit.amount
)
: '-'}
</div>
@@ -269,11 +113,7 @@ const ClosingFinanceTable = ({
</div>
</Card>
<Card
title={
isResponseSuccess(finance)
? finance.data.hpp_purchases.title
: 'HPP Purchases'
}
title='HPP Purchases'
variant='bordered'
collapsible
className={{
@@ -281,17 +121,18 @@ const ClosingFinanceTable = ({
}}
>
<div className='mt-6 p-0 mb-0'>
<Table<HppTableRow>
<Table<HppItem>
data={hppTableData}
isLoading={isLoading}
columns={[
{
header: 'No.',
enableSorting: false,
accessorFn: (item, index) => {
if (item.isGroupHeader) return '-';
if (item.code === 'custom_row') return '-';
const dataRowsBefore = hppTableData
.slice(0, index)
.filter((row) => !row.isGroupHeader).length;
.filter((row) => row.code !== 'custom_row').length;
return dataRowsBefore + 1;
},
footer: (props) => {
@@ -301,7 +142,7 @@ const ClosingFinanceTable = ({
{
header: 'Jenis',
enableSorting: false,
accessorFn: (item) => formatTitleCase(item.type || '-'),
accessorFn: (item) => formatTitleCase(item.label || '-'),
},
{
header: 'Budgeting',
@@ -317,7 +158,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_bird' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting
finance.data.hpp.summary?.budgeting
?.rp_per_bird || 0
)
: '-';
@@ -333,8 +174,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_kg' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting
?.rp_per_kg || 0
finance.data.hpp.summary?.budgeting?.rp_per_kg ||
0
)
: '-';
},
@@ -349,8 +190,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_amount' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting
?.amount || 0
finance.data.hpp.summary?.budgeting?.amount || 0
)
: '-';
},
@@ -371,8 +211,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_bird' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp
?.realization?.rp_per_bird || 0
finance.data.hpp.summary?.realization
?.rp_per_bird || 0
)
: '-';
},
@@ -387,8 +227,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_kg' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp
?.realization?.rp_per_kg || 0
finance.data.hpp.summary?.realization
?.rp_per_kg || 0
)
: '-';
},
@@ -403,8 +243,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_amount' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp
?.realization?.amount || 0
finance.data.hpp.summary?.realization?.amount || 0
)
: '-';
},
@@ -414,7 +253,7 @@ const ClosingFinanceTable = ({
]}
renderCustomRow={(row) => {
const rowData = row.original;
if (rowData.isGroupHeader) {
if (rowData.code === 'custom_row') {
return (
<tr
key={row.id}
@@ -428,7 +267,7 @@ const ClosingFinanceTable = ({
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')}
{formatTitleCase(rowData.label ?? '-')}
</div>
</td>
</tr>
@@ -441,11 +280,7 @@ const ClosingFinanceTable = ({
</div>
</Card>
<Card
title={
isResponseSuccess(finance)
? finance.data.profit_loss.title
: 'Profit/Loss'
}
title='Profit/Loss'
variant='bordered'
collapsible
className={{
@@ -453,38 +288,32 @@ const ClosingFinanceTable = ({
}}
>
<div className='mt-6 p-0 mb-0'>
<Table<ProfitLossTableRow>
<Table<ProfitLossItem>
data={profitLossTableData}
isLoading={isLoading}
columns={[
{
header: 'Jenis',
enableSorting: false,
accessorFn: (item) => item.type,
accessorFn: (item) => item.label,
cell: (item) => (
<div className=''>
{formatTitleCase(item.row.original.type || '-')}
{formatTitleCase(item.row.original.label || '-')}
</div>
),
footer: (item) => (
<div className='font-bold uppercase'>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit
.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: (item) => (
footer: () => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit
finance.data.profit_loss.summary.net_profit
.rp_per_bird || 0
)
: formatCurrency(0)}
@@ -495,11 +324,11 @@ const ClosingFinanceTable = ({
header: 'Rp/Kg',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
footer: (item) => (
footer: () => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit
finance.data.profit_loss.summary.net_profit
.rp_per_kg || 0
)
: formatCurrency(0)}
@@ -510,11 +339,11 @@ const ClosingFinanceTable = ({
header: 'Jumlah (Rp)',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.amount || 0),
footer: (item) => (
footer: () => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit
finance.data.profit_loss.summary.net_profit
.amount || 0
)
: formatCurrency(0)}
@@ -524,55 +353,30 @@ const ClosingFinanceTable = ({
]}
renderCustomRow={(row) => {
const rowData = row.original;
if (rowData.isGroupHeader) {
if (rowData.amount) {
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>
);
}
if (rowData.code === 'custom_row') {
return (
<tr
key={row.id}
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
className={TABLE_DEFAULT_STYLING.footerRowClassName}
>
<td
colSpan={4}
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<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'>
{formatTitleCase(rowData.group_name ?? '-')}
{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>
@@ -0,0 +1,174 @@
'use client';
import { ChangeEventHandler, 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 { 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';
interface ClosingIncomingSapronaksSummaryTableProps {
projectFlockId: number;
}
const ClosingIncomingSapronaksSummaryTable = ({
projectFlockId,
}: ClosingIncomingSapronaksSummaryTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const {
data: incomingSapronakSummaries,
isLoading: isLoadingIncomingSapronakSummaries,
} = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakSummaryFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronakSummary>[] =
[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'total_qty',
header: 'Total Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries.data.length > 0
: false
);
}
}, [incomingSapronakSummaries, 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'>Ringkasan 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'>
<Table<ClosingIncomingSapronakSummary>
data={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.data
: []
}
columns={incomingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.meta?.page
: 0
}
totalItems={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingIncomingSapronakSummaries}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronakSummaries) &&
incomingSapronakSummaries?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingIncomingSapronaksSummaryTable;
@@ -1,6 +1,7 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingIncomingSapronaksTableProps {
const ClosingIncomingSapronaksTable = ({
projectFlockId,
}: ClosingIncomingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
@@ -43,7 +47,7 @@ const ClosingIncomingSapronaksTable = ({
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`,
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakFetcher,
{
keepPreviousData: true,
@@ -0,0 +1,174 @@
'use client';
import { ChangeEventHandler, 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 { 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';
interface ClosingOutgoingSapronaksSummaryTableProps {
projectFlockId: number;
}
const ClosingOutgoingSapronaksSummaryTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksSummaryTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const {
data: outgoingSapronakSummaries,
isLoading: isLoadingOutgoingSapronakSummaries,
} = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakSummaryFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronakSummary>[] =
[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'total_qty',
header: 'Total Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries.data.length > 0
: false
);
}
}, [outgoingSapronakSummaries, 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'>Ringkasan 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'>
<Table<ClosingOutgoingSapronakSummary>
data={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.data
: []
}
columns={outgoingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.meta?.page
: 0
}
totalItems={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingOutgoingSapronakSummaries}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronakSummaries) &&
outgoingSapronakSummaries?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingOutgoingSapronaksSummaryTable;
@@ -1,6 +1,7 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingOutgoingSapronaksTableProps {
const ClosingOutgoingSapronaksTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
@@ -43,7 +47,7 @@ const ClosingOutgoingSapronaksTable = ({
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`,
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllOutgoingSapronakFetcher,
{
keepPreviousData: true,
@@ -32,101 +32,160 @@ const ClosingOverheadTable = ({
);
// Helper function to create columns with footer support
const createColumns = (total?: OverheadTotal): ColumnDef<Overhead>[] => [
// Group untuk kolom tanpa footer
{
header: 'Nama Item',
accessorFn: (props) => props.item_name,
footer: 'Total Pengeluaran Overhead',
},
{
header: 'Satuan',
accessorFn: (props) => props.uom_name,
},
{
header: 'Budget Pengajuan',
footer: '',
columns: [
{
id: 'budget_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
footer: total ? () => formatNumber(total.budget_quantity) : '',
},
{
id: 'budget_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.budget_unit_price
? formatCurrency(props.budget_unit_price)
: '-',
footer: '',
},
{
id: 'budget_total_amount',
header: 'Total',
accessorFn: (props) =>
props.budget_total_amount
? formatCurrency(props.budget_total_amount)
: '-',
footer: total ? () => formatCurrency(total.budget_total_amount) : '',
},
],
},
{
header: 'Realisasi',
footer: '',
columns: [
{
id: 'actual_date',
header: 'Tanggal',
accessorFn: (props) =>
props.actual_date
? formatDate(props.actual_date, 'DD MMM, YYYY')
: '-',
footer: '',
},
{
id: 'actual_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
footer: total ? () => formatNumber(total.actual_quantity) : '',
},
{
id: 'actual_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.actual_unit_price
? formatCurrency(props.actual_unit_price)
: '-',
footer: '',
},
{
id: 'actual_total_amount',
header: 'Total',
accessorFn: (props) =>
props.actual_total_amount
? formatCurrency(props.actual_total_amount)
: '-',
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
},
],
},
{
id: 'cost_per_bird',
header: 'Rp/Ekor',
accessorFn: (props) =>
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-',
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
},
];
const createColumns = (
total?: OverheadTotal,
kandangId?: number
): ColumnDef<Overhead>[] => {
const flockColumn: ColumnDef<Overhead>[] = [
{
header: 'Budget Pengajuan',
footer: '',
columns: [
{
id: 'budget_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
footer: total ? () => formatNumber(total.budget_quantity) : '',
},
{
id: 'budget_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.budget_unit_price
? formatCurrency(props.budget_unit_price)
: '-',
footer: '',
},
{
id: 'budget_total_amount',
header: 'Total',
accessorFn: (props) =>
props.budget_total_amount
? formatCurrency(props.budget_total_amount)
: '-',
footer: total
? () => formatCurrency(total.budget_total_amount)
: '',
},
],
},
{
header: 'Realisasi',
footer: '',
columns: [
{
id: 'actual_date',
header: 'Tanggal',
accessorFn: (props) =>
props.actual_date
? formatDate(props.actual_date, 'DD MMM, YYYY')
: '-',
footer: '',
},
{
id: 'actual_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
footer: total ? () => formatNumber(total.actual_quantity) : '',
},
{
id: 'actual_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.actual_unit_price
? formatCurrency(props.actual_unit_price)
: '-',
footer: '',
},
{
id: 'actual_total_amount',
header: 'Total',
accessorFn: (props) =>
props.actual_total_amount
? formatCurrency(props.actual_total_amount)
: '-',
footer: total
? () => formatCurrency(total.actual_total_amount)
: '',
},
],
},
];
const kandangColumn: ColumnDef<Overhead>[] = [
{
id: 'actual_date',
header: 'Tanggal',
accessorFn: (props) =>
props.actual_date
? formatDate(props.actual_date, 'DD MMM, YYYY')
: '-',
footer: '',
},
{
id: 'actual_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
footer: total ? () => formatNumber(total.actual_quantity) : '',
},
{
id: 'actual_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.actual_unit_price
? formatCurrency(props.actual_unit_price)
: '-',
footer: '',
},
{
id: 'actual_total_amount',
header: 'Total',
accessorFn: (props) =>
props.actual_total_amount
? formatCurrency(props.actual_total_amount)
: '-',
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
},
];
const finalColumns: ColumnDef<Overhead>[] = [
// Group untuk kolom tanpa footer
{
header: 'No',
accessorFn: (_, index) => index,
cell: (props) => props.row.index + 1,
},
{
header: 'Nama Item',
accessorFn: (props) => props.item_name,
footer: 'Total Pengeluaran Overhead',
},
{
header: 'Satuan',
accessorFn: (props) => props.uom_name,
},
...(kandangId ? kandangColumn : flockColumn),
{
id: 'cost_per_bird',
header: 'Rp/Ekor',
accessorFn: (props) =>
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-',
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
},
];
return finalColumns;
};
const columns = useMemo(
() =>
isResponseSuccess(overhead)
? createColumns(overhead.data?.total)
? createColumns(
overhead.data?.total,
kandangId ? Number(kandangId) : undefined
)
: createColumns(),
[overhead]
);
@@ -1,5 +1,6 @@
'use client';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper';
@@ -12,9 +13,12 @@ interface ClosingProductionDataTabContentProps {
const ClosingProductionDataTabContent = ({
projectFlockId,
}: ClosingProductionDataTabContentProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: productionData, isLoading } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/production-data`,
() => ClosingApi.getProductionData(projectFlockId)
`${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`,
() => ClosingApi.getProductionData(projectFlockId, Number(kandangId))
);
if (isLoading) {
@@ -2,6 +2,8 @@
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;
@@ -16,7 +18,15 @@ const ClosingSapronakTabContent = ({
<>
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
<ClosingIncomingSapronaksSummaryTable
projectFlockId={projectFlockId}
/>
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
<ClosingOutgoingSapronaksSummaryTable
projectFlockId={projectFlockId}
/>
</>
)}
</div>
@@ -163,6 +163,7 @@ const ClosingsTable = () => {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
@@ -228,6 +229,7 @@ const ClosingsTable = () => {
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
@@ -82,12 +82,12 @@ const SalesReportTable = ({
<div className='font-semibold text-gray-900'>Total Penjualan</div>
),
},
{
id: 'age',
accessorKey: 'age',
header: 'Umur',
cell: (props) => props.getValue() || '-',
},
// {
// id: 'age',
// accessorKey: 'age',
// header: 'Umur',
// cell: (props) => props.getValue() || '-',
// },
{
id: 'do_number',
accessorKey: 'do_number',
@@ -8,19 +8,22 @@ import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik';
import { ProjectFlockApi } from '@/services/api/production';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
import {
DashboardFilterType,
getDashboardFilterSchema,
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton';
import DashboardAllCharts, {
DashboardAllChartsRef,
} from '@/components/pages/dashboard/chart/DashboardAllCharts';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import {
DashboardFilter,
@@ -30,6 +33,11 @@ import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import { useDashboardStore } from '@/stores/dashboard';
// Helper function to normalize values to array
const normalizeToArray = (
@@ -44,11 +52,22 @@ const normalizeToArray = (
const DashboardProduction = () => {
const filterModal = useModal();
// ===== DASHBOARD STORE =====
const { filterValues, setFilterValues, resetFilterValues } =
useDashboardStore();
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
'OVERVIEW'
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
);
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]);
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
normalizeToArray(filterValues.location)
);
const [exporting, setExporting] = useState(false);
const statsRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<HTMLDivElement>(null);
const allChartsRef = useRef<DashboardAllChartsRef>(null);
// ===== FETCH DATA =====
const {
@@ -64,22 +83,32 @@ const DashboardProduction = () => {
: undefined;
// ===== SELECT =====
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueFlock,
options: flockOptions,
isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueLocation,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const { options: kandangOptions, isLoadingOptions: isLoadingKandangOptions } =
useSelect(KandangApi.basePath, 'id', 'name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' },
@@ -89,20 +118,21 @@ const DashboardProduction = () => {
// ===== FORMIK =====
const formik = useFormik({
initialValues: {
startDate: '',
endDate: '',
flock: [] as OptionType[],
location: [] as OptionType[],
kandang: [] as OptionType[],
analysisMode: analysisMode,
comparisonType: '',
lokasiIds: [],
flockIds: [],
kandangIds: [],
startDate: filterValues.startDate || '',
endDate: filterValues.endDate || '',
flock: filterValues.flock || ([] as OptionType[]),
location: filterValues.location || ([] as OptionType[]),
kandang: filterValues.kandang || ([] as OptionType[]),
analysisMode: filterValues.analysisMode || analysisMode,
comparisonType: filterValues.comparisonType || '',
locationIds: filterValues.locationIds || [],
flockIds: filterValues.flockIds || [],
kandangIds: filterValues.kandangIds || [],
} as DashboardFilterType,
validationSchema: getDashboardFilterSchema(analysisMode),
onSubmit: (values) => {
console.log(values);
// Save filter values to store
setFilterValues(values);
handleApplyFilter({
start_date: values.startDate || '',
@@ -118,13 +148,13 @@ const DashboardProduction = () => {
const handleResetFilter = () => {
formik.resetForm();
resetFilterValues(); // Clear stored filter values
setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboards');
setSelectedLocationIds([]);
};
const handleApplyFilter = (values: DashboardFilter) => {
console.log(values);
// Build query params object, only include non-empty values
const params: Record<string, string> = {};
@@ -140,15 +170,37 @@ const DashboardProduction = () => {
if (values.comparison_type) params.comparison_type = values.comparison_type;
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
console.log(endpointUrl);
filterModal.closeModal();
refreshDashboardProductionData();
formik.resetForm();
};
// ===== Load filter from store on mount =====
useEffect(() => {
if (!filterValues) return;
handleApplyFilter({
start_date: filterValues.startDate,
end_date: filterValues.endDate,
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType,
});
}, [filterValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// ===== Export PDF =====
const handleExportPDF = async () => {
await generateDashboardPDF({
filterValues: formik.values,
statsRef,
allChartsRef,
setExporting,
});
};
if (isLoadingDashboardProductionData) {
return (
<div className='w-full min-h-screen flex items-center justify-center'>
@@ -156,103 +208,108 @@ const DashboardProduction = () => {
</div>
);
}
return (
<>
<section className='w-full p-4 space-y-6'>
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'>
<div></div>
<div className='flex flex-row justify-end gap-2'>
<Button
<ButtonFilter
values={{
...formik.values,
analysisMode: undefined,
}}
variant='outline'
className={`min-w-28 rounded-lg ${
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: ''
}`}
onClick={() => filterModal.openModal()}
>
<Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'text-blue-600'
: ''
}
/>
Filter
{isResponseSuccess(dashboardProductionResponse) &&
dashboardProductionResponse.meta &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'>
{(() => {
const meta =
dashboardProductionResponse.meta as unknown as DashboardMeta;
if (!meta.filters) return 0;
const count =
(meta.filters.location_ids.length > 1
? meta.filters.location_ids.length
: 0) +
(meta.filters.flock_ids.length > 1
? meta.filters.flock_ids.length
: 0) +
(meta.filters.kandang_ids.length > 1
? meta.filters.kandang_ids.length
: 0);
return meta.filters.analysis_mode === 'OVERVIEW'
? 1
: count;
})()}
</span>
)}
</Button>
<Button
variant='outline'
color='neutral'
className='min-w-28 rounded-lg'
onClick={() => filterModal.openModal()}
/>
<Dropdown
trigger={
<Button variant='outline' className='min-w-28 rounded-lg z-50'>
<Icon icon='heroicons:arrow-down-tray' />
Export
<Icon icon='heroicons:chevron-down' />
</Button>
}
className={{
content: 'w-full',
}}
>
<Icon icon='heroicons:arrow-down-tray' width={20} height={20} />
Export
<Icon icon='heroicons:chevron-down' width={20} height={20} />
</Button>
<Menu className={exporting ? 'hidden' : ''}>
<MenuItem title='PDF' onClick={handleExportPDF} />
</Menu>
</Dropdown>
</div>
</div>
{/* Dashboard Stats */}
<DashboardStats data={dashboardProductionData?.statistics_data ?? []} />
<div ref={statsRef}>
<DashboardStats
data={dashboardProductionData?.statistics_data ?? []}
/>
</div>
{/* Use DashboardLineChart component or skeleton */}
{isLoadingDashboardProductionData ? (
<DashboardLineChartSkeleton />
) : dashboardProductionData &&
dashboardProductionData.charts &&
Object.keys(dashboardProductionData.charts).length > 0 ? (
<DashboardLineChart
analysisMode={
isResponseSuccess(dashboardProductionResponse)
? dashboardProductionResponse.meta
? (
dashboardProductionResponse.meta as unknown as DashboardMeta
).filters?.analysis_mode
<div ref={chartRef}>
{isLoadingDashboardProductionData ? (
<DashboardLineChartSkeleton />
) : dashboardProductionData &&
dashboardProductionData.charts &&
Object.keys(dashboardProductionData.charts).length > 0 ? (
<DashboardLineChart
analysisMode={
isResponseSuccess(dashboardProductionResponse)
? dashboardProductionResponse.meta
? (
dashboardProductionResponse.meta as unknown as DashboardMeta
).filters?.analysis_mode
: analysisMode
: analysisMode
: analysisMode
}
data={dashboardProductionData}
/>
) : (
<DashboardLineChartSkeleton
meta={
isResponseSuccess(dashboardProductionResponse)
? (dashboardProductionResponse.meta as unknown as DashboardMeta)
: undefined
}
/>
}
data={dashboardProductionData}
selectedKandang={
analysisMode === 'OVERVIEW'
? (formik.values.kandang as OptionType)
: undefined
}
/>
) : (
<DashboardLineChartSkeleton
meta={
isResponseSuccess(dashboardProductionResponse)
? (dashboardProductionResponse.meta as unknown as DashboardMeta)
: undefined
}
/>
)}
</div>
{/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */}
{dashboardProductionData && (
<div
style={{
position: 'absolute',
left: '-9999px',
top: 0,
width: '1200px', // Fixed width for consistent PDF rendering
}}
>
<DashboardAllCharts
ref={allChartsRef}
data={dashboardProductionData}
analysisMode={
isResponseSuccess(dashboardProductionResponse)
? dashboardProductionResponse.meta
? (
dashboardProductionResponse.meta as unknown as DashboardMeta
).filters?.analysis_mode
: analysisMode
: analysisMode
}
/>
</div>
)}
</section>
@@ -287,7 +344,7 @@ const DashboardProduction = () => {
{/* Rentang Waktu */}
<div className='px-4'>
<label className='flex items-center gap-2 mb-3'>Tanggal</label>
<div className='flex items-center gap-2'>
<div className='flex items-start gap-2'>
<DateInput
name='startDate'
placeholder='Tanggal Mulai'
@@ -302,7 +359,7 @@ const DashboardProduction = () => {
Boolean(formik.touched.startDate)
}
/>
<span className='hidden md:block text-center'></span>
<div className='hidden md:block mt-3 text-center'></div>
<DateInput
name='endDate'
placeholder='Tanggal Akhir'
@@ -383,6 +440,8 @@ const DashboardProduction = () => {
<SelectInput
label='Farm'
value={formik.values.location}
onInputChange={setInputValueLocation}
onMenuScrollToBottom={loadMoreLocation}
onChange={(selected) => {
formik.setFieldValue('location', selected);
// Update selectedLocationIds for kandang filter
@@ -422,6 +481,8 @@ const DashboardProduction = () => {
formik.setFieldValue('flock', selected)
}
errorMessage={formik.errors.flock as string}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
options={flockOptions}
isLoading={isLoadingFlockOptions}
isMulti={
@@ -450,6 +511,8 @@ const DashboardProduction = () => {
formik.setFieldValue('kandang', selected)
}
errorMessage={formik.errors.kandang as string}
onInputChange={setInputValueKandang}
onMenuScrollToBottom={loadMoreKandang}
options={kandangOptions}
isLoading={isLoadingKandangOptions}
isMulti={
@@ -465,7 +528,9 @@ const DashboardProduction = () => {
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='w-full p-4'>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
</div>
{/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
@@ -473,7 +538,6 @@ const DashboardProduction = () => {
type='reset'
variant='soft'
className='ms-4 min-w-36 rounded-lg'
onClick={handleResetFilter}
>
Reset Filter
</Button>
@@ -0,0 +1,343 @@
import Card from '@/components/Card';
import {
Dashboard,
DashboardOverviewCharts,
DashboardComparisonCharts,
DashboardChartsSeries,
DashboardChartsDataset,
} from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
type DashboardAllChartsProps = {
data: Dashboard;
analysisMode: string;
};
export type DashboardAllChartsRef = {
getChartRefs: () => {
key: string;
ref: HTMLDivElement | null;
label: string;
}[];
};
// Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardOverviewCharts {
if (!charts) return false;
return (
'deplesi' in charts ||
'body_weight' in charts ||
'fcr' in charts ||
'performance' in charts ||
'quality_control' in charts
);
}
// Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardComparisonCharts {
if (!charts) return false;
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
}
const lineColors: Record<string, string> = {
body_weight: '#10B981',
std_body_weight: '#10B981',
act_laying: '#1062B9',
std_laying: '#1062B9',
act_egg_weight: '#10B981',
std_egg_weight: '#10B981',
act_feed_intake: '#F52419',
std_feed_intake: '#F52419',
act_uniformity: '#F59E0B',
std_uniformity: '#F59E0B',
act_fcr: '#10B981',
std_fcr: '#10B981',
act_fcr_cum: '#F52419',
std_fcr_cum: '#10B981',
normal: '#10B981',
abnormal: '#F52419',
act_deplesi: '#10B981',
std_deplesi: '#10B981',
};
const defaultLineColors: string[] = [
'#10B981',
'#1062B9',
'#F52419',
'#F59E0B',
'#7F56D9',
];
// Helper function to get line color
const getLineColor = (seriesId: string | number, index: number): string => {
const predefinedColor = lineColors[seriesId];
if (predefinedColor) {
return predefinedColor;
}
return defaultLineColors[index % defaultLineColors.length];
};
// Mapping for chart type labels
const chartTypeLabels: Record<keyof DashboardOverviewCharts, string> = {
body_weight: 'Body Weight',
performance: 'Performance',
fcr: 'FCR',
quality_control: 'Quality Control',
deplesi: 'Deplesi',
};
const DashboardAllCharts = forwardRef<
DashboardAllChartsRef,
DashboardAllChartsProps
>(({ data, analysisMode }, ref) => {
// Create refs for charts - use string keys for flexibility
const chartRefs = useRef<{
[key: string]: HTMLDivElement | null;
}>({});
// Determine chart keys and labels based on analysis mode
const getChartConfig = () => {
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
const overviewKeys: (keyof DashboardOverviewCharts)[] = [
'body_weight',
'performance',
'fcr',
'quality_control',
'deplesi',
];
return overviewKeys.map((key) => ({
key,
label: chartTypeLabels[key],
chartData: (data.charts as DashboardOverviewCharts)[key],
}));
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
// For comparison mode, find which comparison type has data
const comparisonKey = data.charts.farm
? 'farm'
: data.charts.flock
? 'flock'
: 'kandang';
const comparisonLabels: Record<string, string> = {
farm: 'Farm Comparison',
flock: 'Flock Comparison',
kandang: 'Kandang Comparison',
};
return [
{
key: comparisonKey,
label: comparisonLabels[comparisonKey],
chartData: data.charts[comparisonKey],
},
];
}
return [];
};
const chartConfig = getChartConfig();
// Expose method to get all chart refs
useImperativeHandle(ref, () => ({
getChartRefs: () => {
return chartConfig
.map(({ key, label }) => ({
key,
ref: chartRefs.current[key] || null,
label,
}))
.filter((item) => item.ref !== null);
},
}));
return (
<div className='space-y-6'>
{chartConfig.map(({ key, label, chartData }) => {
if (
!chartData ||
!chartData.dataset ||
chartData.dataset.length === 0
) {
return null;
}
const seriesData: DashboardChartsSeries[] = chartData.series || [];
const dataset: DashboardChartsDataset[] = chartData.dataset || [];
return (
<div
key={key}
ref={(el: HTMLDivElement | null) => {
chartRefs.current[key] = el;
}}
>
<Card
className={{
wrapper: 'w-full rounded-lg',
}}
variant='bordered'
>
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'>
<div className='text-lg font-semibold'>
{label}{' '}
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='inline text-neutral-500'
/>
</div>
</div>
{/* Legend */}
<div className='flex flex-wrap gap-3 mb-6'>
{seriesData.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
return (
<div
key={series.id}
className='flex items-center gap-2 px-3 py-2 rounded-lg border border-neutral-400 bg-neutral-50'
>
<div
className={`w-6 h-0.5 ${
isStandard ? 'border-t-2 border-dashed' : ''
}`}
style={{
backgroundColor: isStandard
? 'transparent'
: getLineColor(series.id, index),
borderColor: isStandard
? getLineColor(series.id, index)
: 'transparent',
}}
></div>
<span className='text-sm text-neutral-900 font-medium'>
{series.label}
</span>
<Icon
icon='heroicons:information-circle'
width={16}
height={16}
className='text-neutral-400'
/>
</div>
);
})}
</div>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={dataset}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
seriesData.forEach((series) => {
const value = item[series.id];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(
Math.max(0, minValue - padding)
);
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
/>
{seriesData.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(series.id, index),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</Card>
</div>
);
})}
</div>
);
});
DashboardAllCharts.displayName = 'DashboardAllCharts';
export default DashboardAllCharts;
@@ -1,8 +1,10 @@
import Button from '@/components/Button';
import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import { formatNumber } from '@/lib/helper';
import {
Dashboard,
DashboardOverviewCharts,
@@ -25,20 +27,29 @@ import {
type DashboardLineChartProps = {
analysisMode: 'OVERVIEW' | 'COMPARISON';
data: Dashboard;
selectedKandang?: OptionType;
};
// Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardOverviewCharts {
return 'deplesi' in charts;
if (!charts) return false;
return (
'deplesi' in charts ||
'body_weight' in charts ||
'fcr' in charts ||
'performance' in charts ||
'quality_control' in charts
);
}
// Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardComparisonCharts {
return 'location' in charts || 'flock' in charts || 'kandang' in charts;
if (!charts) return false;
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
}
const lineColors: Record<string, string> = {
@@ -94,6 +105,7 @@ const getLineColor = (
const DashboardLineChart = ({
analysisMode,
data,
selectedKandang,
}: DashboardLineChartProps) => {
const [chartData, setChartData] =
useState<keyof DashboardOverviewCharts>('body_weight');
@@ -123,7 +135,7 @@ const DashboardLineChart = ({
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang;
data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
@@ -224,7 +236,7 @@ const DashboardLineChart = ({
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang;
data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
@@ -283,261 +295,382 @@ const DashboardLineChart = ({
})()}
</div>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={(() => {
// Transform data based on analysisMode
if (analysisMode === 'OVERVIEW') {
// For OVERVIEW mode, use the selected chart data
if (isOverviewCharts(data.charts)) {
const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset) return [];
return selectedChartData.dataset;
{/* Chart Container with Empty State Overlay */}
<div className='relative'>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={(() => {
// Transform data based on analysisMode
if (analysisMode === 'OVERVIEW') {
// For OVERVIEW mode, use the selected chart data
if (isOverviewCharts(data.charts)) {
const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset)
return [];
return selectedChartData.dataset;
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
})()}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
formatter={(
value: number | undefined,
name: string | undefined
) => {
if (value === undefined || name === undefined) return ['', ''];
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const unit = series?.unit || '';
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
return [`${value} ${unit}`, name];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
content={(props) => {
return (
<div className='flex flex-col gap-2 rounded-lg bg-neutral-950 p-4 text-white'>
<p className='text-neutral-300 text-xs font-semibold text-start'>
{analysisMode === 'OVERVIEW'
? selectedKandang
? selectedKandang.label || 'Overview Performance'
: 'Overview Performance'
: 'Comparison Performance'}
</p>
<ul className='flex flex-col gap-1'>
{props.payload.map((item, index) => {
if (item.name.startsWith('STD. ')) return null;
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find(
(s) => s.label === item.name
);
const color = series?.id
? getLineColor(series.id, index, analysisMode)
: '#9ca3af';
const unit = series?.unit;
return (
<li
key={item.name}
className='flex w-full justify-between items-center flex-row gap-6 p-0'
>
<span className='flex flex-row gap-1 items-center'>
<div
className='h-4 w-4 m-0 rounded-md'
style={{
backgroundColor: color,
}}
></div>
<div className='m-0'>
{formatNumber(item.value)}
{unit}
</div>
</span>
<span className='m-0'>{item.name}</span>
</li>
);
})}
</ul>
<p className='text-neutral-300 text-xs text-start'>
Week {props.label}
</p>
</div>
);
});
})()}
</LineChart>
</ResponsiveContainer>
}}
formatter={(
value: number | undefined,
name: string | undefined
) => {
if (
value === undefined ||
name === undefined ||
name.startsWith('STD. ')
)
return [undefined, undefined];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const id = series?.id || '';
return [value, id];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
});
})()}
</LineChart>
</ResponsiveContainer>
{/* Empty State Overlay */}
{(() => {
// Get current dataset
let dataset: DashboardChartsDataset[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
dataset = comparisonChart?.dataset || [];
}
// Show empty state if dataset is empty
if (dataset.length === 0) {
return (
<div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'>
{/* Chart icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'>
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={24}
height={24}
/>
</div>
{/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'>
Data Not Yet Available
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please change your filters to get the data.
</p>
</div>
);
}
return null;
})()}
</div>
</Card>
);
};
@@ -0,0 +1,262 @@
import jsPDF from 'jspdf';
import { toPng } from 'html-to-image';
import toast from 'react-hot-toast';
import { formatDate } from '@/lib/helper';
import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import { DashboardAllChartsRef } from '@/components/pages/dashboard/chart/DashboardAllCharts';
interface DashboardPDFExportParams {
filterValues: DashboardFilterType;
statsRef: React.RefObject<HTMLDivElement | null>;
allChartsRef: React.RefObject<DashboardAllChartsRef | null>;
setExporting: (value: boolean) => void;
}
export const generateDashboardPDF = async ({
filterValues,
statsRef,
allChartsRef,
setExporting,
}: DashboardPDFExportParams): Promise<void> => {
try {
setExporting(true);
toast.loading('Generating PDF...', { id: 'export-pdf' });
// Wait for DOM to update
await new Promise((resolve) => setTimeout(resolve, 200));
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 10;
let yPosition = margin;
// Add title
pdf.setFontSize(16);
pdf.setFont('helvetica', 'bold');
pdf.text('Dashboard Produksi', margin, yPosition);
yPosition += 10;
// Add filter information (horizontal layout)
pdf.setFontSize(6);
pdf.setFont('helvetica', 'normal');
const filterItems: string[] = [];
// Period
if (filterValues.startDate || filterValues.endDate) {
const periodText = `Periode: ${
filterValues.startDate
? formatDate(filterValues.startDate, 'DD MMM YYYY')
: '-'
} s.d ${
filterValues.endDate
? formatDate(filterValues.endDate, 'DD MMM YYYY')
: '-'
}`;
filterItems.push(periodText);
}
// Analysis Mode
const analysisModeText = `Analysis Mode: ${
filterValues.analysisMode === 'OVERVIEW'
? 'Performance Overview'
: 'Performance Comparison'
}`;
filterItems.push(analysisModeText);
// Comparison Type (only for COMPARISON mode)
if (
filterValues.analysisMode === 'COMPARISON' &&
filterValues.comparisonType
) {
const comparisonTypeLabel =
filterValues.comparisonType === 'FARM'
? 'Farm'
: filterValues.comparisonType === 'FLOCK'
? 'Flock'
: filterValues.comparisonType === 'KANDANG'
? 'Kandang'
: filterValues.comparisonType;
filterItems.push(`Compared By: ${comparisonTypeLabel}`);
}
// Farm
if (filterValues.location) {
const locationText = Array.isArray(filterValues.location)
? filterValues.location.map((loc) => loc.label).join(', ')
: filterValues.location.label;
filterItems.push(`Farm: ${locationText || '-'}`);
}
// Flock
if (
filterValues.flock &&
(Array.isArray(filterValues.flock)
? filterValues.flock.length > 0
: filterValues.flock)
) {
const flockText = Array.isArray(filterValues.flock)
? filterValues.flock.map((f) => f.label).join(', ')
: filterValues.flock.label;
filterItems.push(`Flock: ${flockText || '-'}`);
}
// Kandang
if (
filterValues.kandang &&
(Array.isArray(filterValues.kandang)
? filterValues.kandang.length > 0
: filterValues.kandang)
) {
const kandangText = Array.isArray(filterValues.kandang)
? filterValues.kandang.map((k) => k.label).join(', ')
: filterValues.kandang.label;
filterItems.push(`Kandang: ${kandangText || '-'}`);
}
// Generated timestamp
filterItems.push(`Dicetak: ${formatDate(new Date(), 'DD MMM YYYY HH:mm')}`);
// Render filter items horizontally with word wrap and gray background
const maxWidth = pageWidth - 2 * margin;
let currentLine = '';
const lines: string[] = [];
// First pass: calculate all lines
filterItems.forEach((item, index) => {
const separator = index > 0 ? ' | ' : '';
const testLine = currentLine + separator + item;
const testWidth = pdf.getTextWidth(testLine);
if (testWidth > maxWidth && currentLine !== '') {
lines.push(currentLine);
currentLine = item;
} else {
currentLine = testLine;
}
});
// Add last line
if (currentLine) {
lines.push(currentLine);
}
// Calculate background dimensions
const lineHeight = 3;
const padding = 1;
const backgroundHeight = lines.length * lineHeight + padding * 2;
// Draw gray background
pdf.setFillColor(240, 240, 240); // Light gray (RGB: 240, 240, 240)
pdf.rect(
margin - padding,
yPosition - padding - 2,
pageWidth - 2 * margin + padding * 2,
backgroundHeight,
'F'
);
// Render text on top of background
lines.forEach((line, index) => {
pdf.text(line, margin, yPosition);
if (index < lines.length - 1) {
yPosition += lineHeight;
}
});
yPosition += 10;
// Capture and add stats if available
if (statsRef.current) {
const statsImage = await toPng(statsRef.current, {
quality: 1,
pixelRatio: 2,
});
const statsImgProps = pdf.getImageProperties(statsImage);
const statsWidth = pageWidth - 2 * margin;
const statsHeight =
(statsImgProps.height * statsWidth) / statsImgProps.width;
// Check if we need a new page
if (yPosition + statsHeight > pageHeight - margin) {
pdf.addPage();
yPosition = margin;
}
pdf.addImage(
statsImage,
'PNG',
margin,
yPosition,
statsWidth,
statsHeight
);
yPosition += statsHeight + 10;
}
if (allChartsRef.current) {
// Get all individual chart refs
const chartRefs = allChartsRef.current.getChartRefs();
// Capture each chart separately and add to PDF
for (let i = 0; i < chartRefs.length; i++) {
const { ref: chartElement, label } = chartRefs[i];
if (chartElement) {
// Add chart title
pdf.setFontSize(12);
pdf.setFont('helvetica', 'bold');
const chartImage = await toPng(chartElement, {
quality: 1,
pixelRatio: 2,
});
const chartImgProps = pdf.getImageProperties(chartImage);
const chartWidth = pageWidth - 2 * margin;
const chartHeight =
(chartImgProps.height * chartWidth) / chartImgProps.width;
// Calculate total height needed (title + spacing + chart)
const titleHeight = 10;
const totalHeight = titleHeight + chartHeight;
// Check if chart fits on current page
if (yPosition + totalHeight > pageHeight - margin) {
pdf.addPage();
yPosition = margin;
}
// Add title
pdf.text(label, margin, yPosition);
yPosition += titleHeight;
// Add chart image
pdf.addImage(
chartImage,
'PNG',
margin,
yPosition,
chartWidth,
chartHeight
);
// Update yPosition for next chart (add spacing between charts)
yPosition += chartHeight + 10;
}
}
}
// Save the PDF
const fileName = `dashboard-production-${new Date().toISOString().split('T')[0]}.pdf`;
pdf.save(fileName);
toast.success('PDF exported successfully!', { id: 'export-pdf' });
} catch (error) {
toast.error('Failed to export PDF. Please try again.', {
id: 'export-pdf',
});
} finally {
setExporting(false);
}
};
@@ -7,7 +7,7 @@ export type DashboardFilterType = {
analysisMode: string;
comparisonType: string | undefined;
location: OptionType | OptionType[];
lokasiIds: number[] | undefined;
locationIds: number[] | undefined;
flock: OptionType | OptionType[] | undefined;
flockIds: number[] | undefined;
kandang: OptionType | OptionType[] | undefined;
@@ -25,7 +25,7 @@ export const DashboardFilterOverviewSchema: yup.ObjectSchema<DashboardFilterType
then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(),
}),
lokasiIds: yup.array().optional(),
locationIds: yup.array().optional(),
flockIds: yup.array().optional(),
kandangIds: yup.array().optional(),
location: yup
@@ -68,7 +68,7 @@ export const DashboardFilterComparisonSchema: yup.ObjectSchema<DashboardFilterTy
then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(),
}),
lokasiIds: yup.array().optional(),
locationIds: yup.array().optional(),
flockIds: yup.array().optional(),
kandangIds: yup.array().optional(),
location: yup
@@ -43,7 +43,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
return (
<>
<section className='w-full max-w-7xl pb-16'>
<section className='w-full max-w-full pb-16'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
@@ -65,7 +65,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
tabs={expenseDetailTabs}
variant='lifted'
className={{
wrapper: 'max-w-5xl mx-auto mt-4',
wrapper: 'mx-auto mt-4',
}}
/>
</section>
@@ -68,7 +68,7 @@ const ExpenseRealizationContent = ({
return (
<div>
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
<RequirePermission permissions='lti.expense.update.realization'>
<Button
@@ -84,7 +84,7 @@ const ExpenseRealizationContent = ({
</div>
</div>
<div className='overflow-x-auto w-full max-w-5xl mx-auto'>
<div className='overflow-x-auto w-full mx-auto'>
<table className='table table-sm table-zebra'>
<tbody>
<tr>
@@ -179,7 +179,7 @@ const ExpenseRealizationContent = ({
</table>
</div>
<div className='w-full max-w-5xl mt-8 mx-auto'>
<div className='w-full mt-8 mx-auto'>
<div className='flex flex-row gap-4'>
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
<div className='w-full flex flex-col gap-2'>
@@ -216,127 +216,141 @@ const ExpenseRealizationContent = ({
</div>
</div>
<div className='w-full max-w-5xl mt-8 mx-auto'>
<h2 className='font-bold text-xl text-center'>
Rincian Pengajuan Biaya Operasional
</h2>
<div className='w-full mt-8 mx-auto grid grid-cols-2 gap-4'>
<div>
<h2 className='font-bold text-xl text-center'>
Rincian Pengajuan Biaya Operasional
</h2>
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map(
(kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price)
);
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price)
);
return (
<div
key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto'
>
<table className='table table-sm table-zebra'>
<thead>
<tr>
<th
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.name}
</th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.pengajuans?.map(
(pengajuanItem, pengajuanIdx) => (
<tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
return (
<div
key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto'
>
<table className='table table-sm table-zebra'>
<thead>
<tr>
<th
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.name}
</th>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th>
</tr>
</tfoot>
</table>
</div>
);
})}
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.pengajuans?.map(
(pengajuanItem, pengajuanIdx) => (
<tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'>
{pengajuanItem.notes ?? '-'}
</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>
{formatCurrency(expenseGrandTotal)}
</th>
</tr>
</tfoot>
</table>
</div>
);
}
)}
</div>
</div>
</div>
<div className='w-full max-w-5xl mt-8 mx-auto'>
<h2 className='font-bold text-xl text-center'>
Rincian Realisasi Biaya Operasional
</h2>
<div>
<h2 className='font-bold text-xl text-center'>
Rincian Realisasi Biaya Operasional
</h2>
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map(
(kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price)
);
kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price)
);
return (
<div
key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto'
>
<table className='table table-sm table-zebra'>
<thead>
<tr>
<th
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.name}
</th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.realisasi?.map(
(realisasiItem, realisasiIdx) => (
<tr key={realisasiIdx}>
<td>{realisasiItem.nonstock.name}</td>
<td>{realisasiItem.qty}</td>
<td>{formatCurrency(realisasiItem.price)}</td>
<td className='w-xs'>{realisasiItem.note ?? '-'}</td>
return (
<div
key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto'
>
<table className='table table-sm table-zebra'>
<thead>
<tr>
<th
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.name}
</th>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th>
</tr>
</tfoot>
</table>
</div>
);
})}
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.realisasi?.map(
(realisasiItem, realisasiIdx) => (
<tr key={realisasiIdx}>
<td>{realisasiItem.nonstock.name}</td>
<td>{realisasiItem.qty}</td>
<td>{formatCurrency(realisasiItem.price)}</td>
<td className='w-xs'>
{realisasiItem.notes ?? '-'}
</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>
{formatCurrency(expenseGrandTotal)}
</th>
</tr>
</tfoot>
</table>
</div>
);
}
)}
</div>
</div>
</div>
</div>
@@ -273,7 +273,7 @@ const ExpenseRequestContent = ({
<>
<div>
{initialValues && !isLoadingApprovalHistory && approvalHistory && (
<div className='w-full max-w-5xl my-4 mx-auto'>
<div className='w-full my-4 mx-auto'>
<ApprovalSteps approvals={approvalHistory} />
</div>
)}
@@ -281,7 +281,7 @@ const ExpenseRequestContent = ({
<div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */}
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnHeadArea && (
<RequirePermission permissions='lti.expense.approve.head_area'>
<Button
@@ -414,7 +414,7 @@ const ExpenseRequestContent = ({
</div>
</div>
<div className='overflow-x-auto w-full max-w-5xl mx-auto'>
<div className='overflow-x-auto w-full mx-auto'>
<table className='table table-sm table-zebra'>
<tbody>
<tr>
@@ -608,7 +608,7 @@ const ExpenseRequestContent = ({
</table>
</div>
</div>
<div className='w-full max-w-5xl mt-8 mx-auto'>
<div className='w-full mt-8 mx-auto'>
<h2 className='font-bold text-xl text-center'>
Rincian Pengajuan Biaya Operasional
</h2>
@@ -654,7 +654,7 @@ const ExpenseRequestContent = ({
<td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'>
{pengajuanItem.note ?? '-'}
{pengajuanItem.notes ?? '-'}
</td>
</tr>
)
+20 -28
View File
@@ -54,17 +54,19 @@ const RowOptionsMenu = ({
rejectClickHandler: () => void;
deleteClickHandler: () => void;
}) => {
const showEditButton =
props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3 ||
props.row.original.latest_approval.step_number === 4);
const showEditButton = props.row.original.latest_approval
? props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3 ||
props.row.original.latest_approval.step_number === 4)
: false;
// TODO: apply RBAC
const showRealizationButton =
props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4;
const showRealizationButton = props.row.original.latest_approval
? props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4
: false;
return (
<RowOptionsMenuWrapper type={type}>
@@ -278,6 +280,7 @@ const ExpensesTable = () => {
cell: ({ row }) => {
const isCheckboxDisabled =
!row.getCanSelect() ||
!row.original.latest_approval ||
row.original.latest_approval.action === 'REJECTED';
return (
@@ -413,6 +416,8 @@ const ExpensesTable = () => {
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row
) => {
if (!row.original.latest_approval) return false;
return (
row.original.latest_approval.action !== 'REJECTED' &&
row.original.latest_approval.step_number !== 6
@@ -692,14 +697,6 @@ const ExpensesTable = () => {
</>
)}
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='grid grid-cols-12 justify-end gap-2'>
@@ -753,17 +750,12 @@ const ExpensesTable = () => {
}}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'col-span-12 max-w-28 justify-self-end',
}}
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
/>
</div>
</div>
@@ -19,6 +19,7 @@ import { isResponseSuccess } from '@/lib/api-helper';
interface ExpenseKandangsTableProps {
locationId?: number;
type: 'add' | 'edit' | 'detail';
formType?: 'request' | 'realization';
selectedKandangs: {
id?: number;
name?: string;
@@ -31,6 +32,7 @@ interface ExpenseKandangsTableProps {
const ExpenseKandangsTable = ({
type,
formType = 'request',
locationId,
selectedKandangs,
onChange,
@@ -172,69 +174,84 @@ const ExpenseKandangsTable = ({
updateSortingFilter('picSort', picSortFilter);
}, [sorting, updateSortingFilter]);
return (
<Card
className={{
wrapper: className?.wrapper,
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'>Pilih Kandang</div>
// Tampilkan tabel jika:
// 1. Mode request pertama kali (type='add' dan formType='request')
// 2. Atau sudah ada kandang yang dipilih
const shouldShowTable =
(type === 'add' && formType === 'request') ||
(selectedKandangs.length > 0 && selectedKandangs.some((k) => k.id));
<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!'
>
<Table<Kandang>
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
totalItems={
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
return (
<>
{shouldShowTable && (
<Card
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 first:flex first:flex-row first:justify-start',
paginationClassName: cn({
hidden:
isResponseSuccess(kandangs) &&
kandangs?.meta?.total_pages === 1,
}),
wrapper: className?.wrapper,
body: 'p-4 shadow',
}}
/>
</Collapse>
</Card>
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>
{formType === 'realization'
? 'Kandang yang Direalisasikan'
: 'Pilih Kandang'}
</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!'
>
<Table<Kandang>
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
totalItems={
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 first:flex first:flex-row first:justify-start',
paginationClassName: cn({
hidden:
isResponseSuccess(kandangs) &&
kandangs?.meta?.total_pages === 1,
}),
}}
/>
</Collapse>
</Card>
)}
</>
);
};
@@ -130,7 +130,7 @@ export const getExpenseRealizationFormInitialValues = (
? formatDate(initialValues?.realization_date, 'YYYY-MM-DD')
: undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.kandang_id,
id: kandang.id,
name: kandang.name,
})),
supplier: initialValues?.supplier
@@ -159,7 +159,7 @@ export const getExpenseRealizationFormInitialValues = (
},
quantity: realisasiItem.qty,
price: realisasiItem.price,
notes: realisasiItem.note,
notes: realisasiItem.notes,
};
})
: kandangExpense.pengajuans
@@ -170,7 +170,7 @@ export const getExpenseRealizationFormInitialValues = (
},
quantity: expenseItem.qty,
price: expenseItem.price,
notes: expenseItem.note,
notes: expenseItem.notes,
}))
: [];
@@ -249,7 +249,7 @@ const ExpenseRealizationForm = ({
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
return (
<section className='w-full max-w-5xl'>
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
@@ -297,6 +297,7 @@ const ExpenseRealizationForm = ({
<ExpenseKandangsTable
type='detail'
formType='realization'
locationId={formik.values.location?.value}
selectedKandangs={formik.values.kandangs ?? []}
onChange={kandangsChangeHandler}
@@ -41,22 +41,25 @@ type ExpenseFormSchemaType = {
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
Yup.object({
category: Yup.object({
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
value: Yup.string()
.oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
label: Yup.string()
.oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
})
.nullable()
.optional(),
.required('Kategori wajib diisi!')
.typeError('Kategori wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
}).nullable(),
location_id: Yup.number()
.required('Lokasi wajib diisi!')
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'),
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
@@ -73,9 +76,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
}).nullable(),
supplier_id: Yup.number()
.required('Vendor wajib diisi!')
@@ -104,9 +105,12 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
.of(
Yup.object({
nonstock: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
value: Yup.number().min(1).required('Nonstock wajib diisi!'),
label: Yup.string().required('Nonstock wajib diisi!'),
})
.nullable()
.required('Nonstock wajib diisi!')
.typeError('Nonstock wajib diisi!'),
nonstock_id: Yup.number()
.required('Nonstock wajib diisi!')
.min(1, 'Nonstock wajib diisi!')
@@ -204,7 +208,7 @@ export const getExpenseFormInitialValues = (
nonstock_id: expenseItem.nonstock.id,
quantity: expenseItem.qty,
price: expenseItem.price,
notes: expenseItem.note,
notes: expenseItem.notes,
}))
: [],
}))
@@ -190,30 +190,18 @@ const ExpenseRequestForm = ({
formik.setFieldValue('category', val);
};
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('location', true);
formik.setFieldValue('location', val);
const locationChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
const locationId = location ? Number(location.value) : 0;
const locationId = Array.isArray(val) ? val[0]?.value : val?.value;
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('kandangs', []);
// Auto-create expense item for location (without kandang)
formik.setFieldValue('expense_nonstocks', [
{
cost_items: [
{
nonstock: null,
nonstock_id: 0,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
};
formik.setFieldTouched('location', true);
formik.setFieldValue('location', location);
formik.setFieldTouched('location_id', true);
formik.setFieldValue('location_id', locationId);
},
[]
);
const kandangsChangeHandler = (
kandangs: { id?: number; name?: string }[]
@@ -268,6 +256,7 @@ const ExpenseRequestForm = ({
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('supplier', true);
formik.setFieldTouched('supplier_id', true);
formik.setFieldValue('supplier', val);
const supplierId = Array.isArray(val) ? val[0]?.value : val?.value;
@@ -360,7 +349,7 @@ const ExpenseRequestForm = ({
return (
<>
<section className='w-full max-w-5xl'>
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
@@ -407,6 +396,16 @@ const ExpenseRequestForm = ({
placeholder='Pilih Kategori'
value={formik.values.category}
onChange={categoryChangeHandler}
isError={
formik.touched.category && Boolean(formik.errors.category)
}
errorMessage={
formik.touched.category && formik.errors.category
? typeof formik.errors.category === 'object'
? 'Kategori wajib diisi!'
: (formik.errors.category as string)
: undefined
}
options={[
{
value: 'BOP',
@@ -427,8 +426,13 @@ const ExpenseRequestForm = ({
value={formik.values.location}
onChange={locationChangeHandler}
options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
errorMessage={formik.errors.location_id as string}
isClearable
className={{ wrapper: 'col-span-12 sm:col-span-4' }}
/>
@@ -438,6 +442,12 @@ const ExpenseRequestForm = ({
required
value={formik.values.transaction_date}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
Boolean(formik.errors.transaction_date)
}
errorMessage={formik.errors.transaction_date as string}
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
@@ -460,8 +470,12 @@ const ExpenseRequestForm = ({
value={formik.values.supplier}
onChange={supplierChangeHandler}
options={supplierOptions}
isLoading={isLoadingVendorOptions}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
isError={
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
}
errorMessage={formik.errors.supplier_id as string}
className={{ wrapper: 'col-span-12' }}
/>
@@ -55,6 +55,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
true
);
formik.setFieldTouched(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock_id`,
true
);
formik.setFieldValue(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
val
@@ -96,7 +100,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
};
const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'price' | 'notes',
column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number,
expenseIdx: number
) => {
@@ -105,11 +109,14 @@ const ExpenseRequestKandangDetailExpense: React.FC<
expenseIdx
]?.[column] &&
Boolean(
formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof
Object &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx] &&
typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx] ===
'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx
] instanceof Object &&
] &&
typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx]
.cost_items?.[expenseIdx] === 'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx
]?.[column]
@@ -117,6 +124,32 @@ const ExpenseRequestKandangDetailExpense: React.FC<
);
};
const getExpenseRepeaterErrorMessage = (
column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number,
expenseIdx: number
): string => {
const kandangError = formik.errors.expense_nonstocks?.[kandangExpenseIdx];
if (!kandangError || typeof kandangError !== 'object') return '';
if (!('cost_items' in kandangError)) return '';
const costItemsError = kandangError.cost_items?.[expenseIdx];
if (!costItemsError || typeof costItemsError !== 'object') return '';
const fieldError = costItemsError[column as keyof typeof costItemsError];
if (!fieldError) return '';
if (typeof fieldError === 'object' && fieldError !== null) {
return 'Nonstock wajib diisi!';
}
return String(fieldError);
};
return (
<Card
className={{
@@ -202,10 +235,21 @@ const ExpenseRequestKandangDetailExpense: React.FC<
val
);
}}
isError={isExpenseRepeaterInputError(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }}
isClearable={true}
/>
</td>
@@ -226,6 +270,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
@@ -246,6 +295,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'price',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={
<span className='text-gray-600 font-medium'>
Rp
@@ -271,6 +325,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
@@ -447,7 +447,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.note}
{pengajuan.notes}
</Text>
</View>
</View>
@@ -607,7 +607,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.note}
{realisasi.notes}
</Text>
</View>
</View>
+19 -22
View File
@@ -34,7 +34,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
},
{
label: 'Pihak',
value: finance.party.id ? finance.party.name : '-',
value: finance.party?.id ? finance.party?.name : '-',
},
{
label: 'Tanggal',
@@ -56,25 +56,21 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
},
{
label: 'Nomor Rekening',
value: `${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`,
value: `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`,
},
{
label: `Rekening ${formatTitleCase(finance.party.type)}`,
value: finance.party.account_number,
label: `Rekening ${formatTitleCase(finance.party?.type)}`,
value: finance.party?.account_number,
},
{
label: 'Nominal',
value: formatCurrency(finance.expense_amount),
},
{
label: 'Sisa',
value: formatCurrency(finance.income_amount),
value: formatCurrency(finance.nominal),
},
].filter((item) => {
// Hide party account number row if transaction type is INJECTION
if (
FINANCE_INJECTION_STATUS.includes(finance.transaction_type) &&
item.label === `Rekening ${formatTitleCase(finance.party.type)}`
item.label === `Rekening ${formatTitleCase(finance.party?.type)}`
) {
return false;
}
@@ -148,18 +144,19 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
</Card>
<div className='flex flex-row gap-2 justify-end'>
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) && (
<RequirePermission permissions='lti.finance.payments.update'>
<Button
color='warning'
className='min-w-24'
href={`/finance/detail/edit?financeId=${finance.id}`}
>
<Icon icon='mdi:pencil-outline' />
Edit
</Button>
</RequirePermission>
)}
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) &&
finance.party?.type !== 'SUPPLIER' && (
<RequirePermission permissions='lti.finance.payments.update'>
<Button
color='warning'
className='min-w-24'
href={`/finance/detail/edit?financeId=${finance.id}`}
>
<Icon icon='mdi:pencil-outline' />
Edit
</Button>
</RequirePermission>
)}
{FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && (
<RequirePermission permissions='lti.finance.initial_balances.update'>
<Button
+67 -54
View File
@@ -1,21 +1,17 @@
import { ChangeEventHandler, useMemo, useState } from 'react';
import { CellContext, Row } from '@tanstack/react-table';
import { CellContext } from '@tanstack/react-table';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import Button from '@/components/Button';
import Card from '@/components/Card';
import Dropdown from '@/components/dropdown/Dropdown';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Table from '@/components/Table';
import Tooltip from '@/components/Tooltip';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Finance } from '@/types/api/finance/finance';
@@ -23,7 +19,6 @@ import {
FINANCE_INITIAL_BALANCE_STATUS,
FINANCE_INJECTION_STATUS,
FINANCE_TRANSACTION_STATUS,
ROWS_OPTIONS,
} from '@/config/constant';
import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper';
@@ -70,19 +65,24 @@ const RowOptionsMenu = ({
{FINANCE_TRANSACTION_STATUS.includes(
props.row.original.transaction_type
) && (
<RequirePermission permissions='lti.finance.payments.update'>
<Button
href={`/finance/detail/edit?financeId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
)}
) &&
props.row.original.party?.type !== 'SUPPLIER' && (
<RequirePermission permissions='lti.finance.payments.update'>
<Button
href={`/finance/detail/edit?financeId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon
icon='material-symbols:edit-outline'
width={16}
height={16}
/>
Edit
</Button>
</RequirePermission>
)}
{FINANCE_INITIAL_BALANCE_STATUS.includes(
props.row.original.transaction_type
@@ -199,35 +199,37 @@ const FinanceTable = () => {
// ===== Options =====
const transactionTypeOptions = useMemo(() => {
return [
{ label: 'Transfer', value: 'TRANSFER' },
{ label: 'Cash', value: 'CASH' },
{ label: 'Card', value: 'CARD' },
{ label: 'Cheque', value: 'CHEQUE' },
{ label: 'Saldo', value: 'SALDO' },
];
}, []);
const partyTypeOptions = useMemo(() => {
return [
{ label: 'Customer', value: 'CUSTOMER' },
{ label: 'Supplier', value: 'SUPPLIER' },
];
}, []);
const {
options: partyTypeOptions,
isLoadingOptions: partyTypeIsLoadingOptions,
setInputValue: partyTypeInputValue,
loadMore: partyTypeLoadMore,
} = useSelect(
selectedTransactionType
? selectedTransactionType.value === 'CUSTOMER'
? CustomerApi.basePath
: SupplierApi.basePath
: '',
'id',
'name'
);
const sortByOptions = useMemo(() => {
return [
{ label: 'Tanggal Pembayaran', value: 'payment_date' },
{ label: 'Tanggal Dibuat', value: 'created_at' },
];
}, []);
const { options: bankOptions, rawData: bankRawData } = useSelect<Bank>(
BankApi.basePath,
'id',
'alias',
'',
{
limit: 'limit',
}
);
const {
options: bankOptions,
rawData: bankRawData,
setInputValue: bankInputValue,
loadMore: bankLoadMore,
} = useSelect<Bank>(BankApi.basePath, 'id', 'alias');
// ===== Handler =====
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
@@ -344,10 +346,10 @@ const FinanceTable = () => {
},
{
header: 'Pihak',
accessorFn: (finance: Finance) => finance.party.name,
accessorFn: (finance: Finance) => finance.party?.name,
cell: (props: CellContext<Finance, unknown>) => {
if (props.row.original.party.id) {
return <span>{props.row.original.party.name}</span>;
if (props.row.original.party?.id) {
return <span>{props.row.original.party?.name}</span>;
}
return <span>{'-'}</span>;
},
@@ -368,12 +370,12 @@ const FinanceTable = () => {
{
header: 'Bank',
accessorFn: (finance: Finance) =>
`${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`,
`${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`,
},
{
header: 'Pengeluaran (Rp)',
accessorFn: (finance: Finance) =>
formatCurrency(finance.expense_amount),
formatCurrency(Math.abs(finance.expense_amount)),
},
{
header: 'Pemasukan (Rp)',
@@ -476,38 +478,49 @@ const FinanceTable = () => {
<div className='grid grid-cols-4 gap-6'>
<SelectInput
options={transactionTypeOptions}
label='Jenis Transaksi'
label='Tipe Transaksi'
value={selectedTransactionType}
onChange={transactionTypeChangeHandler}
isClearable
/>
<SelectInput
options={partyTypeOptions}
label={
selectedTransactionType
? selectedTransactionType.value === 'CUSTOMER'
? 'Pelanggan'
: 'Supplier'
: 'Pihak'
}
value={selectedPartyType}
onChange={partyTypeChangeHandler}
onInputChange={partyTypeInputValue}
onMenuScrollToBottom={partyTypeLoadMore}
isLoading={partyTypeIsLoadingOptions}
isClearable
/>
<SelectInput
options={
isResponseSuccess(bankRawData)
? bankOptions.map((bank) => ({
label:
bankRawData.data.find((data) => data.id === bank.value)
bankRawData.data.find((data) => data.id === bank?.value)
?.alias +
' - ' +
bankRawData.data.find((data) => data.id === bank.value)
bankRawData.data.find((data) => data.id === bank?.value)
?.account_number +
' - ' +
bankRawData.data.find((data) => data.id === bank.value)
bankRawData.data.find((data) => data.id === bank?.value)
?.owner,
value: bank.value,
value: bank?.value,
}))
: []
}
label='Bank'
value={selectedBank}
onChange={bankChangeHandler}
isClearable
/>
<SelectInput
options={partyTypeOptions}
label='Pihak'
value={selectedPartyType}
onChange={partyTypeChangeHandler}
onInputChange={bankInputValue}
onMenuScrollToBottom={bankLoadMore}
isClearable
/>
<DebouncedTextInput
@@ -32,8 +32,10 @@ import {
import { Bank } from '@/types/api/master-data/bank';
import { useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
import { Icon } from '@iconify/react';
interface FormFinanceAddProps {
type?: 'add' | 'edit';
@@ -51,18 +53,22 @@ const FormFinanceAdd = ({
initialValues,
}: FormFinanceAddProps) => {
const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
const [isSupplier, setIsSupplier] = useState(
initialValues?.party?.type === 'SUPPLIER'
);
// ===== Formik =====
const formikInitialValues = useMemo((): FinanceFormValues => {
return {
party_type_option:
FINANCE_PARTY_TYPE_OPTIONS.find(
(option) => option.value === initialValues?.party.type
(option) => option.value === initialValues?.party?.type
) || null,
party_id_option: initialValues?.party
? {
label: initialValues?.party.name || '',
value: initialValues?.party.id || 0,
label: initialValues?.party?.name || '',
value: initialValues?.party?.id || 0,
}
: null,
payment_date: initialValues?.payment_date || '',
@@ -72,11 +78,11 @@ const FormFinanceAdd = ({
) || null,
bank_id_option: initialValues?.bank
? {
label: initialValues.bank.name,
value: initialValues.bank.id,
label: initialValues?.bank?.name,
value: initialValues?.bank?.id,
}
: null,
party_account_number: initialValues?.party.account_number || '',
party_account_number: initialValues?.party?.account_number || '',
reference_number: initialValues?.reference_number || '',
nominal: initialValues?.nominal.toString() || '',
notes: initialValues?.notes || '',
@@ -113,20 +119,22 @@ const FormFinanceAdd = ({
options: partyOptions,
isLoadingOptions: isLoadingPartyOptions,
rawData: partyRawData,
setInputValue: setPartyInputValue,
loadMore: loadMorePartyOptions,
} = useSelect<PartyCommonProps>(
formik.values.party_type_option?.value === 'CUSTOMER'
? CustomerApi.basePath
: SupplierApi.basePath,
'id',
'name',
'',
{ limit: 'limit' }
'name'
);
const {
options: bankOptions,
rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' });
setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions =====
const transformFormValuesToPayload = (
@@ -151,6 +159,7 @@ const FormFinanceAdd = ({
if (isResponseError(response)) {
toast.error(response.message);
setServerErrorMessage(response.message);
return;
}
@@ -166,6 +175,7 @@ const FormFinanceAdd = ({
if (isResponseError(response)) {
toast.error(response.message);
setServerErrorMessage(response.message);
return;
}
@@ -205,6 +215,7 @@ const FormFinanceAdd = ({
? formik.errors.party_type_option
: ''
}
isDisabled={type === 'edit' || isSupplier}
required
isClearable
/>
@@ -219,6 +230,8 @@ const FormFinanceAdd = ({
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis transaksi dahulu'}`}
options={partyOptions}
value={formik.values.party_id_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => {
formik.setFieldValue('party_id_option', value);
if (isResponseSuccess(partyRawData) && value) {
@@ -241,7 +254,7 @@ const FormFinanceAdd = ({
}
required
isClearable
isDisabled={!formik.values.party_type_option?.value}
isDisabled={!formik.values.party_type_option?.value || isSupplier}
/>
<DateInput
label='Tanggal'
@@ -259,6 +272,7 @@ const FormFinanceAdd = ({
: ''
}
required
disabled={isSupplier}
/>
<SelectInput
label='Metode Pembayaran'
@@ -280,6 +294,7 @@ const FormFinanceAdd = ({
}
required
isClearable
isDisabled={isSupplier}
/>
<SelectInput
label='Bank'
@@ -304,6 +319,8 @@ const FormFinanceAdd = ({
: []
}
value={formik.values.bank_id_option}
onInputChange={setBankInputValue}
onMenuScrollToBottom={loadMoreBankOptions}
onChange={(value) => {
formik.setFieldValue('bank_id_option', value);
}}
@@ -318,6 +335,7 @@ const FormFinanceAdd = ({
}
required
isClearable
isDisabled={isSupplier}
/>
<TextInput
label={`Nomor Rekening ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'Pihak'}`}
@@ -338,6 +356,7 @@ const FormFinanceAdd = ({
}
required
readOnly
disabled={isSupplier}
/>
<TextInput
label='Nomor Referensi'
@@ -357,6 +376,7 @@ const FormFinanceAdd = ({
: ''
}
required
disabled={isSupplier}
/>
<NumberInput
label='Nominal'
@@ -372,6 +392,7 @@ const FormFinanceAdd = ({
: ''
}
required
disabled={isSupplier}
/>
<TextArea
label='Catatan'
@@ -387,8 +408,18 @@ const FormFinanceAdd = ({
: ''
}
required
disabled={isSupplier}
/>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{serverErrorMessage && (
<Alert color='error'>
<Icon icon='mdi:alert' />
{serverErrorMessage}
<Button color='error' onClick={() => setServerErrorMessage('')}>
<Icon icon='mdi:close' />
</Button>
</Alert>
)}
<div className='flex justify-center gap-4'>
<Button
type='reset'
@@ -27,13 +27,7 @@ export const InitialBalanceFormSchema = Yup.object().shape({
'Pihak wajib diisi',
(value) => value !== null && value !== undefined
),
bank_id_option: Yup.mixed()
.nullable()
.test(
'is-valid-option',
'Bank wajib diisi',
(value) => value !== null && value !== undefined
),
bank_id_option: Yup.mixed().nullable(),
reference_number: Yup.string().required('Nomor referensi wajib diisi'),
initial_balance_type_option: Yup.mixed()
.nullable()
@@ -29,8 +29,9 @@ import { Bank } from '@/types/api/master-data/bank';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
interface FormFinanceAddInitialBalanceProps {
type?: 'add' | 'edit';
@@ -42,6 +43,7 @@ const FormFinanceAddInitialBalance = ({
initialValues,
}: FormFinanceAddInitialBalanceProps) => {
const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
// ===== Formik =====
const formikInitialValues = useMemo((): InitialBalanceFormValues => {
@@ -53,18 +55,18 @@ const FormFinanceAddInitialBalance = ({
return {
party_type_option:
FINANCE_PARTY_TYPE_OPTIONS.find(
(option) => option.value === initialValues?.party.type
(option) => option.value === initialValues?.party?.type
) || null,
party_id_option: initialValues?.party
? {
label: initialValues.party.name,
value: initialValues.party.id,
label: initialValues.party?.name,
value: initialValues.party?.id,
}
: null,
bank_id_option: initialValues?.bank
? {
label: initialValues.bank.name,
value: initialValues.bank.id,
label: initialValues.bank?.name,
value: initialValues.bank?.id,
}
: null,
reference_number: initialValues?.reference_number || '',
@@ -104,21 +106,25 @@ const FormFinanceAddInitialBalance = ({
});
// ===== Options =====
const { options: partyOptions, isLoadingOptions: isLoadingPartyOptions } =
useSelect(
formik.values.party_type_option?.value === 'CUSTOMER'
? CustomerApi.basePath
: SupplierApi.basePath,
'id',
'name',
'',
{ limit: 'limit' }
);
const {
options: partyOptions,
isLoadingOptions: isLoadingPartyOptions,
setInputValue: setPartyInputValue,
loadMore: loadMorePartyOptions,
} = useSelect(
formik.values.party_type_option?.value === 'CUSTOMER'
? CustomerApi.basePath
: SupplierApi.basePath,
'id',
'name'
);
const {
options: bankOptions,
rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' });
setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions =====
const transformFormValuesToPayload = (
@@ -143,6 +149,7 @@ const FormFinanceAddInitialBalance = ({
if (isResponseError(response)) {
toast.error(response.message);
setServerErrorMessage(response.message);
return;
}
@@ -162,6 +169,7 @@ const FormFinanceAddInitialBalance = ({
if (isResponseError(response)) {
toast.error(response.message);
setServerErrorMessage(response.message);
return;
}
@@ -189,6 +197,8 @@ const FormFinanceAddInitialBalance = ({
placeholder='Pilih jenis pihak'
options={FINANCE_PARTY_TYPE_OPTIONS}
value={formik.values.party_type_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => {
formik.setFieldValue('party_type_option', value);
formik.setFieldValue('party_id_option', null);
@@ -205,6 +215,7 @@ const FormFinanceAddInitialBalance = ({
: ''
}
required
isDisabled={type === 'edit'}
isClearable
/>
<SelectInput
@@ -218,6 +229,8 @@ const FormFinanceAddInitialBalance = ({
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis pihak dahulu'}`}
options={partyOptions}
value={formik.values.party_id_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => {
formik.setFieldValue('party_id_option', value);
}}
@@ -269,7 +282,6 @@ const FormFinanceAddInitialBalance = ({
? formik.errors.bank_id_option
: ''
}
required
isClearable
/>
<TextInput
@@ -354,7 +366,18 @@ const FormFinanceAddInitialBalance = ({
}
required
/>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{serverErrorMessage && (
<Alert color='error'>
<Icon icon='mdi:alert' />
{serverErrorMessage}
<Button color='error' onClick={() => setServerErrorMessage('')}>
<Icon icon='mdi:close' />
</Button>
</Alert>
)}
<div className='flex justify-center gap-4'>
<Button
type='reset'
@@ -24,8 +24,10 @@ import {
import { Bank } from '@/types/api/master-data/bank';
import { useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
import { Icon } from '@iconify/react';
interface FormFinanceInjectionProps {
type?: 'add' | 'edit';
@@ -37,14 +39,15 @@ const FormFinanceInjection = ({
initialValues,
}: FormFinanceInjectionProps) => {
const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
// ===== Formik =====
const formikInitialValues = useMemo((): InjectionFormValues => {
return {
bank_id_option: initialValues?.bank
? {
label: initialValues.bank.name,
value: initialValues.bank.id,
label: initialValues.bank?.name,
value: initialValues.bank?.id,
}
: null,
adjustment_date: initialValues?.payment_date || '',
@@ -80,7 +83,9 @@ const FormFinanceInjection = ({
options: bankOptions,
rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' });
setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions =====
const transformFormValuesToPayload = (
@@ -101,6 +106,7 @@ const FormFinanceInjection = ({
if (isResponseError(response)) {
toast.error(response.message);
setServerErrorMessage(response.message);
return;
}
@@ -117,6 +123,7 @@ const FormFinanceInjection = ({
if (isResponseError(response)) {
toast.error(response.message);
setServerErrorMessage(response.message);
return;
}
@@ -162,6 +169,8 @@ const FormFinanceInjection = ({
: []
}
value={formik.values.bank_id_option}
onInputChange={setBankInputValue}
onMenuScrollToBottom={loadMoreBankOptions}
onChange={(value) => {
formik.setFieldValue('bank_id_option', value);
}}
@@ -226,6 +235,15 @@ const FormFinanceInjection = ({
required
/>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{serverErrorMessage && (
<Alert color='error'>
<Icon icon='mdi:alert' />
{serverErrorMessage}
<Button color='error' onClick={() => setServerErrorMessage('')}>
<Icon icon='mdi:close' />
</Button>
</Alert>
)}
<div className='flex justify-center gap-4'>
<Button
type='reset'
@@ -1,6 +1,6 @@
'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { isResponseError } from '@/lib/api-helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory';
import {
CreateInventoryAdjustmentPayload,
@@ -22,12 +22,18 @@ import {
} from '@/services/api/master-data';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput';
import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface InventoryAdjustmentFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -44,10 +50,7 @@ const InventoryAdjustmentForm = ({
InventoryAdjustmentFormErrorMessage,
setInventoryAdjustmentFormErrorMessage,
] = useState('');
const [selectedProductCategories, setSelectedProductCategories] =
useState('');
const [disabledProduct, setDisabledProduct] = useState(true);
const [optionsProduct, setOptionsProduct] = useState<OptionType[]>([]);
const [quantityLabel, setQuantityLabel] = useState('Tambah Stok');
// Submit Handler
@@ -108,45 +111,30 @@ const InventoryAdjustmentForm = ({
});
// Fetch Data
const productCategoriesUrl = `${
ProductCategoryApi.basePath
}?${new URLSearchParams({
search: '',
}).toString()}`;
const { data: productCategories, isLoading: isLoadingProductCategories } =
useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher);
const {
setInputValue: setProductCategoryInputValue,
options: productCategoryOptions,
isLoadingOptions: isLoadingProductCategoryOptions,
loadMore: loadMoreProductCategories,
} = useSelect<ProductCategory>(ProductCategoryApi.basePath, 'id', 'name');
const productUrl = `${ProductApi.basePath}?${new URLSearchParams({
search: '',
product_category_id: selectedProductCategories,
}).toString()}`;
const { data: products, isLoading: isLoadingProducts } = useSWR(
productUrl,
ProductApi.getAllFetcher
);
const {
setInputValue: setProductInputValue,
options: productOptions,
isLoadingOptions: isLoadingProductOptions,
loadMore: loadMoreProducts,
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
product_category_id: formik.values.product_category_id
? String(formik.values.product_category_id)
: '',
});
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({
search: '',
limit: '100',
}).toString()}`;
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
warehouseUrl,
WarehouseApi.getAllFetcher
);
// Map Data to Options
const optionsProductCategory = isResponseSuccess(productCategories)
? productCategories?.data.map((productCategory) => ({
value: productCategory.id,
label: productCategory.name,
}))
: [];
const optionsWarehouse = isResponseSuccess(warehouses)
? warehouses?.data.map((warehouse) => ({
value: warehouse.id,
label: warehouse.name,
}))
: [];
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
// Options Handler
const productCategoryChangeHandler = (
@@ -157,7 +145,6 @@ const InventoryAdjustmentForm = ({
formik.setFieldValue('product_category', val);
setSelectedProductCategories((val as OptionType)?.value as string);
const disabled = (val as OptionType)?.value == null;
setDisabledProduct(disabled);
formik.setFieldValue('product_id', 0);
@@ -193,9 +180,6 @@ const InventoryAdjustmentForm = ({
// Effect
useEffect(() => {
if (initialValues?.product_warehouse?.product?.id) {
setSelectedProductCategories(
String(initialValues.product_warehouse.product.id)
);
setDisabledProduct(false);
formik.setFieldValue(
'product_id',
@@ -219,25 +203,10 @@ const InventoryAdjustmentForm = ({
);
formik.setFieldValue('note', initialValues.note);
}
}, [
formik,
initialValues,
setQuantityLabel,
setDisabledProduct,
setSelectedProductCategories,
]);
}, [formik, initialValues, setQuantityLabel, setDisabledProduct]);
useEffect(() => {
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
}, [formikSetValues, formikInitialValues]);
useEffect(() => {
if (isResponseSuccess(products)) {
const options = products.data.map((p) => ({
value: p.id,
label: p.name,
}));
setOptionsProduct(options);
}
}, [products]);
// Utils Function
const formatNumber = (value: string) => {
@@ -282,9 +251,10 @@ const InventoryAdjustmentForm = ({
label='Kategori Produk'
value={formik.values.product_category as OptionType}
onChange={productCategoryChangeHandler}
onInputChange={setSelectedProductCategories}
options={optionsProductCategory}
isLoading={isLoadingProductCategories}
onInputChange={setProductCategoryInputValue}
options={productCategoryOptions}
onMenuScrollToBottom={loadMoreProductCategories}
isLoading={isLoadingProductCategoryOptions}
isError={
formik.touched.product_category &&
Boolean(formik.errors.product_category)
@@ -300,8 +270,10 @@ const InventoryAdjustmentForm = ({
label='Produk'
value={formik.values.product as OptionType}
onChange={productChangeHandler}
options={optionsProduct}
isLoading={isLoadingProducts}
onInputChange={setProductInputValue}
options={productOptions}
onMenuScrollToBottom={loadMoreProducts}
isLoading={isLoadingProductOptions}
isError={formik.touched.product && Boolean(formik.errors.product)}
errorMessage={formik.errors.product as string}
isDisabled={type === 'detail' || disabledProduct}
@@ -314,8 +286,10 @@ const InventoryAdjustmentForm = ({
label='Warehouse'
value={formik.values.warehouse as OptionType}
onChange={warehouseChangeHandler}
options={optionsWarehouse}
isLoading={isLoadingWarehouses}
onInputChange={setWarehouseInputValue}
options={warehouseOptions}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouseOptions}
isError={
formik.touched.warehouse && Boolean(formik.errors.warehouse)
}
@@ -110,6 +110,14 @@ const DeliveryProductObjectSchema = Yup.object({
.typeError('Qty harus berupa angka!'),
});
const DeliveryDocumentSchema = Yup.mixed<File | MovementDocument>()
.nullable()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value): boolean => {
if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true;
});
const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
delivery_cost: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value))
@@ -135,13 +143,7 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
}),
document_path: Yup.string().nullable().optional(),
document_index: Yup.number().optional(),
document: Yup.mixed<File | MovementDocument>()
.nullable()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true;
}),
document: DeliveryDocumentSchema,
driver_name: Yup.string().required('Nama sopir wajib diisi!'),
vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'),
supplier: Yup.object({
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFormik } from 'formik';
import useSWR from 'swr';
@@ -38,6 +38,8 @@ import Card from '@/components/Card';
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
interface MovementFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -49,10 +51,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== STATE MANAGEMENT =====
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [
productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue,
] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]);
@@ -93,10 +91,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== USE SELECT HOOKS =====
const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
loadMore: loadMoreWarehouses,
rawData: warehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search', {
flag: 'EKSPEDISI',
});
// ===== SELECT INPUT DATA =====
const {
@@ -107,12 +108,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
category: 'BOP',
});
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
// ===== DATA PROCESSING =====
const warehouseStockMap = useMemo(() => {
if (!isResponseSuccess(allProductWarehouses)) return new Map();
@@ -268,26 +263,64 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
},
});
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
const getProductWarehousesUrl = useCallback(() => {
const productWarehouseParams = new URLSearchParams({
search: productWarehouseSelectInputValue,
});
if (formik.values.source_warehouse_id) {
productWarehouseParams.append(
'warehouse_id',
formik.values.source_warehouse_id.toString()
);
}
return `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`;
}, [formik.values.source_warehouse_id, productWarehouseSelectInputValue]);
const prevSourceWarehouseIdRef = useRef<number | null>(
formik.values.source_warehouse_id
);
const productWarehousesUrl = getProductWarehousesUrl();
const { data: productWarehouses, isLoading: isLoadingProductWarehouses } =
useSWR(
formik.values.source_warehouse_id ? productWarehousesUrl : null,
ProductWarehouseApi.getAllFetcher
);
// ===== RESET PRODUCTS WHEN SOURCE WAREHOUSE CHANGES =====
useEffect(() => {
const prevSourceWarehouseId = prevSourceWarehouseIdRef.current;
const currentSourceWarehouseId = formik.values.source_warehouse_id;
if (
prevSourceWarehouseId !== currentSourceWarehouseId &&
prevSourceWarehouseId !== null
) {
formik.setFieldValue('products', [
{
product: null,
product_id: 0,
product_qty: '',
},
]);
formik.setFieldTouched('products', false);
const updatedDeliveries = formik.values.deliveries.map(
(delivery: DeliverySchema) => ({
...delivery,
products: [
{
product: null,
product_id: 0,
product_qty: '',
},
],
})
);
formik.setFieldValue('deliveries', updatedDeliveries);
formik.setFieldTouched('deliveries', false);
}
prevSourceWarehouseIdRef.current = currentSourceWarehouseId;
}, [formik.values.source_warehouse_id, formik.values.deliveries]);
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
const {
setInputValue: setProductWarehouseSelectInputValue,
isLoadingOptions: isLoadingProductWarehouses,
loadMore: loadMoreProductWarehouses,
rawData: productWarehouses,
} = useSelect<ProductWarehouse>(
formik.values.source_warehouse_id ? ProductWarehouseApi.basePath : null,
'id',
'name',
'search',
{
warehouse_id: formik.values.source_warehouse_id
? formik.values.source_warehouse_id.toString()
: '',
}
);
const productWarehouseOptions = isResponseSuccess(productWarehouses)
? productWarehouses?.data.map((pw) => ({
@@ -357,13 +390,71 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
};
};
const handleTransferDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('transfer_date', e.target.value);
};
// ===== EVENT HANDLERS =====
// Product Handlers
const addProduct = () => {
const handleTransferDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('transfer_date', e.target.value);
},
[]
);
const handleSourceWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const newSourceWarehouseId = (val as WarehouseOptionType)?.value;
if (
newSourceWarehouseId &&
newSourceWarehouseId === formik.values.destination_warehouse_id
) {
const destinationWarehouseName =
(formik.values.destination_warehouse as WarehouseOptionType)?.label ||
'gudang tujuan';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang asal tidak boleh sama dengan ${destinationWarehouseName}.`
);
return;
}
formik.setFieldTouched('source_warehouse', true);
formik.setFieldValue('source_warehouse', val);
formik.setFieldTouched('source_warehouse_id', true);
formik.setFieldValue('source_warehouse_id', newSourceWarehouseId);
},
[
formik.values.destination_warehouse_id,
formik.values.destination_warehouse,
]
);
const handleDestinationWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const newDestinationWarehouseId = (val as WarehouseOptionType)?.value;
if (
newDestinationWarehouseId &&
newDestinationWarehouseId === formik.values.source_warehouse_id
) {
const sourceWarehouseName =
(formik.values.source_warehouse as WarehouseOptionType)?.label ||
'gudang asal';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang tujuan tidak boleh sama dengan ${sourceWarehouseName}.`
);
return;
}
formik.setFieldTouched('destination_warehouse', true);
formik.setFieldValue('destination_warehouse', val);
formik.setFieldTouched('destination_warehouse_id', true);
formik.setFieldValue(
'destination_warehouse_id',
newDestinationWarehouseId
);
},
[formik.values.source_warehouse_id, formik.values.source_warehouse]
);
const addProduct = useCallback(() => {
const newProducts = [
...(formik.values.products || []),
{
@@ -373,22 +464,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
},
];
formik.setFieldValue('products', newProducts);
};
}, []);
const removeProduct = useCallback(
(i: number) => {
const updatedProducts =
formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
}, []) ?? [];
const removeProduct = useCallback((i: number) => {
const updatedProducts =
formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
}, []) ?? [];
formik.setFieldValue('products', updatedProducts);
},
[formik]
);
formik.setFieldValue('products', updatedProducts);
}, []);
const bulkRemoveProduct = useCallback(() => {
const updatedProducts =
@@ -397,10 +485,45 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
) ?? [];
formik.setFieldValue('products', updatedProducts);
setSelectedProducts([]);
}, [formik, selectedProducts]);
}, [formik, selectedProducts, setSelectedProducts]);
// Delivery Handlers
const addDelivery = () => {
const handleProductChange = useCallback(
(idx: number, val: OptionType | OptionType[] | null) => {
formik.setFieldTouched(`products.${idx}.product`, true);
formik.setFieldValue(`products.${idx}.product`, val);
formik.setFieldTouched(`products.${idx}.product_id`, true);
formik.setFieldValue(
`products.${idx}.product_id`,
(val as ProductWarehouseOptionType)?.value
);
},
[]
);
const handleProductSelectAllChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedProducts(formik.values.products?.map((_, idx) => idx) ?? []);
} else {
setSelectedProducts([]);
}
},
[formik.values.products, setSelectedProducts]
);
const handleProductCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.name.replace('product-', ''));
if (e.target.checked) {
setSelectedProducts((prev) => [...prev, idx]);
} else {
setSelectedProducts((prev) => prev.filter((i) => i !== idx));
}
},
[setSelectedProducts]
);
const addDelivery = useCallback(() => {
formik.setFieldValue('deliveries', [
...(formik.values.deliveries || []),
{
@@ -420,25 +543,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
],
},
]);
};
}, []);
const removeDelivery = useCallback(
(i: number) => {
const updatedDeliveries =
formik.values.deliveries?.reduce(
(acc: DeliverySchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
},
[]
) ?? [];
const removeDelivery = useCallback((i: number) => {
const updatedDeliveries =
formik.values.deliveries?.reduce((acc: DeliverySchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
}, []) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries);
},
[formik]
);
formik.setFieldValue('deliveries', updatedDeliveries);
}, []);
const bulkRemoveDelivery = useCallback(() => {
const updatedDeliveries =
@@ -447,33 +564,101 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]);
}, [formik, selectedDeliveries]);
}, [formik, selectedDeliveries, setSelectedDeliveries]);
// Cost Calculation Handlers
const handleDeliveryCostChange = useCallback(
(idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value);
const delivery = formik.values.deliveries?.[idx];
if (delivery) {
const productQty = delivery.products.reduce(
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0
const handleDeliverySelectAllChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedDeliveries(
formik.values.deliveries?.map((_, idx) => idx) ?? []
);
if (productQty > 0 && value > 0) {
const perItem = value / productQty;
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
} else if (value === 0) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
}
} else {
setSelectedDeliveries([]);
}
},
[formik]
[formik.values.deliveries, setSelectedDeliveries]
);
const handleDeliveryCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.name.replace('delivery-', ''));
if (e.target.checked) {
setSelectedDeliveries((prev) => [...prev, idx]);
} else {
setSelectedDeliveries((prev) => prev.filter((i) => i !== idx));
}
},
[setSelectedDeliveries]
);
const handleDeliveryProductChange = useCallback(
(deliveryIdx: number, val: OptionType | OptionType[] | null) => {
formik.setFieldTouched(
`deliveries.${deliveryIdx}.products.0.product`,
true
);
formik.setFieldValue(`deliveries.${deliveryIdx}.products.0.product`, val);
formik.setFieldTouched(
`deliveries.${deliveryIdx}.products.0.product_id`,
true
);
formik.setFieldValue(
`deliveries.${deliveryIdx}.products.0.product_id`,
(val as OptionType)?.value
);
},
[]
);
const handleDeliverySupplierChange = useCallback(
(deliveryIdx: number, val: OptionType | OptionType[] | null) => {
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier`, true);
formik.setFieldValue(`deliveries.${deliveryIdx}.supplier`, val);
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier_id`, true);
formik.setFieldValue(
`deliveries.${deliveryIdx}.supplier_id`,
(val as OptionType)?.value
);
},
[]
);
const handleDeliveryDocumentChange = useCallback(
(deliveryIdx: number, e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
toast.error('Ukuran dokumen maksimal 5 MB!');
e.target.value = '';
return;
}
formik.setFieldValue(`deliveries.${deliveryIdx}.document`, file);
}
},
[]
);
const handleDeliveryCostChange = useCallback((idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value);
const delivery = formik.values.deliveries?.[idx];
if (delivery) {
const productQty = delivery.products.reduce(
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0
);
if (productQty > 0 && value > 0) {
const perItem = value / productQty;
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
} else if (value === 0) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
}
}
}, []);
const handleDeliveryCostPerItemChange = useCallback(
(idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value);
@@ -492,7 +677,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}
}
},
[formik]
[]
);
const handleDeliveryCostChangeWrapper = useCallback(
@@ -967,45 +1152,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang'
placeholder='Pilih gudang asal...'
value={formik.values.source_warehouse}
onChange={(val) => {
const newSourceWarehouseId = (val as WarehouseOptionType)
?.value;
if (newSourceWarehouseId) {
if (
newSourceWarehouseId ===
formik.values.destination_warehouse_id
) {
const destinationWarehouseName =
(
formik.values
.destination_warehouse as WarehouseOptionType
)?.label || 'gudang tujuan';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang asal tidak boleh sama dengan ${destinationWarehouseName}.`
);
return;
}
}
formik.setFieldTouched('source_warehouse', true);
formik.setFieldValue('source_warehouse', val);
formik.setFieldTouched('source_warehouse_id', true);
formik.setFieldValue(
'source_warehouse_id',
newSourceWarehouseId
);
if (
formik.errors.destination_warehouse_id ===
'Gudang tujuan tidak boleh sama dengan gudang asal!'
) {
formik.setFieldError('destination_warehouse_id', undefined);
}
}}
onChange={handleSourceWarehouseChange}
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouses}
isError={
formik.touched.source_warehouse_id &&
@@ -1066,44 +1216,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang'
placeholder='Pilih gudang tujuan...'
value={formik.values.destination_warehouse}
onChange={(val) => {
const newDestinationWarehouseId = (val as WarehouseOptionType)
?.value;
if (newDestinationWarehouseId) {
if (
newDestinationWarehouseId ===
formik.values.source_warehouse_id
) {
const sourceWarehouseName =
(formik.values.source_warehouse as WarehouseOptionType)
?.label || 'gudang asal';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang tujuan tidak boleh sama dengan ${sourceWarehouseName}.`
);
return;
}
}
formik.setFieldTouched('destination_warehouse', true);
formik.setFieldValue('destination_warehouse', val);
formik.setFieldTouched('destination_warehouse_id', true);
formik.setFieldValue(
'destination_warehouse_id',
newDestinationWarehouseId
);
if (
formik.errors.destination_warehouse_id ===
'Gudang tujuan tidak boleh sama dengan gudang asal!'
) {
formik.setFieldError('destination_warehouse_id', undefined);
}
}}
onChange={handleDestinationWarehouseChange}
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses}
onMenuScrollToBottom={loadMoreWarehouses}
isError={
formik.touched.destination_warehouse_id &&
Boolean(formik.errors.destination_warehouse_id)
@@ -1173,18 +1290,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedProducts.length &&
formik.values.products?.length > 0
}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts(
formik.values.products?.map((_, idx) => idx) ??
[]
);
} else {
setSelectedProducts([]);
}
}}
onChange={handleProductSelectAllChange}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -1221,17 +1327,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<CheckboxInput
name={`product-${idx}`}
checked={selectedProducts.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts([...selectedProducts, idx]);
} else {
setSelectedProducts(
selectedProducts.filter((i) => i !== idx)
);
}
}}
onChange={handleProductCheckboxChange}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -1243,26 +1339,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<SelectInput
required
value={product.product ?? undefined}
onChange={(val) => {
formik.setFieldTouched(
`products.${idx}.product`,
true
);
formik.setFieldValue(
`products.${idx}.product`,
val
);
formik.setFieldTouched(
`products.${idx}.product_id`,
true
);
formik.setFieldValue(
`products.${idx}.product_id`,
(val as ProductWarehouseOptionType)?.value
);
}}
onChange={(val) => handleProductChange(idx, val)}
options={productWarehouseOptions}
onInputChange={setProductWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreProductWarehouses}
isLoading={isLoadingProductWarehouses}
isDisabled={
type === 'detail' ||
@@ -1386,19 +1466,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedDeliveries.length &&
formik.values.deliveries?.length > 0
}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries(
formik.values.deliveries?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedDeliveries([]);
}
}}
onChange={handleDeliverySelectAllChange}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -1481,20 +1549,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<CheckboxInput
name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries([
...selectedDeliveries,
idx,
]);
} else {
setSelectedDeliveries(
selectedDeliveries.filter((i) => i !== idx)
);
}
}}
onChange={handleDeliveryCheckboxChange}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -1507,24 +1562,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required
placeholder='Pilih produk...'
value={delivery.products[0]?.product ?? undefined}
onChange={(val) => {
formik.setFieldTouched(
`deliveries.${idx}.products.0.product`,
true
);
formik.setFieldValue(
`deliveries.${idx}.products.0.product`,
val
);
formik.setFieldTouched(
`deliveries.${idx}.products.0.product_id`,
true
);
formik.setFieldValue(
`deliveries.${idx}.products.0.product_id`,
(val as OptionType)?.value
);
}}
onChange={(val) =>
handleDeliveryProductChange(idx, val)
}
options={getFilteredProductWarehouseOptions()}
isDisabled={type === 'detail'}
isClearable
@@ -1575,24 +1615,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required
placeholder='Pilih supplier...'
value={delivery.supplier}
onChange={(val) => {
formik.setFieldTouched(
`deliveries.${idx}.supplier`,
true
);
formik.setFieldValue(
`deliveries.${idx}.supplier`,
val
);
formik.setFieldTouched(
`deliveries.${idx}.supplier_id`,
true
);
formik.setFieldValue(
`deliveries.${idx}.supplier_id`,
(val as OptionType)?.value
);
}}
onChange={(val) =>
handleDeliverySupplierChange(idx, val)
}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
@@ -1684,20 +1709,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<FileInput
accept='.pdf,.jpg,.jpeg,.png'
name={`deliveries.${idx}.document`}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
toast.error('Ukuran dokumen maksimal 5 MB!');
e.target.value = '';
return;
}
formik.setFieldValue(
`deliveries.${idx}.document`,
file
);
}
}}
onChange={(e) =>
handleDeliveryDocumentChange(idx, e)
}
{...isRepeaterInputError(
'deliveries',
'document',
@@ -91,7 +91,7 @@ const InventoryProductDetail = ({
<td>:</td>
<td>
{inventoryProduct?.tax
? formatCurrency(inventoryProduct?.tax)
? formatNumber(inventoryProduct?.tax) + '%'
: '-'}
</td>
</tr>
@@ -16,7 +16,7 @@ import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { TableToolbar } from '@/components/table/TableToolbar';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import {
MarketingApi,
SalesOrderApi,
@@ -33,6 +33,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { useAuth } from '@/services/hooks/useAuth';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import Badge from '@/components/Badge';
const RowsOptionsMenu = ({
type = 'dropdown',
@@ -184,12 +185,16 @@ const MarketingTable = () => {
const {
options: productsOptions,
isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect(ProductApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const {
options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
@@ -400,6 +405,8 @@ const MarketingTable = () => {
.join(',') || ''
)
}
onInputChange={setProductsInputValue}
onMenuScrollToBottom={loadMoreProducts}
isMulti
/>
{/* select status */}
@@ -444,6 +451,8 @@ const MarketingTable = () => {
(value as OptionType)?.value.toString() || ''
)
}
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
/>
</TableRowSizeSelector>
</div>
@@ -512,8 +521,53 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'latest_approval.step_name',
accessorKey: 'approval.step_name',
header: 'Status',
cell: (props) => {
const approval = props.row.original.latest_approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return (
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-full flex flex-row justify-start whitespace-nowrap',
}}
color={
isRejected
? 'error'
: isApproved
? approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
}
/>
{isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge>
);
},
},
{
accessorKey: 'customer.name',
@@ -16,6 +16,7 @@ import {
formatCurrency,
formatDate,
formatNumber,
formatTitleCase,
formatVechicleNumber,
} from '@/lib/helper';
import {
@@ -34,6 +35,7 @@ import toast from 'react-hot-toast';
import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
import RequirePermission from '@/components/helper/RequirePermission';
import Badge from '@/components/Badge';
const MarketingDetail = ({
initialValues,
@@ -121,6 +123,10 @@ const MarketingDetail = ({
);
};
const approval = initialValues?.latest_approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return (
<>
<div className='flex flex-col w-full gap-4'>
@@ -230,7 +236,46 @@ const MarketingDetail = ({
<tr>
<td className='font-semibold'>Status</td>
<td>:</td>
<td>{initialValues?.latest_approval?.step_name}</td>
<td>
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-fit flex flex-row justify-start whitespace-nowrap',
}}
color={
isRejected
? 'error'
: isApproved
? approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
}
/>
{isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge>
</td>
</tr>
<tr>
<td className='font-semibold'>Tanggal Penjualan</td>
@@ -633,7 +633,9 @@ const MarketingForm = ({
isClearable
placeholder='Pilih Pelanggan'
isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver'
formType === 'add_deliver' ||
formType === 'edit_deliver' ||
formType === 'edit'
}
/>
<DateInput
@@ -7,6 +7,7 @@ import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import { format } from 'path';
import { date } from 'yup';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast';
interface DeliveryOrderExportProps {
data?: Marketing;
@@ -23,7 +24,7 @@ const DeliveryOrderExport = ({
const handleDownloadPDF = async () => {
if (!salesData) {
alert('No sales order data available');
toast.error('No sales order data available');
return;
}
setIsGeneratingPDF(true);
@@ -40,8 +41,7 @@ const DeliveryOrderExport = ({
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
toast.error('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
}
@@ -5,6 +5,7 @@ import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import { formatDate, formatNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast';
interface SalesOrderExportProps {
data?: Marketing;
@@ -17,7 +18,7 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
const handleDownloadPDF = async () => {
if (!salesData) {
alert('No sales order data available');
toast.error('No sales order data available');
return;
}
setIsGeneratingPDF(true);
@@ -32,8 +33,7 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
toast.error('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
}
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const AreasTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await AreaApi.delete(selectedArea?.id as number);
const deleteResponse = await AreaApi.delete(selectedArea?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshAreas();
deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -177,7 +177,14 @@ const BanksTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await BankApi.delete(selectedBank?.id as number);
const deleteResponse = await BankApi.delete(selectedBank?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshBanks();
deleteModal.closeModal();
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { CustomerApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -186,7 +186,16 @@ const CustomersTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await CustomerApi.delete(selectedCustomer?.id as number);
const deleteResponse = await CustomerApi.delete(
selectedCustomer?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshCustomers();
deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Fcr } from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const FcrsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FcrApi.delete(selectedFcr?.id as number);
const deleteResponse = await FcrApi.delete(selectedFcr?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshFcrs();
deleteModal.closeModal();
@@ -19,7 +19,7 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { ROWS_OPTIONS } from '@/config/constant';
import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
const RowsOptions = ({
@@ -33,22 +33,6 @@ const RowsOptions = ({
}) => {
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.master.flocks.update'>
<Button
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon
icon='material-symbols:edit-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.flocks.detail'>
<Button
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
@@ -65,6 +49,22 @@ const RowsOptions = ({
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.flocks.update'>
<Button
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon
icon='material-symbols:edit-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.flocks.delete'>
<Button
onClick={deleteClickHandler}
@@ -182,7 +182,14 @@ const FlockTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FlockApi.delete(selectedFlock?.id as number);
const deleteResponse = await FlockApi.delete(selectedFlock?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshFlocks();
deleteModal.closeModal();
@@ -19,6 +19,8 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { toast } from 'react-hot-toast';
import Alert from '@/components/Alert';
interface FlockCustomProps {
formType?: 'add' | 'edit' | 'detail';
@@ -37,7 +39,13 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FlockApi.delete(initialValues?.id as number);
const deleteFlockRes = await FlockApi.delete(initialValues?.id as number);
if (deleteFlockRes?.status === 'error') {
setFlockFormErrorMessage(deleteFlockRes.message);
return;
}
toast.success(deleteFlockRes?.message as string);
deleteModal.closeModal();
setIsDeleteLoading(false);
@@ -68,12 +76,29 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
// cek type form yang disubmit
switch (formType) {
case 'add':
await FlockApi.create(payload);
case 'add': {
const createFlockRes = await FlockApi.create(payload);
if (createFlockRes?.status === 'error') {
setFlockFormErrorMessage(createFlockRes.message);
return;
}
toast.success(createFlockRes?.message as string);
break;
case 'edit':
await FlockApi.update(initialValues?.id as number, payload);
}
case 'edit': {
const updateFlockRes = await FlockApi.update(
initialValues?.id as number,
payload
);
if (updateFlockRes?.status === 'error') {
setFlockFormErrorMessage(updateFlockRes.message);
return;
}
toast.success(updateFlockRes?.message as string);
break;
}
default:
break;
}
@@ -174,6 +199,24 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{flockFormErrorMessage && (
<Alert color='error' className='w-full'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
{flockFormErrorMessage}
<Button
onClick={() => setFlockFormErrorMessage('')}
variant='link'
className='ml-auto p-0 w-fit text-white'
color='none'
>
<Icon icon='material-symbols:close' width={24} height={24} />
</Button>
</Alert>
)}
{formType !== 'detail' && (
<div
@@ -197,17 +240,6 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
</div>
)}
</div>
{flockFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{flockFormErrorMessage}</span>
</div>
)}
</form>
</section>
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -199,7 +199,16 @@ const KandangsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await KandangApi.delete(selectedKandang?.id as number);
const deleteResponse = await KandangApi.delete(
selectedKandang?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshKandangs();
deleteModal.closeModal();
@@ -215,7 +215,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
placeholder='Masukkan nama kandang'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Location } from '@/types/api/master-data/location';
import { LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -186,7 +186,16 @@ const LocationsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await LocationApi.delete(selectedLocation?.id as number);
const deleteResponse = await LocationApi.delete(
selectedLocation?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshLocations();
deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -198,7 +198,16 @@ const NonstocksTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await NonstockApi.delete(selectedNonstock?.id as number);
const deleteResponse = await NonstockApi.delete(
selectedNonstock?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshNonstocks();
deleteModal.closeModal();
@@ -83,7 +83,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const formikInitialValues = useMemo<NonstockFormValues>(() => {
return {
name: initialValues?.name ?? '',
uomId: initialValues?.uom_id ?? 0,
uomId: initialValues?.uom?.id ?? 0,
uom: initialValues?.uom
? {
value: initialValues?.uom?.id,
@@ -229,7 +229,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
placeholder='Masukkan nama nonstock'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -170,7 +170,16 @@ const ProductCategoryTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductCategoryApi.delete(selectedProductCategory?.id as number);
const deleteResponse = await ProductCategoryApi.delete(
selectedProductCategory?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProductCategories();
deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Product } from '@/types/api/master-data/product';
import { ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -230,8 +230,19 @@ const ProductsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductApi.delete(selectedProduct?.id as number);
const deleteResponse = await ProductApi.delete(
selectedProduct?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProducts();
deleteModal.closeModal();
toast.success('Successfully delete Product!');
setIsDeleteLoading(false);
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
type ProductFormSchemaType = {
name: string;
brand: string;
sku: string;
sku?: string;
uom?: {
value: number;
label: string;
@@ -15,10 +15,16 @@ type ProductFormSchemaType = {
} | null;
product_category_id: number;
product_price: number | string;
selling_price: number | string;
tax: number | string;
expiry_period: number | string;
supplier_ids: number[];
selling_price?: number | string;
tax?: number | string;
expiry_period?: number | string;
suppliers: {
supplier: {
value: number;
label: string;
} | null;
price: number;
}[];
flags: string[];
};
@@ -26,7 +32,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'),
sku: Yup.string(),
uom: Yup.object({
value: Yup.number()
@@ -58,24 +64,34 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
.min(1, 'Harga produk tidak boleh kurang dari 1!'),
selling_price: Yup.number()
.required('Harga jual wajib diisi!')
.typeError('Harga jual wajib diisi!')
.typeError('Harga hanya boleh angka!')
.min(1, 'Harga jual tidak boleh kurang dari 1!'),
tax: Yup.number()
.required('Pajak wajib diisi!')
.typeError('Pajak wajib diisi!')
.typeError('Pajak hanya boleh angka!')
.min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa hanya boleh angka!')
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
supplier_ids: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!'))
.min(1, 'Minimal harus ada 1 supplier!')
suppliers: Yup.array()
.of(
Yup.object({
supplier: Yup.object({
value: Yup.number()
.min(1, 'Supplier wajib dipilih!')
.required('Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'),
label: Yup.string().required('Supplier wajib dipilih!'),
}).required('Supplier wajib dipilih!'),
price: Yup.number()
.min(1, 'Harga tidak boleh kurang dari 1!')
.required('Harga wajib diisi!')
.typeError('Harga wajib diisi!'),
})
)
.required('Supplier wajib diisi!'),
flags: Yup.array()
@@ -41,6 +41,8 @@ import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { Supplier } from '@/types/api/master-data/supplier';
import Card from '@/components/Card';
import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ProductFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -101,7 +103,15 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
selling_price: initialValues?.selling_price ?? '',
tax: initialValues?.tax ?? '',
expiry_period: initialValues?.expiry_period ?? '',
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [],
suppliers: initialValues?.suppliers
? initialValues.suppliers.map((supplier) => ({
supplier: {
value: supplier.id,
label: supplier.name,
},
price: supplier.price,
}))
: [],
flags: initialValues?.flags ?? [],
}),
[initialValues]
@@ -120,12 +130,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
uom_id: values.uom_id,
product_category_id: values.product_category_id,
product_price: parseInt(values.product_price.toString()) || 0,
selling_price: parseInt(values.selling_price.toString()) || 0,
tax: parseInt(values.tax.toString()) || 0,
expiry_period: parseInt(values.expiry_period.toString()) || 0,
supplier_ids: values.supplier_ids.filter(
(id): id is number => typeof id === 'number'
),
selling_price: values.selling_price
? parseInt(values.selling_price.toString()) || 0
: undefined,
tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
expiry_period: values.expiry_period
? parseInt(values.expiry_period.toString()) || 0
: undefined,
suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number,
price: parseInt(s.price.toString()) || 0,
})),
flags: values.flags.filter((f): f is string => typeof f === 'string'),
};
switch (type) {
@@ -179,13 +194,29 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
category: 'SAPRONAK',
});
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldTouched('supplier_ids', true);
formik.setFieldValue(
'supplier_ids',
arr.map((v) => (v as OptionType).value)
);
const filteredSupplierOptions = useMemo(() => {
return supplierOptions.filter((opt) => {
return !formik.values.suppliers.some(
(s) => s.supplier?.value === opt.value
);
});
}, [supplierOptions, formik.values.suppliers]);
const addSupplierHandler = () => {
formik.setFieldValue('suppliers', [
...formik.values.suppliers,
{
supplier_id: '',
price: formik.values.product_price,
},
]);
};
const deleteSupplierItemHandler = (idx: number) => {
const path = 'suppliers';
// trims values, errors, and touched at idx
removeArrayItemAndSync(formik, path, idx);
};
const deleteProductClickHandler = () => {
@@ -201,6 +232,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
router.push('/master-data/product');
};
const isSupplierRepeaterError = (
column: 'supplier' | 'price',
supplierIdx: number
) => {
return (
formik.touched.suppliers?.[supplierIdx]?.[column] &&
Boolean(
formik.errors.suppliers?.[supplierIdx] instanceof Object &&
formik.errors.suppliers?.[supplierIdx]?.[column]
)
);
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
@@ -271,7 +315,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
<TextInput
required
label='SKU'
name='sku'
placeholder='Masukkan SKU...'
@@ -344,7 +387,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
<NumberInput
required
label='Harga Jual'
name='selling_price'
placeholder='Masukkan harga jual...'
@@ -366,7 +408,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput
required
label='Pajak (%)'
name='tax'
placeholder='Masukkan pajak...'
@@ -383,7 +424,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
<NumberInput
required
label='Periode Kadaluarsa (hari)'
name='expiry_period'
placeholder='Masukkan periode kadaluarsa...'
@@ -403,28 +443,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<SelectInput
required
label='Supplier'
placeholder='Pilih supplier...'
isMulti
value={supplierOptions.filter((opt) =>
(formik.values.supplier_ids || []).includes(opt.value)
)}
onChange={supplierChangeHandler}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSuppliers}
isError={
formik.touched.supplier_ids &&
Boolean(formik.errors.supplier_ids)
}
errorMessage={formik.errors.supplier_ids as string}
isDisabled={type === 'detail'}
isClearable
/>
<div className='grid sm:grid-cols-1 gap-4'>
<SelectInput
required
label='Flags'
@@ -447,6 +466,129 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isClearable
/>
</div>
<div className='grid sm:grid-cols-1 gap-4'>
{type !== 'detail' && formik.values.suppliers.length === 0 && (
<Button
type='button'
color='success'
onClick={addSupplierHandler}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah
Supplier
</Button>
)}
{formik.values.suppliers.length > 0 && (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<div className='mb-4 text-center'>
<h4 className='font-bold text-xl'>Supplier</h4>
</div>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Supplier
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Harga
</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{formik.values.suppliers.map((supplier, idx) => (
<tr key={idx}>
<td className='p-2 w-full max-w-1/2'>
<SelectInput
placeholder='Pilih Supplier'
options={filteredSupplierOptions}
onInputChange={setSupplierSelectInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSuppliers}
value={formik.values.suppliers[idx].supplier}
onChange={(val) => {
formik.setFieldValue(
`suppliers.${idx}.supplier`,
val
);
}}
isError={isSupplierRepeaterError(
'supplier',
idx
)}
isClearable
isDisabled={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
<td className='p-2 w-full max-w-1/2'>
<NumberInput
required
name={`suppliers.${idx}.price`}
placeholder='Masukkan harga...'
value={formik.values.suppliers[idx].price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={isSupplierRepeaterError('price', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => deleteSupplierItemHandler(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{type !== 'detail' && (
<div className='w-full flex flex-row justify-center'>
<Button
type='button'
color='success'
onClick={addSupplierHandler}
>
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
Tambah Supplier
</Button>
</div>
)}
</Card>
)}
</div>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
@@ -7,7 +7,7 @@ import { ProductionStandard } from '@/types/api/master-data/production-standard'
import { Icon } from '@iconify/react';
import useSWR from 'swr';
import { ProductionStandardApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { CellContext } from '@tanstack/react-table';
import { useModal } from '@/components/Modal';
@@ -94,9 +94,16 @@ const ProductionStandardTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductionStandardApi.delete(
const deleteResponse = await ProductionStandardApi.delete(
selectedProductionStandard?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProductionStandards();
deleteModal.closeModal();
@@ -2,34 +2,30 @@ import * as Yup from 'yup';
// Schema for LAYING category (production_standard_details is required)
const LayingRepeaterFormSchema = Yup.object({
week: Yup.number().required('Minggu wajib diisi!'),
week: Yup.number().required('Wajib diisi!'),
production_standard_uniformity_details: Yup.object({
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'),
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'),
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'),
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'),
target_mean_bw: Yup.number().required('Wajib diisi!'),
max_depletion: Yup.number().required('Wajib diisi!'),
min_uniformity: Yup.number().required('Wajib diisi!'),
feed_intake: Yup.number().required('Wajib diisi!'),
}),
production_standard_details: Yup.object({
target_hen_day_production: Yup.number().required(
'Produksi telur per hari wajib diisi!'
),
target_hen_house_production: Yup.number().required(
'Produksi telur per kandang wajib diisi!'
),
target_egg_weight: Yup.number().required('Berat telur wajib diisi!'),
target_egg_mass: Yup.number().required('Massa telur wajib diisi!'),
standard_fcr: Yup.number().required('FCR wajib diisi!'),
target_hen_day_production: Yup.number().required('Wajib diisi!'),
target_hen_house_production: Yup.number().required('Wajib diisi!'),
target_egg_weight: Yup.number().required('Wajib diisi!'),
target_egg_mass: Yup.number().required('Wajib diisi!'),
standard_fcr: Yup.number().required('Wajib diisi!'),
}).required(),
});
// Schema for GROWING category (production_standard_details is optional)
const GrowingRepeaterFormSchema = Yup.object({
week: Yup.number().required('Minggu wajib diisi!'),
week: Yup.number().required('Wajib diisi!'),
production_standard_uniformity_details: Yup.object({
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'),
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'),
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'),
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'),
target_mean_bw: Yup.number().required('Wajib diisi!'),
max_depletion: Yup.number().required('Wajib diisi!'),
min_uniformity: Yup.number().required('Wajib diisi!'),
feed_intake: Yup.number().required('Wajib diisi!'),
}),
production_standard_details: Yup.object({
target_hen_day_production: Yup.number().optional(),
@@ -344,7 +344,7 @@ const ProductionStandardForm = ({
const columns = useMemo<ColumnDef<TableRowsType>[]>(() => {
const baseColumns: ColumnDef<TableRowsType>[] = [
{
header: 'Minggu',
header: 'Week',
accessorKey: 'week',
enableSorting: false,
},
@@ -358,30 +358,40 @@ const ProductionStandardForm = ({
header: 'Hen Day',
accessorFn: (row) =>
row.production_standard_details?.target_hen_day_production,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_hen_day_production}%`,
enableSorting: false,
},
{
header: 'Hen House',
accessorFn: (row) =>
row.production_standard_details?.target_hen_house_production,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_hen_house_production} pc`,
enableSorting: false,
},
{
header: 'Egg Weight',
accessorFn: (row) =>
row.production_standard_details?.target_egg_weight,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_egg_weight} g`,
enableSorting: false,
},
{
header: 'Egg Mass',
accessorFn: (row) =>
row.production_standard_details?.target_egg_mass,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_egg_mass} g`,
enableSorting: false,
},
{
header: 'FCR',
accessorFn: (row) =>
row.production_standard_details?.standard_fcr,
cell: ({ row }) =>
`${row.original.production_standard_details?.standard_fcr} g`,
enableSorting: false,
},
]
@@ -393,24 +403,32 @@ const ProductionStandardForm = ({
header: 'Mean BW',
accessorFn: (row) =>
row.production_standard_uniformity_details?.target_mean_bw,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.target_mean_bw} g`,
enableSorting: false,
},
{
header: 'Max Depletion',
accessorFn: (row) =>
row.production_standard_uniformity_details?.max_depletion,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.max_depletion}%`,
enableSorting: false,
},
{
header: 'Min Uniformity',
accessorFn: (row) =>
row.production_standard_uniformity_details?.min_uniformity,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.min_uniformity}%`,
enableSorting: false,
},
{
header: 'Feed Intake',
accessorFn: (row) =>
row.production_standard_uniformity_details?.feed_intake,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.feed_intake} g`,
enableSorting: false,
},
];
@@ -728,7 +746,52 @@ const ProductionStandardForm = ({
};
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
formik,
{
onBeforeSubmit: (e) => {
e.preventDefault();
// For GROWING category, clear production_standard_details errors and set default values
if (formik.values.project_category === 'GROWING') {
// Set default values for production_standard_details
formik.values.details?.forEach((detail) => {
detail.production_standard_details = {
target_hen_day_production: 0,
target_hen_house_production: 0,
target_egg_weight: 0,
target_egg_mass: 0,
standard_fcr: 0,
};
});
// Clear any errors related to production_standard_details
const currentErrors = { ...formik.errors };
if (currentErrors.details && Array.isArray(currentErrors.details)) {
const cleanedDetails = currentErrors.details
.map((detailError) => {
if (detailError && typeof detailError === 'object') {
const { production_standard_details, ...rest } = detailError;
return Object.keys(rest).length > 0 ? rest : undefined;
}
return detailError;
})
.filter(
(error): error is Exclude<typeof error, undefined> =>
error !== undefined
);
currentErrors.details = (
cleanedDetails.length > 0 ? cleanedDetails : undefined
) as typeof currentErrors.details;
}
formik.setErrors(currentErrors);
}
return true;
},
}
);
return (
<>
@@ -821,19 +884,20 @@ const ProductionStandardForm = ({
key={`row-${row.index}`}
className='sticky bottom-0 bg-base-100 shadow-lg'
>
<td colSpan={colSpan} className='p-6'>
<td colSpan={colSpan} className='p-2'>
<form
className='h-full w-full flex flex-col justify-end'
onSubmit={repeaterFormik.handleSubmit}
onReset={repeaterFormik.handleReset}
>
<div
className={cn(
'grid gap-4 items-start',
formik.values.project_category === 'LAYING'
? 'grid-cols-10'
: 'grid-cols-5'
)}
className='grid gap-2 items-start w-full'
style={{
gridTemplateColumns:
formik.values.project_category === 'LAYING'
? 'repeat(10, minmax(auto, 1fr)) minmax(auto, auto)'
: 'repeat(4, minmax(auto, 1fr)) minmax(auto, auto)',
}}
>
<NumberInput
name='week'
@@ -862,7 +926,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />}
bottomLabel='Persen (%)'
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -894,11 +958,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
Butir
</div>
}
bottomLabel='Butir (pc)'
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -930,11 +990,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
bottomLabel='Gram (g)'
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -959,17 +1015,13 @@ const ProductionStandardForm = ({
name='production_standard_details.target_egg_mass'
label='Egg Mass'
placeholder='1'
bottomLabel='Gram (g)'
value={
repeaterFormik.values
.production_standard_details?.target_egg_mass
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -1000,11 +1052,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
bottomLabel='Gram (g)'
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -1038,11 +1086,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
bottomLabel='Gram (g)'
errorMessage={
repeaterFormik.errors
.production_standard_uniformity_details
@@ -1072,7 +1116,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />}
bottomLabel='Persen (%)'
errorMessage={
repeaterFormik.errors
.production_standard_uniformity_details
@@ -1102,7 +1146,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />}
bottomLabel='Persen (%)'
errorMessage={
repeaterFormik.errors
.production_standard_uniformity_details
@@ -1132,11 +1176,8 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr/ekor
</div>
}
bottomLabel='Gram/Ekor (g)'
endAdornment
errorMessage={
repeaterFormik.errors
.production_standard_uniformity_details
@@ -1162,7 +1203,7 @@ const ProductionStandardForm = ({
type='button'
color='error'
variant='outline'
className='min-w-24'
className='min-w-xs'
onClick={handleCancelEdit}
>
<Icon icon='mdi:close' /> Batal
@@ -1178,7 +1219,7 @@ const ProductionStandardForm = ({
<Button
type='submit'
color={editMode ? 'warning' : 'success'}
className='min-w-24'
className='min-w-xs'
disabled={
isAddingRow ||
formik.values.project_category === ''
@@ -1195,7 +1236,7 @@ const ProductionStandardForm = ({
variant='outline'
color='primary'
onClick={toggleTableHeight}
className='absolute bottom-6 right-6'
className='absolute bottom-2 right-2'
>
<Icon
icon={
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -205,7 +205,16 @@ const SuppliersTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await SupplierApi.delete(selectedSupplier?.id as number);
const deleteResponse = await SupplierApi.delete(
selectedSupplier?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshSuppliers();
deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Uom } from '@/types/api/master-data/uom';
import { UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const UomsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await UomApi.delete(selectedUom?.id as number);
const deleteResponse = await UomApi.delete(selectedUom?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshUoms();
deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -220,7 +220,16 @@ const WarehousesTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await WarehouseApi.delete(selectedWarehouse?.id as number);
const deleteResponse = await WarehouseApi.delete(
selectedWarehouse?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshWarehouses();
deleteModal.closeModal();
@@ -330,7 +330,7 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
placeholder='Masukkan nama warehouse'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
@@ -75,12 +75,12 @@ const ChickinFormKandang = ({
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color='success'
color='primary'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
<Icon icon='mdi:circle' width={12} height={12} color='primary' />{' '}
Aktif
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
@@ -5,14 +5,17 @@ import Button from '@/components/Button';
import FloatingActionsButton from '@/components/FloatingActionsButton';
import CheckboxInput from '@/components/input/CheckboxInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate } from '@/lib/helper';
import { cn, formatDate, formatTitleCase } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -59,9 +62,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id])
.map((id) => parseInt(id));
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
const [kandangSelectInputValue, setKandangSelectInputValue] = useState('');
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
@@ -90,55 +90,25 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
{ revalidateOnMount: true }
);
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({
search: areaSelectInputValue,
limit: '100',
}).toString()}`;
const { data: areas, isLoading: isLoadingAreas } = useSWR(
areaUrl,
AreaApi.getAllFetcher
);
const locationUrl = `${LocationApi.basePath}?${new URLSearchParams({
search: locationSelectInputValue,
area_id: selectedArea != null ? selectedArea.value.toString() : '',
limit: '100',
}).toString()}`;
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationUrl,
LocationApi.getAllFetcher
);
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: kandangSelectInputValue,
location_id:
selectedLocation != null ? selectedLocation.value.toString() : '',
limit: '100',
}).toString()}`;
const { data: kandangs, isLoading: isLoadingKandang } = useSWR(
kandangUrl,
KandangApi.getAllFetcher
);
// ===== Data to Options Mapping ======
const optionsArea = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const optionsKandang = isResponseSuccess(kandangs)
? kandangs?.data.map((kandang) => ({
value: kandang.id,
label: kandang.name,
}))
: [];
const optionsLocation = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
// ===== Fetch Data Select =====
const {
options: optionsArea,
isLoadingOptions: isLoadingArea,
setInputValue: setAreaSelectInputValue,
loadMore: loadMoreArea,
} = useSelect(AreaApi.basePath, 'id', 'name');
const {
options: optionsLocation,
isLoadingOptions: isLoadingLocation,
setInputValue: setLocationSelectInputValue,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name');
const {
options: optionsKandang,
isLoadingOptions: isLoadingKandang,
setInputValue: setKandangSelectInputValue,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name');
// ====== HANDLER ======
const confirmationModalDeleteClickHandler = async () => {
@@ -284,7 +254,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
header: 'Status',
cell: (props) => {
const approval = props.row.original.approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return (
<Badge
variant='soft'
@@ -292,11 +263,17 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
badge: 'rounded-lg px-2 w-full flex flex-row justify-start',
}}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'success'
: 'error'
isRejected
? 'error'
: isApproved
? approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
}
>
<Icon
@@ -307,11 +284,15 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'success'
: 'error'
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
}
/>
{approval?.step_name}
{isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge>
);
},
@@ -385,7 +366,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<SelectInput
label='Area'
options={optionsArea}
isLoading={isLoadingAreas}
isLoading={isLoadingArea}
value={selectedArea}
onChange={(val) => {
setSelectedArea(val as OptionType);
@@ -395,12 +376,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
}}
onInputChange={setAreaSelectInputValue}
onMenuScrollToBottom={loadMoreArea}
isClearable
/>
<SelectInput
label='Lokasi'
options={optionsLocation}
isLoading={isLoadingLocations}
isLoading={isLoadingLocation}
value={selectedLocation}
onChange={(val) => {
setSelectedLocation(val as OptionType);
@@ -410,6 +392,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
}}
onInputChange={setLocationSelectInputValue}
onMenuScrollToBottom={loadMoreLocation}
isClearable
/>
<SelectInput
@@ -425,6 +408,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
}}
onInputChange={setKandangSelectInputValue}
onMenuScrollToBottom={loadMoreKandang}
isClearable
/>
<DebouncedTextInput
@@ -156,9 +156,9 @@ const ProjectFlockDetail = ({
projectFlock.approval?.step_number == 1
? 'neutral'
: projectFlock.approval?.step_number == 2
? 'success'
: projectFlock.approval?.step_number >= 3
? 'error'
? 'primary'
: projectFlock.approval?.step_number == 3
? 'success'
: undefined
}
className={{
@@ -173,9 +173,9 @@ const ProjectFlockDetail = ({
projectFlock.approval?.step_number == 1
? 'neutral'
: projectFlock.approval?.step_number == 2
? 'success'
: projectFlock.approval?.step_number >= 3
? 'error'
? 'primary'
: projectFlock.approval?.step_number == 3
? 'success'
: undefined
}
/>{' '}
@@ -273,7 +273,7 @@ const ProjectFlockDetail = ({
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
color={'primary'}
className={{
badge: 'rounded-lg px-2',
}}
@@ -282,7 +282,7 @@ const ProjectFlockDetail = ({
icon='mdi:circle'
width={12}
height={12}
color={'success'}
color={'primary'}
/>{' '}
Kandang Aktif ({projectFlock.kandangs?.length})
</Badge>
@@ -102,41 +102,54 @@ const ProjectFlockForm = ({
);
// Fetch Data
const { isLoadingOptions: isLoadingFlocks, options: optionsFlock } =
useSelect(FlockApi.basePath, 'id', 'name');
const {
setInputValue: setInputValueFlock,
isLoadingOptions: isLoadingFlocks,
options: optionsFlock,
loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
project_category: selectedCategory,
});
const { options: optionsArea, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name'
);
const {
setInputValue: setInputValueArea,
options: optionsArea,
isLoadingOptions: isLoadingAreas,
loadMore: loadMoreArea,
} = useSelect(AreaApi.basePath, 'id', 'name');
const { options: optionsLocation, isLoadingOptions: isLoadingLocations } =
useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
});
const {
options: optionsLocation,
isLoadingOptions: isLoadingLocations,
setInputValue: setInputValueLocation,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
});
const { options: optionsFcr, isLoadingOptions: isLoadingFcrs } = useSelect(
FcrApi.basePath,
'id',
'name'
);
const {
options: optionsFcr,
isLoadingOptions: isLoadingFcrs,
setInputValue: setInputValueFcr,
loadMore: loadMoreFcr,
} = useSelect(FcrApi.basePath, 'id', 'name');
const {
options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards,
setInputValue: setInputValueProductionStandard,
loadMore: loadMoreProductionStandard,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
search: '',
project_category: selectedCategory,
});
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: '',
location_id: selectedLocation == '' ? '0' : selectedLocation,
limit: 'limit',
limit: '500',
}).toString()}`;
const {
data: kandang,
@@ -153,6 +166,8 @@ const ProjectFlockForm = ({
options: optionsNonstock,
rawData: nonstocks,
isLoadingOptions: isLoadingNonstocks,
setInputValue: setInputValueNonstock,
loadMore: loadMoreNonstock,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
useEffect(() => {
@@ -542,15 +557,12 @@ const ProjectFlockForm = ({
};
const onDeleteBudgetRowHandler = (nonstock_id: number, index?: number) => {
console.log(`nonstock_id: ${nonstock_id}, index: ${index}`);
if (!nonstock_id) {
const updatedBudgets = formik.values.project_budgets
.map((budget, i) => {
if (i == index) {
console.log(`buget: ${null}, index: ${index}, i: ${i}`);
return null;
} else {
console.log(`buget: ${budget}, index: ${index}, i: ${i}`);
return budget;
}
})
@@ -722,6 +734,8 @@ const ProjectFlockForm = ({
formik.touched.area_id && Boolean(formik.errors.area_id)
}
errorMessage={formik.errors.area_id as string}
onInputChange={setInputValueArea}
onMenuScrollToBottom={loadMoreArea}
isClearable
isDisabled={formType != 'add'}
/>
@@ -740,6 +754,8 @@ const ProjectFlockForm = ({
formik.touched.location_id &&
Boolean(formik.errors.location_id)
}
onInputChange={setInputValueLocation}
onMenuScrollToBottom={loadMoreLocation}
errorMessage={formik.errors.location_id as string}
isClearable
isDisabled={formType != 'add' || disabledLocation}
@@ -766,6 +782,8 @@ const ProjectFlockForm = ({
);
}}
options={optionsFlock}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
isLoading={isLoadingFlocks}
isError={
formik.touched.flock_name && Boolean(formik.errors.flock_name)
@@ -781,6 +799,8 @@ const ProjectFlockForm = ({
onChange={(val) => {
optionChangeHandler(val, 'fcr');
}}
onInputChange={setInputValueFcr}
onMenuScrollToBottom={loadMoreFcr}
options={optionsFcr}
isLoading={isLoadingFcrs}
isError={formik.touched.fcr_id && Boolean(formik.errors.fcr_id)}
@@ -808,6 +828,8 @@ const ProjectFlockForm = ({
onChange={(val) => {
optionChangeHandler(val, 'production_standard');
}}
onInputChange={setInputValueProductionStandard}
onMenuScrollToBottom={loadMoreProductionStandard}
options={optionsProductionStandards}
isLoading={isLoadingProductionStandards}
isError={
@@ -892,6 +914,8 @@ const ProjectFlockForm = ({
isLoading={isLoadingNonstocks}
placeholder='Pilih barang non stock'
value={formik.values.project_budgets[index].nonstock}
onInputChange={setInputValueNonstock}
onMenuScrollToBottom={loadMoreNonstock}
onChange={(val) => {
const updatedBudgets = [
...formik.values.project_budgets,
@@ -5,7 +5,7 @@ import { RefObject } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { SortingState, CellContext } from '@tanstack/react-table';
import { cn, formatDate } from '@/lib/helper';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import RequirePermission from '@/components/helper/RequirePermission';
import { useModal } from '@/components/Modal';
import Modal from '@/components/Modal';
@@ -656,30 +656,52 @@ const RecordingTable = () => {
);
},
cell: ({ row }) => {
const recording = row.original;
const isDisabled = isRecordingApproved(recording);
const handleToggleSelection = (e: unknown) => {
if (!isDisabled) {
row.getToggleSelectedHandler()(e);
}
};
return (
<div>
<div className={cn({ 'opacity-50': isDisabled })}>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
onChange={handleToggleSelection}
disabled={isDisabled}
/>
</div>
);
},
},
{
header: '#',
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
header: 'Nama Project',
header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-',
},
{
header: 'Flock',
cell: (props) =>
props.row.original.project_flock?.flock_name || '-',
},
{
header: 'Kandang',
cell: (props) => props.row.original.kandang?.name || '-',
},
{
header: 'Periode',
cell: (props) => props.row.original.project_flock?.period || '-',
},
{
header: 'Kategori',
cell: (props) => {
@@ -696,19 +718,280 @@ const RecordingTable = () => {
},
{
header: 'Umur (hari)',
cell: (props) => props.row.original.day,
cell: (props) => {
return (
<>
<span>
{props.row.original.day} (Minggu ke-
{props.row.original.project_flock.production_standart.week})
</span>
</>
);
},
},
{
accessorKey: 'record_date',
header: 'Waktu Recording',
cell: (props) =>
formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'),
},
{
header: 'Populasi Awal',
header: 'Populasi Akhir',
cell: (props) =>
props.row.original.project_flock?.total_chick_qty?.toLocaleString() ||
'-',
props.row.original.project_flock?.total_chick_qty != null
? formatNumber(props.row.original.project_flock.total_chick_qty)
: '-',
},
{
id: 'fcr',
header: 'FCR',
columns: [
{
id: 'fcr_actual',
header: 'Actual',
cell: (props) => {
const value = props.row.original.fcr_value;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'fcr_standard',
header: 'Standard',
cell: (props) => {
const value = props.row.original.project_flock?.fcr?.fcr_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
],
},
{
id: 'feed_intake',
header: 'Feed Intake (KG)',
columns: [
{
id: 'feed_intake_actual',
header: 'Actual',
cell: (props) => {
const value = props.row.original.feed_intake;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'feed_intake_standard',
header: 'Standard',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.feed_intake_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
],
},
{
id: 'mortality',
header: 'Mortality',
columns: [
{
id: 'cum_depletion_rate_actual',
header: 'Cum Depletion Rate',
cell: (props) => {
const value = props.row.original.cum_depletion_rate;
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'max_depletion_std',
header: 'Max Depletion Std',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.max_depletion_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'total_depletion',
header: 'Total Depletion',
cell: (props) => {
const value = props.row.original.total_depletion_qty;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
],
},
{
id: 'egg_production',
header: 'Egg Production',
columns: [
{
id: 'egg_mass_actual',
header: 'Egg Mass Actual',
cell: (props) => {
const value = props.row.original.egg_mass;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'egg_mass_standard',
header: 'Egg Mass Standar',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.egg_mass_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'egg_weight_actual',
header: 'Egg Weight Actual',
cell: (props) => {
const value = props.row.original.egg_weight;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'egg_weight_standard',
header: 'Egg Weight Standar',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.egg_weight_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
],
},
{
id: 'hen_performance',
header: 'Hen Performance',
columns: [
{
id: 'hen_day_actual',
header: 'Hen Day Actual',
cell: (props) => {
const value = props.row.original.hen_day;
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'hen_day_standard',
header: 'Hen Day Standar',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.hen_day_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'hen_house_actual',
header: 'Hen House Actual',
cell: (props) => {
const value = props.row.original.hen_house;
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'hen_house_standard',
header: 'Hen House Standar',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.hen_house_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
],
},
{
header: 'Status Approval',
@@ -730,21 +1013,6 @@ const RecordingTable = () => {
approvalHistoryModal.openModal();
};
const getStatusText = (action: string) => {
switch (action) {
case 'APPROVED':
return 'Disetujui';
case 'REJECTED':
return 'Ditolak';
case 'CREATED':
return 'Dibuat';
case 'UPDATED':
return 'Diperbarui';
default:
return action;
}
};
return (
<Badge
variant='soft'
@@ -755,7 +1023,7 @@ const RecordingTable = () => {
}}
onClick={openApprovalHistory}
>
{getStatusText(approval.action)}
{approval.step_name || approval.action}
</Badge>
);
},
@@ -874,14 +1142,15 @@ const RecordingTable = () => {
'mb-20':
isResponseSuccess(recordings) && recordings?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
'px-4 py-3 text-xs font-semibold text-gray-500 whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
@@ -7,6 +7,22 @@ import {
} from '@/types/api/production/recording';
type RecordingGrowingFormSchemaType = {
record_date: string;
location?: {
value: number;
label: string;
} | null;
location_id: number;
project_flock?: {
value: number;
label: string;
} | null;
project_flock_id: number;
kandang?: {
value: number;
label: string;
} | null;
kandang_id: number;
project_flock_kandang: {
value: number;
label: string;
@@ -17,16 +33,16 @@ type RecordingGrowingFormSchemaType = {
qty: number | string;
}[];
depletions: {
product_warehouse_id: number;
qty: number | string;
product_warehouse_id?: number;
qty?: number | string;
}[];
};
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: {
product_warehouse_id: number;
qty: number | string;
weight: number | string;
product_warehouse_id?: number;
qty?: number | string;
weight?: number | string;
}[];
};
@@ -36,14 +52,14 @@ export type StockSchema = {
};
export type DepletionSchema = {
product_warehouse_id: number;
qty: number | string;
product_warehouse_id?: number;
qty?: number | string;
};
export type EggSchema = {
product_warehouse_id: number;
qty: number | string;
weight: number | string;
product_warehouse_id?: number;
qty?: number | string;
weight?: number | string;
};
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
@@ -59,32 +75,51 @@ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.number()
.required('Produk depletions wajib diisi!')
.min(1, 'Produk depletions wajib diisi!')
.typeError('Produk depletions harus berupa angka!'),
.optional()
.typeError('Depletions harus berupa angka!'),
qty: Yup.number()
.required('Jumlah depletions wajib diisi!')
.min(1, 'Jumlah depletions minimal 1!')
.optional()
.typeError('Jumlah depletions harus berupa angka!'),
});
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
product_warehouse_id: Yup.number()
.required('Kondisi telur wajib diisi!')
.min(1, 'Kondisi telur wajib diisi!')
.optional()
.typeError('Kondisi telur harus berupa angka!'),
qty: Yup.number()
.required('Jumlah telur wajib diisi!')
.min(1, 'Jumlah telur tidak boleh 0!')
.typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number()
.required('Berat telur wajib diisi!')
.min(1, 'Berat telur minimal 1 gram!')
.typeError('Berat telur harus berupa angka!'),
qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
});
export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
Yup.object({
record_date: Yup.string()
.required('Tanggal recording wajib diisi!')
.min(1, 'Tanggal recording wajib diisi!')
.typeError('Tanggal recording wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
location_id: Yup.number()
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'),
project_flock: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
project_flock_id: Yup.number()
.min(1, 'Project flock wajib diisi!')
.required('Project flock wajib diisi!')
.typeError('Project flock wajib diisi!'),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!')
.typeError('Kandang wajib diisi!'),
project_flock_kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
@@ -100,7 +135,7 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSc
.required('Project Flock Kandang wajib diisi!')
.test(
'not-already-recorded',
'Project Flock ini sudah direcord hari ini!',
'Project Flock ini sudah direcord pada tanggal tersebut!',
function (value) {
const recordedProjectFlockIds = this.options.context
?.recordedProjectFlockIds as Set<number>;
@@ -119,18 +154,12 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSc
.of(StockObjectSchema)
.min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'),
depletions: Yup.array()
.of(DepletionObjectSchema)
.min(1, 'Minimal harus ada 1 data depletions!')
.required('Data depletions wajib diisi!'),
depletions: Yup.array().of(DepletionObjectSchema).default([]),
});
export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> =
RecordingGrowingFormSchema.shape({
eggs: Yup.array()
.of(EggObjectSchema)
.min(1, 'Minimal harus ada 1 data telur!')
.required('Data telur wajib diisi!'),
eggs: Yup.array().of(EggObjectSchema).default([]),
});
export const UpdateRecordingGrowingFormSchema =
@@ -179,6 +208,15 @@ type RecordingFormData = Partial<Recording> & {
export const getRecordingGrowingFormInitialValues = (
initialValues?: RecordingFormData
): RecordingGrowingFormValues => ({
record_date: initialValues?.record_datetime
? new Date(initialValues.record_datetime).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
location: null,
location_id: 0,
project_flock: null,
project_flock_id: 0,
kandang: null,
kandang_id: 0,
project_flock_kandang: initialValues?.project_flock_kandang_id
? {
value: initialValues.project_flock_kandang_id,
File diff suppressed because it is too large Load Diff
@@ -179,12 +179,16 @@ const TransferToLayingsTable = () => {
setInputValue: setFlockSourceInputValue,
options: flockSourceOptions,
isLoadingOptions: isLoadingFlockSourceOptions,
loadMore: loadMoreFlockSource,
hasMore: hasMoreFlockSource,
} = useSelect<Flock>(FlockApi.basePath, 'id', 'name');
const {
setInputValue: setFlockDestinationInputValue,
options: flockDestinationOptions,
isLoadingOptions: isLoadingFlockDestinationOptions,
loadMore: loadMoreFlockDestination,
hasMore: hasMoreFlockDestination,
} = useSelect<Flock>(FlockApi.basePath, 'id', 'name');
// Flocks value
@@ -595,6 +599,7 @@ const TransferToLayingsTable = () => {
value={selectedFlockSource}
onChange={flockSourceChangeHandler}
onInputChange={setFlockSourceInputValue}
onMenuScrollToBottom={loadMoreFlockSource}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
@@ -608,6 +613,7 @@ const TransferToLayingsTable = () => {
value={selectedFlockDestination}
onChange={flockDestinationChangeHandler}
onInputChange={setFlockDestinationInputValue}
onMenuScrollToBottom={loadMoreFlockDestination}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
@@ -270,6 +270,8 @@ const TransferToLayingForm = ({
options: flockSourceOptions,
isLoadingOptions: isLoadingFlockSourceOptions,
rawData: flockSources,
loadMore: loadMoreFlockSource,
hasMore: hasMoreFlockSource,
} = useSelect<ProjectFlock>(
'/production/project-flocks',
'id',
@@ -360,6 +362,8 @@ const TransferToLayingForm = ({
options: flockDestinationOptions,
isLoadingOptions: isLoadingFlockDestinationOptions,
rawData: flockDestinations,
loadMore: loadMoreFlockDestination,
hasMore: hasMoreFlockDestination,
} = useSelect<ProjectFlock>(
'/production/project-flocks',
'id',
@@ -573,6 +577,7 @@ const TransferToLayingForm = ({
onChange={flockSourceChangeHandler}
isLoading={isLoadingFlockSourceOptions}
onInputChange={setFlockSourceInputValue}
onMenuScrollToBottom={loadMoreFlockSource}
isError={
formik.touched.flockSource &&
Boolean(typeof formik.errors.flockSource === 'string')
@@ -591,6 +596,7 @@ const TransferToLayingForm = ({
onChange={flockDestinationChangeHandler}
isLoading={isLoadingFlockDestinationOptions}
onInputChange={setFlockDestinationInputValue}
onMenuScrollToBottom={loadMoreFlockDestination}
isError={
formik.touched.flockDestination &&
Boolean(typeof formik.errors.flockDestination === 'string')
@@ -37,7 +37,10 @@ import DateInput from '@/components/input/DateInput';
import { LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
import {
ProjectFlockKandangLookup,
ProjectFlock,
} from '@/types/api/production/project-flock';
import {
getStatusColor,
getStatusIndicatorColor,
@@ -229,63 +232,37 @@ const UniformityTable = () => {
useState<number | undefined>(undefined);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState('');
const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
useState<string>('');
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const {
setInputValue: setFilterLocationInputValue,
options: filterLocationOptions,
isLoadingOptions: isLoadingFilterLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
limit: '100',
});
loadMore: loadMoreFilterLocations,
hasMore: hasMoreFilterLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
// ===== FETCH PROJECT FLOCKS DATA FOR FILTER =====
const filterProjectFlocksUrl = useMemo(() => {
const params = new URLSearchParams({
search: projectFlockSearchValue || '',
limit: '100',
});
if (filterLocation) {
params.append('location_id', filterLocation.value.toString());
}
return `${ProjectFlockApi.basePath}?${params.toString()}`;
}, [projectFlockSearchValue, filterLocation]);
const {
data: filterProjectFlocksData,
isLoading: isLoadingFilterProjectFlocks,
} = useSWR(filterProjectFlocksUrl, ProjectFlockApi.getAllFetcher);
const filterProjectFlocksDataList = useMemo(
() =>
isResponseSuccess(filterProjectFlocksData)
? filterProjectFlocksData.data
: undefined,
[filterProjectFlocksData]
);
const filterProjectFlockOptions = useMemo(() => {
let options: OptionType[] = [];
if (isResponseSuccess(filterProjectFlocksData)) {
const flockOptions =
filterProjectFlocksData?.data.map((projectFlock) => ({
value: projectFlock.id,
label: projectFlock.flock_name || '',
})) || [];
options = options.concat(flockOptions);
}
return options;
}, [filterProjectFlocksData]);
setInputValue: setFilterProjectFlockSearchValue,
options: filterProjectFlockOptions,
rawData: filterProjectFlocksRawData,
isLoadingOptions: isLoadingFilterProjectFlocks,
loadMore: loadMoreFilterProjectFlocks,
hasMore: hasMoreFilterProjectFlocks,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
location_id: filterProjectFlockLocationId,
});
// ===== KANDANG OPTIONS FOR FILTER =====
const filterKandangOptions = useMemo(() => {
let options: OptionType[] = [];
if (filterProjectFlock && filterProjectFlocksDataList) {
const selectedProjectFlockData = filterProjectFlocksDataList.find(
if (filterProjectFlock && isResponseSuccess(filterProjectFlocksRawData)) {
const data = filterProjectFlocksRawData.data as unknown as ProjectFlock[];
const selectedProjectFlockData = data.find(
(pf) => pf.id === filterProjectFlock.value
);
@@ -301,7 +278,7 @@ const UniformityTable = () => {
}
return options;
}, [filterProjectFlock, filterProjectFlocksDataList]);
}, [filterProjectFlock, filterProjectFlocksRawData]);
// ===== PROJECT FLOCK KANDANG LOOKUP =====
const projectFlockKandangLookupUrl = useMemo(() => {
@@ -394,9 +371,13 @@ const UniformityTable = () => {
// ===== FILTER HANDLERS =====
const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
setFilterLocation(val as OptionType | null);
const location = val as OptionType | null;
setFilterLocation(location);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterProjectFlockLocationId(
location ? location.value.toString() : ''
);
},
[]
);
@@ -1206,6 +1187,7 @@ const UniformityTable = () => {
options={filterLocationOptions}
onInputChange={setFilterLocationInputValue}
isLoading={isLoadingFilterLocations}
onMenuScrollToBottom={loadMoreFilterLocations}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.location && (
@@ -1225,8 +1207,9 @@ const UniformityTable = () => {
setFilterErrors((prev) => ({ ...prev, project_flock: '' }));
}}
options={filterProjectFlockOptions}
onInputChange={setProjectFlockSearchValue}
onInputChange={setFilterProjectFlockSearchValue}
isLoading={isLoadingFilterProjectFlocks}
onMenuScrollToBottom={loadMoreFilterProjectFlocks}
isDisabled={!filterLocation}
className={{ wrapper: 'w-full' }}
/>
@@ -1,4 +1,4 @@
import Badge from '../../../../Badge';
import Badge from '@/components/Badge';
import Card from '@/components/Card';
import { Icon } from '@iconify/react';
import { formatNumber } from '@/lib/helper';
@@ -36,7 +36,10 @@ import {
VerifyUniformityPayload,
} from '@/types/api/production/uniformity';
import { type BaseApiResponse } from '@/types/api/api-general';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
import {
ProjectFlockKandangLookup,
ProjectFlock,
} from '@/types/api/production/project-flock';
import { Kandang } from '@/types/api/master-data/kandang';
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
@@ -88,7 +91,9 @@ const UniformityForm = ({
null
);
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState('');
const [selectedProjectFlockLocationId, setSelectedProjectFlockLocationId] =
useState<string>('');
const [selectedProjectFlock, setSelectedProjectFlock] =
useState<OptionType | null>(null);
@@ -100,50 +105,21 @@ const UniformityForm = ({
setInputValue: setLocationSelectInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
page: '1',
limit: '100',
loadMore: loadMoreLocations,
hasMore: hasMoreLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setProjectFlockSearchValue,
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlocks,
loadMore: loadMoreProjectFlocks,
hasMore: hasMoreProjectFlocks,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
location_id: selectedProjectFlockLocationId,
});
// ===== FETCH PROJECT FLOCKS DATA =====
const projectFlocksUrl = useMemo(() => {
const params = new URLSearchParams({
search: projectFlockSearchValue || '',
page: '1',
limit: '100',
});
if (selectedLocation) {
params.append('location_id', selectedLocation.value.toString());
}
return `${ProjectFlockApi.basePath}?${params.toString()}`;
}, [projectFlockSearchValue, selectedLocation]);
const { data: projectFlocksData, isLoading: isLoadingProjectFlocks } = useSWR(
projectFlocksUrl,
ProjectFlockApi.getAllFetcher
);
const projectFlocksDataList =
projectFlocksData?.status === 'success'
? projectFlocksData.data
: undefined;
// ===== PROJECT FLOCK OPTIONS =====
const projectFlockOptions = useMemo(() => {
let options: OptionType[] = [];
if (isResponseSuccess(projectFlocksData)) {
const flockOptions =
projectFlocksData?.data.map((projectFlock) => ({
value: projectFlock.id,
label: projectFlock.flock_name || '',
})) || [];
options = options.concat(flockOptions);
}
return options;
}, [projectFlocksData]);
// ===== APPROVED PROJECT FLOCK KANDANGS =====
const approvedProjectFlockKandangsUrl = useMemo(() => {
const params = new URLSearchParams({
@@ -168,8 +144,9 @@ const UniformityForm = ({
const kandangOptions = useMemo(() => {
let options: OptionType[] = [];
if (selectedProjectFlock && projectFlocksDataList) {
const selectedProjectFlockData = projectFlocksDataList.find(
if (selectedProjectFlock && isResponseSuccess(projectFlocksRawData)) {
const data = projectFlocksRawData.data as unknown as ProjectFlock[];
const selectedProjectFlockData = data.find(
(pf) => pf.id === selectedProjectFlock.value
);
@@ -196,7 +173,7 @@ const UniformityForm = ({
return options;
}, [
selectedProjectFlock,
projectFlocksDataList,
projectFlocksRawData,
approvedProjectFlockKandangs,
formType,
]);
@@ -313,6 +290,10 @@ const UniformityForm = ({
formik.setFieldValue('location_id', locationId);
setSelectedLocation(location);
setSelectedProjectFlock(null);
setSelectedProjectFlockLocationId(
location ? location.value.toString() : ''
);
},
[]
);
@@ -513,6 +494,7 @@ const UniformityForm = ({
options={locationOptions}
onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations}
onMenuScrollToBottom={loadMoreLocations}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
@@ -530,6 +512,7 @@ const UniformityForm = ({
options={projectFlockOptions}
onInputChange={setProjectFlockSearchValue}
isLoading={isLoadingProjectFlocks}
onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!formik.values.location_id}
isError={
formik.touched.project_flock_id &&
@@ -156,8 +156,11 @@ const PurchaseOrderAcceptApprovalForm = ({
setInputValue: setExpeditionsSelectInputValue,
options: expeditionVendors,
isLoadingOptions: isLoadingExpeditions,
loadMore: loadMoreExpeditions,
hasMore: hasMoreExpeditions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'BOP',
flag: 'EKSPEDISI',
});
// ===== FORM CONFIGURATION =====
@@ -183,8 +186,8 @@ const PurchaseOrderAcceptApprovalForm = ({
purchase_item_id: formItem.purchase_item_id || 0,
received_date: formItem.received_date || '',
travel_number: formItem.travel_number || '',
vehicle_number: formItem.vehicle_number || '',
expedition_vendor_id: formItem.expedition_vendor_id || 0,
vehicle_number: formItem.vehicle_number || null,
expedition_vendor_id: formItem.expedition_vendor_id || null,
received_qty:
typeof formItem.received_qty === 'string'
? parseFloat(formItem.received_qty) || 0
@@ -192,10 +195,13 @@ const PurchaseOrderAcceptApprovalForm = ({
transport_per_item:
typeof formItem.transport_per_item === 'string'
? parseFloat(formItem.transport_per_item) || 0
: formItem.transport_per_item || 0,
: formItem.transport_per_item || null,
};
}) || [],
travel_documents: values.travel_documents || [],
travel_documents:
values.travel_documents
?.filter((file): file is File => file instanceof File)
.filter(Boolean) || undefined,
};
switch (type) {
@@ -403,22 +409,13 @@ const PurchaseOrderAcceptApprovalForm = ({
Dokumen Surat Jalan
<span className='text-error'>*</span>
</th>
<th>
Nomor Kendaraan
<span className='text-error'>*</span>
</th>
<th>
Vendor Ekspedisi
<span className='text-error'>*</span>
</th>
<th>Nomor Kendaraan</th>
<th>Vendor Ekspedisi</th>
<th>
Jumlah Diterima
<span className='text-error'>*</span>
</th>
<th>
Transport/Item
<span className='text-error'>*</span>
</th>
<th>Transport/Item</th>
</tr>
</thead>
<tbody>
@@ -536,7 +533,6 @@ const PurchaseOrderAcceptApprovalForm = ({
</td>
<td>
<TextInput
required
name={`items.${idx}.vehicle_number`}
type='text'
value={formItem?.vehicle_number || ''}
@@ -562,7 +558,6 @@ const PurchaseOrderAcceptApprovalForm = ({
</td>
<td>
<SelectInput
required
isClearable={true}
value={formItem?.expedition_vendor}
key={`expedition-vendor-${idx}`}
@@ -570,6 +565,8 @@ const PurchaseOrderAcceptApprovalForm = ({
expeditionVendorChangeHandler(idx, val)
}
options={getExpeditionVendorOptions()}
isLoading={isLoadingExpeditions}
onMenuScrollToBottom={loadMoreExpeditions}
isError={
isRepeaterInputError(idx, 'expedition_vendor_id')
.isError
@@ -629,7 +626,6 @@ const PurchaseOrderAcceptApprovalForm = ({
</td>
<td>
<NumberInput
required
name={`items.${idx}.transport_per_item`}
value={formItem?.transport_per_item || ''}
onChange={(e) =>
@@ -680,7 +676,6 @@ const PurchaseOrderAcceptApprovalForm = ({
<div className={'col-span-2 my-2'}>
<FileInput
required
name='travel_documents'
label='Dokumen Surat Jalan'
accept='.pdf,.jpg,.jpeg,.png'
@@ -38,16 +38,16 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
purchase_item_id: number;
received_date: string;
travel_number: string;
vehicle_number: string;
vehicle_number?: string | null;
expedition_vendor?: {
value: number;
label: string;
} | null;
expedition_vendor_id: number;
expedition_vendor_id?: number | null;
received_qty: number | string;
transport_per_item: number | string;
transport_per_item?: number | string | null;
}[];
travel_documents: File[];
travel_documents?: (File | null | undefined)[] | null;
};
export type PurchaseStaffApprovalItemSchema = {
@@ -75,14 +75,14 @@ export type PurchaseAcceptApprovalItemSchema = {
purchase_item_id: number;
received_date: string;
travel_number: string;
vehicle_number: string;
vehicle_number?: string | null;
expedition_vendor?: {
value: number;
label: string;
} | null;
expedition_vendor_id: number;
expedition_vendor_id?: number | null;
received_qty: number | string;
transport_per_item: number | string;
transport_per_item?: number | string | null;
};
export type PurchaseDeleteItemsSchema = {
@@ -184,24 +184,19 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
.required('No. Surat jalan wajib diisi!')
.typeError('No. Surat jalan wajib diisi!'),
vehicle_number: Yup.string()
.required('Nomor kendaraan wajib diisi!')
.typeError('Nomor kendaraan wajib diisi!'),
.nullable()
.optional()
.typeError('Nomor kendaraan harus berupa plat nomor!'),
expedition_vendor: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
})
.nullable()
.optional(),
expedition_vendor_id: Yup.number()
.min(1, 'Vendor ekspedisi wajib diisi!')
.required('Vendor ekspedisi wajib diisi!')
.test(
'is-valid-expedition-vendor',
'Vendor ekspedisi harus dipilih!',
function (value) {
if (!this.parent.expedition_vendor) return true;
return Boolean(value && value > 0);
}
)
.typeError('Vendor ekspedisi harus dipilih!'),
.nullable()
.optional()
.typeError('Vendor ekspedisi harus berupa angka!'),
received_qty: Yup.mixed<string | number>()
.required('Jumlah diterima wajib diisi!')
.test(
@@ -217,13 +212,14 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
)
.typeError('Jumlah diterima harus berupa angka!'),
transport_per_item: Yup.mixed<string | number>()
.required('Biaya transport per item wajib diisi!')
.nullable()
.optional()
.test(
'is-valid-transport-per-item',
'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!',
function (value) {
if (value === '' || value === null || value === undefined)
return false;
return true;
const numValue =
typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0;
@@ -389,16 +385,17 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
travel_documents: Yup.array()
.of(
Yup.mixed<File>()
.required('Dokumen surat jalan wajib diupload!')
.nullable()
.optional()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true;
})
)
.required('Dokumen surat jalan wajib diupload!')
.min(1, 'Minimal upload 1 dokumen surat jalan!')
.typeError('Dokumen surat jalan wajib diupload!'),
.nullable()
.optional()
.typeError('Dokumen surat jalan harus berupa array!'),
});
export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType =
@@ -633,8 +633,18 @@ const PurchaseOrderStaffApprovalForm = ({
formik.setFieldValue(`items.${idx}.qty`, numValue);
formik.setFieldValue(`items.${idx}.price`, '');
formik.setFieldValue(`items.${idx}.total_price`, '');
if (
formItem.price !== '' &&
formItem.price !== undefined &&
formItem.price !== null &&
numValue !== '' &&
numValue > 0
) {
const calculatedTotal = Number(formItem.price) * Number(numValue);
formik.setFieldValue(`items.${idx}.total_price`, calculatedTotal);
} else if (numValue === '') {
formik.setFieldValue(`items.${idx}.total_price`, '');
}
}
if (field === 'price' || field === 'total_price') {
@@ -1184,8 +1194,10 @@ const PurchaseOrderStaffApprovalForm = ({
color='warning'
className='px-4'
onClick={() => {
formik.setValues(formikInitialValues);
formik.resetForm();
if (type === 'add') {
formik.setValues(formikInitialValues);
formik.resetForm();
}
setPurchaseOrderFormErrorMessage('');
onCancel?.();
onModalClose?.();
@@ -63,11 +63,9 @@ const PurchaseRequestForm = ({
useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ===== TYPE DEFINITIONS =====
interface ProductOptionType {
value: number;
label: string;
}
const [selectedArea, setSelectedArea] = useState('');
const [selectedLocation, setSelectedLocation] = useState('');
const [disabledLocation, setDisabledLocation] = useState(true);
// ===== UTILITY FUNCTIONS =====
const isRepeaterInputError = (
@@ -160,11 +158,35 @@ const PurchaseRequestForm = ({
isLoadingOptions: isLoadingAreas,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const {
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
hasMore: hasMoreLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
});
const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
loadMore: loadMoreWarehouses,
hasMore: hasMoreWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search', {
area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
location_id:
selectedLocation != ''
? selectedLocation
: ((initialValues?.location?.id ?? '') as string),
});
// ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<PurchaseRequestFormValues>(
@@ -267,70 +289,6 @@ const PurchaseRequestForm = ({
return data;
}, [supplierData]);
const locationsUrl = useMemo(() => {
const params = new URLSearchParams({
search: locationSelectInputValue,
...(formik.values.area_id && formik.values.area_id > 0
? { area_id: formik.values.area_id.toString() }
: {}),
});
return `${LocationApi.basePath}?${params.toString()}`;
}, [locationSelectInputValue, formik.values.area_id]);
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = useMemo(() => {
if (!isResponseSuccess(locations)) return [];
return (
locations?.data.map((location) => ({
value: location.id,
label: location.name,
})) || []
);
}, [locations]);
const warehousesUrl = useMemo(() => {
const params = new URLSearchParams({ search: warehouseSelectInputValue });
if (formik.values.area_id && formik.values.area_id > 0) {
params.append('area_id', formik.values.area_id.toString());
}
if (formik.values.location_id && formik.values.location_id > 0) {
params.append('location_id', formik.values.location_id.toString());
}
return `${WarehouseApi.basePath}?${params.toString()}`;
}, [
warehouseSelectInputValue,
formik.values.area_id,
formik.values.location_id,
]);
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
const warehouseOptions = useMemo(() => {
if (!isResponseSuccess(warehouses)) return [];
return (
warehouses?.data.map((w) => ({
value: w.id,
label: w.name,
area: w.area?.name,
location:
'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG')
? w.location?.name
: undefined,
})) || []
);
}, [warehouses]);
const addPurchaseItem = () => {
const newItems = [
...(formik.values.items || []),
@@ -407,6 +365,18 @@ const PurchaseRequestForm = ({
}
}, [formik.values.supplier_id]);
useEffect(() => {
if (type !== 'add' && initialValues) {
if (initialValues.area?.id) {
setSelectedArea(initialValues.area.id.toString());
setDisabledLocation(false);
}
if (initialValues.location?.id) {
setSelectedLocation(initialValues.location.id.toString());
}
}
}, [type, initialValues]);
// ===== FORM HANDLERS =====
const handleSupplierChange = useCallback(
(val: OptionType | OptionType[] | null) => {
@@ -445,6 +415,16 @@ const PurchaseRequestForm = ({
formik.setFieldValue('area_id', (area as OptionType)?.value || 0);
formik.setFieldTouched('area', true);
formik.setFieldValue('area', area);
setSelectedArea((area as OptionType)?.value as string);
setSelectedLocation('');
const disabled = (area as OptionType)?.value == null;
setDisabledLocation(disabled);
formik.setFieldTouched('location_id', false);
formik.setFieldValue('location_id', 0);
formik.setFieldTouched('location', false);
formik.setFieldValue('location', null);
},
[]
);
@@ -456,6 +436,8 @@ const PurchaseRequestForm = ({
formik.setFieldValue('location_id', (location as OptionType)?.value || 0);
formik.setFieldTouched('location', true);
formik.setFieldValue('location', location);
setSelectedLocation((location as OptionType)?.value as string);
},
[]
);
@@ -596,10 +578,15 @@ const PurchaseRequestForm = ({
placeholder='Pilih Lokasi...'
value={formik.values.location}
onChange={handleLocationChange}
options={locationOptions}
options={
selectedArea != '' || initialValues?.area?.id
? locationOptions
: []
}
onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations}
isDisabled={type === 'detail'}
onMenuScrollToBottom={loadMoreLocations}
isDisabled={type === 'detail' || disabledLocation}
isClearable={type !== 'detail'}
/>
@@ -713,6 +700,7 @@ const PurchaseRequestForm = ({
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses}
onMenuScrollToBottom={loadMoreWarehouses}
isError={
isRepeaterInputError(idx, 'warehouse_id').isError
}
@@ -732,9 +720,9 @@ const PurchaseRequestForm = ({
required
value={item.product ?? undefined}
onChange={(val) => {
const product = val as ProductOptionType | null;
const product = val as OptionType | null;
const productId =
(product as ProductOptionType)?.value || 0;
(product as OptionType)?.value || 0;
formik.setFieldTouched(
`items.${idx}.product`,
@@ -540,31 +540,6 @@ const PurchaseOrderDetail = ({
accessorKey: 'travel_number',
cell: (props) => props.row.original.travel_number || '-',
},
{
header: 'Dokumen Surat Jalan',
accessorKey: 'travel_document_path',
cell: (props) => {
const documentPath = props.row.original.travel_document_path;
return documentPath ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm'
href={documentPath}
target='_blank'
rel='noopener noreferrer'
>
<Icon
icon='material-symbols:file-open-outline'
width={16}
height={16}
/>
Lihat Dokumen
</Button>
) : (
'-'
);
},
},
{
header: 'No. Armada Pengangkut',
accessorKey: 'vehicle_number',
@@ -588,7 +563,10 @@ const PurchaseOrderDetail = ({
{
header: 'Transport /Item',
accessorKey: 'transport_per_item',
cell: (props) => formatCurrency(props.getValue() as number),
cell: (props) => {
const value = props.row.original.transport_per_item;
return value ? formatCurrency(value) : formatCurrency(0);
},
},
];
@@ -723,8 +701,8 @@ const PurchaseOrderDetail = ({
</span>
<span className='text-gray-900 ml-3 break-all'>
:{' '}
{purchaseData.items?.[0]?.warehouse?.type === 'LOKASI' &&
purchaseData.items?.[0]?.warehouse?.location?.name
{purchaseData.items?.[0]?.warehouse &&
'location' in purchaseData.items[0].warehouse
? purchaseData.items[0].warehouse.location.name
: '-'}
</span>
@@ -905,11 +883,29 @@ const PurchaseOrderDetail = ({
Informasi Penerimaan Barang
</h3>
{canShowPenerimaanBarang && (
<RowDropdownOptions isLast2Rows>
<PenerimaanBarangDropdown
onEdit={penerimaanBarangModal.openModal}
/>
</RowDropdownOptions>
<div className='flex items-center gap-2'>
{goodsReceiptItems[0]?.travel_document_path && (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 p-1.5 text-sm'
href={goodsReceiptItems[0].travel_document_path}
target='_blank'
rel='noopener noreferrer'
>
<Icon
icon='material-symbols:file-open-outline'
width={16}
height={16}
/>
Lihat Dokumen
</Button>
)}
<RowDropdownOptions isLast2Rows>
<PenerimaanBarangDropdown
onEdit={penerimaanBarangModal.openModal}
/>
</RowDropdownOptions>
</div>
)}
</div>
<div className='overflow-x-auto'>
@@ -324,12 +324,14 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
PT LUMBUNG TELUR INDONESIA
</Text>
<Text>
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI'
{purchaseData?.items?.[0]?.warehouse &&
'location' in purchaseData.items[0].warehouse
? purchaseData.items[0].warehouse.location.name
: '-'}
</Text>
<Text>
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI'
{purchaseData?.items?.[0]?.warehouse &&
'location' in purchaseData.items[0].warehouse
? purchaseData.items[0].warehouse.location.address
: '-'}
</Text>
@@ -434,7 +436,7 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
</View>
<View style={pdfStyles.tableCell}>
<Text>
{item.warehouse?.type === 'LOKASI'
{item.warehouse && 'location' in item.warehouse
? item.warehouse.location.address
: '-'}
</Text>
@@ -87,6 +87,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -101,6 +102,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -118,6 +120,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -135,6 +138,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setCustomerInputValue,
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
loadMore: loadMoreCustomers,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -298,6 +302,7 @@ const DailyMarketingReportContent = () => {
value={selectedArea}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -312,6 +317,7 @@ const DailyMarketingReportContent = () => {
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -326,6 +332,7 @@ const DailyMarketingReportContent = () => {
value={selectedWarehouse}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -340,6 +347,7 @@ const DailyMarketingReportContent = () => {
value={selectedCustomer}
onChange={customerChangeHandler}
onInputChange={setCustomerInputValue}
onMenuScrollToBottom={loadMoreCustomers}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -168,7 +168,7 @@ const DailyMarketingsTable = ({
];
useEffect(() => {
console.log({ sorting });
// console.log({ sorting });
if (sorting.length === 1) {
onFilterByChange(sorting[0].id);
@@ -26,6 +26,15 @@ 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 =====
@@ -64,16 +73,33 @@ const ReportExpenseTable = () => {
});
// ===== SELECT OPTIONS =====
const { options: optionsLocation, isLoadingOptions: isLoadingLocation } =
useSelect(`/master-data/locations`, 'id', 'name');
const { options: optionsSupplier, isLoadingOptions: isLoadingSupplier } =
useSelect(`/master-data/suppliers`, 'id', 'name');
const { options: optionsKandang, isLoadingOptions: isLoadingKandang } =
useSelect(`/master-data/kandangs`, 'id', 'name', '', {
location_id: filterState.location_id,
});
const { options: optionsNonstock, isLoadingOptions: isLoadingNonstock } =
useSelect(`/master-data/nonstocks`, 'id', 'name');
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(
() => [
@@ -86,31 +112,31 @@ const ReportExpenseTable = () => {
// Mendapatkan value option select dari filter state
const selectedLocation = useMemo(
() =>
optionsLocation.find(
locationOptions.find(
(opt) => String(opt.value) === filterState.location_id
) || null,
[optionsLocation, filterState.location_id]
[locationOptions, filterState.location_id]
);
const selectedSupplier = useMemo(
() =>
optionsSupplier.find(
supplierOptions.find(
(opt) => String(opt.value) === filterState.supplier_id
) || null,
[optionsSupplier, filterState.supplier_id]
[supplierOptions, filterState.supplier_id]
);
const selectedKandang = useMemo(
() =>
optionsKandang.find(
kandangOptions.find(
(opt) => String(opt.value) === filterState.kandang_id
) || null,
[optionsKandang, filterState.kandang_id]
[kandangOptions, filterState.kandang_id]
);
const selectedNonstock = useMemo(
() =>
optionsNonstock.find(
nonstockOptions.find(
(opt) => String(opt.value) === filterState.nonstock_id
) || null,
[optionsNonstock, filterState.nonstock_id]
[nonstockOptions, filterState.nonstock_id]
);
const selectedCategory = useMemo(
() =>
@@ -756,38 +782,46 @@ const ReportExpenseTable = () => {
<SelectInput
isClearable
label='Lokasi'
options={optionsLocation}
isLoading={isLoadingLocation}
options={locationOptions}
isLoading={isLoadingLocationOptions}
placeholder='Lokasi'
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
/>
<SelectInput
isClearable
label='Kandang'
options={optionsKandang}
isLoading={isLoadingKandang}
options={kandangOptions}
isLoading={isLoadingKandangOptions}
placeholder='Kandang'
value={selectedKandang}
onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
/>
<SelectInput
isClearable
label='Supplier'
options={optionsSupplier}
isLoading={isLoadingSupplier}
options={supplierOptions}
isLoading={isLoadingSupplierOptions}
placeholder='Supplier'
value={selectedSupplier}
onChange={supplierChangeHandler}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
/>
<SelectInput
isClearable
label='Produk'
options={optionsNonstock}
isLoading={isLoadingNonstock}
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
placeholder='Produk'
value={selectedNonstock}
onChange={nonstockChangeHandler}
onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
/>
<SelectInput
isClearable
@@ -177,10 +177,12 @@ interface CustomerPaymentExportPDFParams {
data: CustomerPaymentReport[];
params?: {
customer_name?: string;
sales?: string;
// TODO: Uncomment when BE is ready
// sales?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
// TODO: Uncomment when BE is ready
// filter_by?: string;
};
}
@@ -195,9 +197,10 @@ const getParameterText = (
paramsText.push('Semua Customer');
}
if (params?.sales) {
paramsText.push(`Sales: ${params.sales}`);
}
// TODO: Uncomment when BE is ready
// if (params?.sales) {
// paramsText.push(`Sales: ${params.sales}`);
// }
if (params?.start_date && params?.end_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
@@ -242,9 +245,10 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
: '-'}
</Text>
</View>
<View style={pdfStyles.parameterBadge}>
{/* TODO: Uncomment when BE is ready */}
{/* <View style={pdfStyles.parameterBadge}>
<Text>Filter Tanggal: Tanggal DO</Text>
</View>
</View> */}
<View style={pdfStyles.parameterBadge}>
<Text>
Customer: {params.params?.customer_name || 'Semua Customer'}
@@ -280,7 +284,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<View style={[pdfStyles.tableCellHeader, { flex: 0.8 }]}>
<Text>Aging</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Referensi</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
@@ -296,17 +300,11 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>Rata-Rata</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Awal</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>CN</Text>
<Text>Harga/Unit</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Akhir</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Pajak</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Total</Text>
</View>
@@ -343,13 +341,15 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>
{item.do_date ? formatDate(item.do_date, 'DD MMM YY') : '-'}
{item.trans_date
? formatDate(item.trans_date, 'DD MMM YY')
: '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>
{item.realization_date
? formatDate(item.realization_date, 'DD MMM YY')
{item.delivery_date
? formatDate(item.delivery_date, 'DD MMM YY')
: '-'}
</Text>
</View>
@@ -358,11 +358,15 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
{item.aging_day ? formatNumber(item.aging_day) : '-'} hari
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.reference || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>{item.vehicle_plate || '-'}</Text>
<Text>
{Array.isArray(item.vehicle_numbers)
? item.vehicle_numbers.join(', ')
: item.vehicle_numbers || '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.qty)}</Text>
@@ -374,22 +378,16 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>{formatNumber(item.average_weight)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.price)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(item.credit_note)}</Text>
<Text>{formatCurrency(item.unit_price)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.final_price)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.ppn)}%</Text>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.total_price)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.total)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.payment)}</Text>
<Text>{formatCurrency(item.payment_amount)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text style={pdfStyles.textError}>
@@ -397,30 +395,32 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
{item.notes ? (
<Text>{item.notes}</Text>
) : (
{item.status ? (
<View
style={[
pdfStyles.badge,
item.accounts_receivable === 0
item.status === 'LUNAS'
? pdfStyles.badgeLunas
: pdfStyles.badgeBelumLunas,
]}
>
<Text>
{item.accounts_receivable === 0
? 'Lunas'
: 'Belum Lunas'}
{item.status === 'LUNAS' ? 'Lunas' : 'Belum Lunas'}
</Text>
</View>
) : (
<Text>-</Text>
)}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.pickup_info || '-'}</Text>
<Text>
{Array.isArray(item.pickup_info)
? item.pickup_info.join(', ')
: item.pickup_info || '-'}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.sales_marketing || '-'}</Text>
<Text>{item.sales_person || '-'}</Text>
</View>
</View>
))}
@@ -440,7 +440,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<View style={[pdfStyles.tableCell, { flex: 0.8 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
@@ -458,25 +458,13 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(
customerReport.summary.total_initial_amount
)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatCurrency(customerReport.summary.total_credit_note)}
</Text>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(customerReport.summary.total_final_amount)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(customerReport.summary.total_grand_amount)}
@@ -24,30 +24,30 @@ export const generateCustomerPaymentExcel = (
const excelData: { [key: string]: string | number }[] = customerData.map(
(item, index) => ({
No: index + 1,
'Tanggal DO/Bayar': item.do_date
? formatDate(item.do_date, 'DD MMM YYYY')
'Tanggal DO/Bayar': item.trans_date
? formatDate(item.trans_date, 'DD MMM YYYY')
: '',
'Tanggal Realisasi': item.realization_date
? formatDate(item.realization_date, 'DD MMM YYYY')
'Tanggal Realisasi': item.delivery_date
? formatDate(item.delivery_date, 'DD MMM YYYY')
: '',
Aging: formatNumber(item.aging_day || 0),
Referensi: item.reference || '',
'Nomor Polisi': Array.isArray(item.vehicle_plate)
? item.vehicle_plate.join(', ')
'Nomor Polisi': Array.isArray(item.vehicle_numbers)
? item.vehicle_numbers.join(', ')
: '',
'Ekor/Qty': formatNumber(item.qty || 0),
'Berat (Kg)': formatNumber(item.weight || 0),
AVG: formatNumber(item.average_weight || 0),
'Harga Awal': formatCurrency(item.price || 0),
CN: formatCurrency(item.credit_note || 0),
'Harga/Unit': formatCurrency(item.unit_price || 0),
'Harga Akhir': formatCurrency(item.final_price || 0),
'PPN (%)': formatNumber(item.ppn || 0),
Total: formatCurrency(item.total || 0),
Pembayaran: formatCurrency(item.payment || 0),
Total: formatCurrency(item.total_price || 0),
Pembayaran: formatCurrency(item.payment_amount || 0),
'Saldo Piutang': formatCurrency(item.accounts_receivable || 0),
Keterangan: item.notes || '',
Pengambilan: item.pickup_info || '',
'Sales/Marketing': item.sales_marketing || '',
Keterangan: item.status || '',
Pengambilan: Array.isArray(item.pickup_info)
? item.pickup_info.join(', ')
: '',
'Sales/Marketing': item.sales_person || '',
})
);
@@ -62,14 +62,10 @@ export const generateCustomerPaymentExcel = (
'Ekor/Qty': formatNumber(customerReport.summary.total_qty || 0),
'Berat (Kg)': formatNumber(customerReport.summary.total_weight || 0),
AVG: '',
'Harga Awal': formatCurrency(
customerReport.summary.total_initial_amount || 0
),
CN: formatCurrency(customerReport.summary.total_credit_note || 0),
'Harga/Unit': '',
'Harga Akhir': formatCurrency(
customerReport.summary.total_final_amount || 0
),
'PPN (%)': '',
Total: formatCurrency(customerReport.summary.total_grand_amount || 0),
Pembayaran: formatCurrency(customerReport.summary.total_payment || 0),
'Saldo Piutang': formatCurrency(
@@ -93,10 +89,8 @@ export const generateCustomerPaymentExcel = (
{ wch: 10 }, // Ekor/Qty
{ wch: 12 }, // Berat
{ wch: 10 }, // AVG
{ wch: 15 }, // Harga Awal
{ wch: 10 }, // CN
{ wch: 15 }, // Harga/Unit
{ wch: 15 }, // Harga Akhir
{ wch: 10 }, // PPN
{ wch: 15 }, // Total
{ wch: 15 }, // Pembayaran
{ wch: 15 }, // Saldo Piutang
@@ -187,10 +187,30 @@ const pdfStyles = StyleSheet.create({
textAlign: 'center',
whiteSpace: 'nowrap',
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface DebtSupplierExportPDFParams {
data: DebtSupplier[];
params?: {
supplier_name?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
};
}
const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
@@ -208,9 +228,50 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<Text style={pdfStyles.mainTitle}>
Laporan &gt; Rekapitulasi Hutang ke Supplier
</Text>
<View style={pdfStyles.parameterContainer}>
<View style={pdfStyles.parameterBadge}>
<Text>
Periode:{' '}
{params.params?.start_date
? formatDate(params.params.start_date, 'DD MMM YYYY')
: '-'}{' '}
s.d{' '}
{params.params?.end_date
? formatDate(params.params.end_date, 'DD MMM YYYY')
: '-'}
</Text>
</View>
{params.params?.filter_by && (
<View style={pdfStyles.parameterBadge}>
<Text>
Filter Tanggal:{' '}
{params.params.filter_by === 'po_date'
? 'Tanggal PO'
: params.params.filter_by === 'received_date'
? 'Tanggal Terima'
: params.params.filter_by === 'due_date'
? 'Tanggal Jatuh Tempo'
: params.params.filter_by}
</Text>
</View>
)}
<View style={pdfStyles.parameterBadge}>
<Text>
Supplier: {params.params?.supplier_name || 'Semua Supplier'}
</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</Text>
</View>
</View>
<Text style={pdfStyles.supplierTitle}>
{supplierReport.supplier.name}
</Text>
<Text style={pdfStyles.supplierInfo}>
{supplierReport.supplier.category}
</Text>
</View>
{/* Table */}
@@ -94,18 +94,18 @@ export const generateDebtSupplierExcel = (
const colWidths = [
{ wch: 5 }, // No
{ wch: 15 }, // Nomor PR
{ wch: 15 }, // Nomor PO
{ wch: 15 }, // Tanggal Terima/Bayar
{ wch: 15 }, // Tanggal PO
{ wch: 12 }, // Aging
{ wch: 10 }, // Nomor PR
{ wch: 10 }, // Nomor PO
{ wch: 20 }, // Tanggal Terima/Bayar
{ wch: 10 }, // Tanggal PO
{ wch: 10 }, // Aging
{ wch: 15 }, // Area
{ wch: 15 }, // Gudang
{ wch: 18 }, // Jatuh Tempo
{ wch: 18 }, // Status Jatuh Tempo
{ wch: 15 }, // Nominal Pembelian (Rp)
{ wch: 12 }, // Jatuh Tempo
{ wch: 20 }, // Status Jatuh Tempo
{ wch: 20 }, // Nominal Pembelian (Rp)
{ wch: 15 }, // Pembayaran (Rp)
{ wch: 15 }, // Sisa Saldo Hutang (Rp)
{ wch: 20 }, // Sisa Saldo Hutang (Rp)
{ wch: 12 }, // Status
{ wch: 15 }, // Nomor Perjalanan
];
@@ -47,6 +47,8 @@ const CustomerPaymentTab = () => {
const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>(
[]
);
// TODO: Uncomment when BE is ready
// const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
@@ -55,13 +57,16 @@ const CustomerPaymentTab = () => {
const {
options: customerOptions,
setInputValue: setCustomerInputValue,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
hasMore: hasMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// TODO: Uncomment when BE is ready
const {
options: salesOptions,
setInputValue: setSalesInputValue,
isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales,
hasMore: hasMoreSales,
@@ -101,7 +106,11 @@ const CustomerPaymentTab = () => {
};
const getPaymentStatusText = (notes: string) => {
return notes;
return notes
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
// ===== FILTER HANDLERS =====
@@ -133,23 +142,18 @@ const CustomerPaymentTab = () => {
count += 1;
}
// Sales filter
if (filterSales.length > 0) {
count += 1;
}
// Filter by (always count if submitted)
if (isSubmitted) {
count += 1;
}
// TODO: Uncomment when BE is ready
// // Sales filter
// if (filterSales.length > 0) {
// count += 1;
// }
return count;
}, [
filterStartDate,
filterEndDate,
filterCustomer,
filterSales,
isSubmitted,
// filterSales,
]);
const hasFilters = activeFiltersCount > 0;
@@ -159,15 +163,16 @@ const CustomerPaymentTab = () => {
isSubmitted
? () => {
const params = {
customer_id:
customer_ids:
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
sales_id:
filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',')
: undefined,
filter_by: 'do_date' as const,
// TODO: Uncomment when BE is ready
// sales_id:
// filterSales.length > 0
// ? filterSales.map((v) => String(v.value)).join(',')
// : undefined,
// filter_by: 'do_date' as const,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
page: currentPage,
@@ -179,9 +184,9 @@ const CustomerPaymentTab = () => {
: null,
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales_id,
params.filter_by,
params.customer_ids,
undefined, // TODO: Change to params.sales_id when BE is ready
undefined, // TODO: Change to params.filter_by when BE is ready
params.start_date,
params.end_date,
params.page,
@@ -202,15 +207,15 @@ const CustomerPaymentTab = () => {
CustomerPaymentReport[] | null
> => {
const params = {
customer_id:
customer_ids:
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
sales_id:
filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',')
: undefined,
filter_by: 'do_date' as const,
// TODO: Uncomment when BE is ready
// sales_id:
// filterSales.length > 0
// ? filterSales.map((v) => String(v.value)).join(',')
// : undefined,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
limit: 100,
@@ -218,9 +223,9 @@ const CustomerPaymentTab = () => {
};
const response = await FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales_id,
params.filter_by,
params.customer_ids,
undefined, // TODO: Change to params.sales_id when BE is ready
undefined, // TODO: Change to params.filter_by when BE is ready
params.start_date,
params.end_date,
params.page,
@@ -277,13 +282,15 @@ const CustomerPaymentTab = () => {
filterCustomer.length > 0
? filterCustomer.map((c) => c.label).join(', ')
: undefined,
sales:
filterSales.length > 0
? filterSales.map((s) => s.label).join(', ')
: undefined,
// TODO: Uncomment when BE is ready
// sales:
// filterSales.length > 0
// ? filterSales.map((s) => s.label).join(', ')
// : undefined,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
filter_by: 'do_date',
// TODO: Uncomment when BE is ready
// filter_by: 'do_date' as const,
},
});
toast.success('PDF berhasil dibuat dan diunduh.');
@@ -301,36 +308,41 @@ const CustomerPaymentTab = () => {
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
cell: (props) => props.row.index,
footer: () => <div className='font-semibold text-gray-900'>Total</div>,
},
{
id: 'do_date_or_payment_date',
header: 'Tanggal DO/Bayar',
accessorKey: 'do_date',
header: 'Tanggal Jual/Bayar',
accessorKey: 'trans_date',
enableSorting: false,
cell: (props) => {
const value = props.row.original.do_date;
return formatDate(value, 'DD MMM YYYY');
const value = props.row.original.trans_date;
return value ? formatDate(value, 'DD MMM YYYY') : '-';
},
},
{
id: 'realization_date',
header: 'Tanggal Realisasi',
accessorKey: 'realization_date',
accessorKey: 'delivery_date',
enableSorting: false,
cell: (props) => {
const value = props.row.original.realization_date;
return formatDate(value, 'DD MMM YYYY');
const value = props.row.original.delivery_date;
return value ? formatDate(value, 'DD MMM YYYY') : '-';
},
},
{
id: 'aging',
header: 'Aging',
accessorKey: 'aging_day',
enableSorting: false,
cell: (props) => {
const value = props.row.original.aging_day;
return (
<div className='text-center'>
{value ? formatNumber(value) : '-'} hari
{value !== null && value !== undefined
? `${formatNumber(value)} hari`
: '-'}
</div>
);
},
@@ -339,6 +351,7 @@ const CustomerPaymentTab = () => {
id: 'reference',
header: 'Referensi',
accessorKey: 'reference',
enableSorting: false,
cell: (props) => {
const value = props.row.original.reference;
return value || '-';
@@ -347,16 +360,18 @@ const CustomerPaymentTab = () => {
{
id: 'vehicle_plate',
header: 'Nomor Polisi',
accessorKey: 'vehicle_plate',
accessorKey: 'vehicle_numbers',
enableSorting: false,
cell: (props) => {
const value = props.row.original.vehicle_plate;
return value || '-';
const value = props.row.original.vehicle_numbers;
return Array.isArray(value) ? value.join(', ') : value || '-';
},
},
{
id: 'qty',
header: 'Ekor/Qty',
header: 'Qty',
accessorKey: 'qty',
enableSorting: false,
cell: (props) => {
const value = props.row.original.qty;
return <div className='text-right'>{formatNumber(value)}</div>;
@@ -371,6 +386,7 @@ const CustomerPaymentTab = () => {
id: 'weight',
header: 'Berat (Kg)',
accessorKey: 'weight',
enableSorting: false,
cell: (props) => {
const value = props.row.original.weight;
return <div className='text-right'>{formatNumber(value)}</div>;
@@ -385,6 +401,7 @@ const CustomerPaymentTab = () => {
id: 'average_weight',
header: 'AVG',
accessorKey: 'average_weight',
enableSorting: false,
cell: (props) => {
const value = props.row.original.average_weight;
return <div className='text-right'>{formatNumber(value)}</div>;
@@ -394,37 +411,23 @@ const CustomerPaymentTab = () => {
),
},
{
id: 'price',
header: 'Harga Awal',
accessorKey: 'price',
id: 'unit_price',
header: 'Harga/Unit',
accessorKey: 'unit_price',
enableSorting: false,
cell: (props) => {
const value = props.row.original.price;
const value = props.row.original.unit_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_initial_amount) || '-'}
</div>
),
},
{
id: 'credit_note',
header: 'CN',
accessorKey: 'credit_note',
cell: (props) => {
const value = props.row.original.credit_note;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_credit_note) || '-'}
</div>
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'final_price',
header: 'Harga Akhir',
accessorKey: 'final_price',
enableSorting: false,
cell: (props) => {
const value = props.row.original.final_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
@@ -435,24 +438,13 @@ const CustomerPaymentTab = () => {
</div>
),
},
{
id: 'ppn',
header: 'PPN (%)',
accessorKey: 'ppn',
cell: (props) => {
const value = props.row.original.ppn;
return <div className='text-right'>{formatNumber(value)}%</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'total',
header: 'Total',
accessorKey: 'total',
accessorKey: 'total_price',
enableSorting: false,
cell: (props) => {
const value = props.row.original.total;
const value = props.row.original.total_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
@@ -464,9 +456,10 @@ const CustomerPaymentTab = () => {
{
id: 'payment',
header: 'Pembayaran',
accessorKey: 'payment',
accessorKey: 'payment_amount',
enableSorting: false,
cell: (props) => {
const value = props.row.original.payment;
const value = props.row.original.payment_amount;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
@@ -479,14 +472,25 @@ const CustomerPaymentTab = () => {
id: 'accounts_receivable',
header: 'Saldo Piutang',
accessorKey: 'accounts_receivable',
enableSorting: false,
cell: (props) => {
const value = props.row.original.accounts_receivable;
return (
<div className='text-right text-error'>{formatCurrency(value)}</div>
<div
className={`text-right font-semibold ${
value < 0 ? 'text-error' : ''
}`}
>
{formatCurrency(value)}
</div>
);
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div
className={`text-right font-semibold ${
summary.total_accounts_receivable < 0 ? 'text-error' : ''
}`}
>
{formatCurrency(summary.total_accounts_receivable) || '-'}
</div>
),
@@ -494,9 +498,10 @@ const CustomerPaymentTab = () => {
{
id: 'notes',
header: 'Keterangan',
accessorKey: 'notes',
accessorKey: 'status',
enableSorting: false,
cell: (props) => {
const value = props.row.original.notes;
const value = props.row.original.status;
if (!value) {
return '-';
@@ -511,7 +516,7 @@ const CustomerPaymentTab = () => {
status: getPaymentStatusIndicatorColor(value),
}}
>
{getPaymentStatusText(value)}
<span className='capitalize'>{getPaymentStatusText(value)}</span>
</Badge>
);
},
@@ -520,17 +525,19 @@ const CustomerPaymentTab = () => {
id: 'pickup_info',
header: 'Pengambilan',
accessorKey: 'pickup_info',
enableSorting: false,
cell: (props) => {
const value = props.row.original.pickup_info;
return value || '-';
return Array.isArray(value) ? value.join(', ') : value || '-';
},
},
{
id: 'sales_marketing',
header: 'Sales/Marketing',
accessorKey: 'sales_marketing',
accessorKey: 'sales_person',
enableSorting: false,
cell: (props) => {
const value = props.row.original.sales_marketing;
const value = props.row.original.sales_person;
return value || '-';
},
},
@@ -654,6 +661,7 @@ const CustomerPaymentTab = () => {
Array.isArray(val) ? val : val ? [val] : []
);
}}
onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
@@ -661,7 +669,8 @@ const CustomerPaymentTab = () => {
/>
</div>
<div>
{/* TODO: Uncomment when BE is ready */}
{/* <div>
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
@@ -670,14 +679,16 @@ const CustomerPaymentTab = () => {
onChange={(val) => {
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
}}
onInputChange={setSalesInputValue}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
</div>
</div> */}
<div>
{/* TODO: Uncomment when BE is ready */}
{/* <div>
<SelectInput
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
@@ -686,7 +697,7 @@ const CustomerPaymentTab = () => {
isDisabled={true}
className={{ wrapper: 'w-full' }}
/>
</div>
</div> */}
</div>
{/* Action Buttons */}
@@ -726,10 +737,7 @@ const CustomerPaymentTab = () => {
const summary = customerReport.summary || {
total_qty: 0,
total_weight: 0,
total_initial_amount: 0,
total_credit_note: 0,
total_final_amount: 0,
total_ppn: 0,
total_grand_amount: 0,
total_payment: 0,
total_accounts_receivable: 0,
@@ -741,19 +749,27 @@ const CustomerPaymentTab = () => {
<Card
key={customerReport.customer.id}
title={customerReport.customer.name}
subtitle={`(${customerReport.customer.address})`}
className={{
wrapper: 'w-full rounded-2xl',
body: 'p-0',
title:
'py-1.5 px-3 bg-[#0069E0] text-white text-lg font-normal',
subtitle:
'px-3 pb-1 bg-[#0069E0] text-white text-sm font-normal',
}}
variant='bordered'
collapsible={true}
>
<Table
data={customerReport.rows}
data={[
{
accounts_receivable: customerReport.initial_balance,
} as CustomerPaymentReport['rows'][0],
...customerReport.rows,
]}
columns={tableColumns}
pageSize={10}
pageSize={customerReport.rows.length + 1}
renderFooter={customerReport.rows.length > 0}
className={{
containerClassName: 'w-full',
@@ -773,6 +789,36 @@ const CustomerPaymentTab = () => {
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
renderCustomRow={(row) => {
if (row.index === 0) {
return (
<tr
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
key={row.index}
>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={13}
></td>
<td className='px-4 py-3 text-xs whitespace-nowrap'>
<div
className={`text-right ${
row.original.accounts_receivable < 0
? 'text-error'
: ''
}`}
>
{formatCurrency(row.original.accounts_receivable)}
</div>
</td>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={4}
></td>
</tr>
);
}
}}
/>
</Card>
);
@@ -34,6 +34,7 @@ import {
import ButtonFilter from '@/components/helper/ButtonFilter';
import Badge from '@/components/Badge';
import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier';
const dueStatus: Record<string, Color> = {
'Sudah Jatuh Tempo': 'error',
@@ -89,10 +90,12 @@ const DebtSupplierTab = () => {
const filterModal = useModal();
const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } =
useSelect(SupplierApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const dataTypeOptions = useMemo(
() => [
@@ -247,7 +250,17 @@ const DebtSupplierTab = () => {
return;
}
await generateDebtSupplierPDF({ data: allDataForExport });
await generateDebtSupplierPDF({
data: allDataForExport,
params: {
supplier_name: formik.values.supplierIds
?.map((v) => v.label)
.join(', '),
filter_by: formik.values.filterBy?.label,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
},
});
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
@@ -670,7 +683,9 @@ const DebtSupplierTab = () => {
Array.isArray(val) ? val : val ? [val] : null
);
}}
isLoading={isLoadingSuppliers}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isClearable
className={{ wrapper: 'w-full' }}
isError={
@@ -21,10 +21,18 @@ import {
ProjectFlockApi,
ProjectFlockKandangApi,
} from '@/services/api/production';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { isResponseError } from '@/lib/api-helper';
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<
@@ -49,6 +57,8 @@ const ProductionResultContent = () => {
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
@@ -62,6 +72,7 @@ const ProductionResultContent = () => {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -78,6 +89,7 @@ const ProductionResultContent = () => {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '',
});
@@ -94,6 +106,7 @@ const ProductionResultContent = () => {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<BaseKandang>(
ProjectFlockApi.basePath,
'id',
@@ -120,6 +133,7 @@ const ProductionResultContent = () => {
setInputValue: setProjectFlockKandangInputValue,
options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangOptions,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<BaseKandang>(
ProjectFlockKandangApi.basePath,
'id',
@@ -154,6 +168,87 @@ const ProductionResultContent = () => {
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.');
}
// await ProductionResultReportApi.exportProductionResultToPdf(
// projectFlockKandangs
// );
setIsLoadingExportingToPdf(false);
};
const searchHandler = async () => {
setProjectFlockKandangs(null);
setIsLoadingSearch(true);
@@ -235,6 +330,7 @@ const ProductionResultContent = () => {
value={selectedArea}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -251,6 +347,7 @@ const ProductionResultContent = () => {
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isDisabled={!selectedArea}
className={{
@@ -270,6 +367,7 @@ const ProductionResultContent = () => {
value={selectedProjectFlock}
onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isDisabled={!selectedArea || !selectedLocation}
className={{
@@ -289,6 +387,7 @@ const ProductionResultContent = () => {
value={selectedProjectFlockKandang}
onChange={projectFlockKandangChangeHandler}
onInputChange={setProjectFlockKandangInputValue}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isClearable
isDisabled={!selectedProjectFlock}
className={{
@@ -347,6 +446,13 @@ const ProductionResultContent = () => {
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>
@@ -0,0 +1,388 @@
'use client';
import React from 'react';
import {
Document,
Page,
StyleSheet,
Text,
View,
Image,
} from '@react-pdf/renderer';
import { formatDate, formatNumber } from '@/lib/helper';
import { BaseProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { ProductionResult } from '@/types/api/report/production-result';
type MappedProductionResultsItem = {
projectFlockKandang: BaseProjectFlockKandang;
productionResult: ProductionResult[] | null;
};
interface ProductionResultReportPDFProps {
mappedProductionResults?: MappedProductionResultsItem[];
}
const styles = StyleSheet.create({
page: {
paddingTop: 24,
paddingBottom: 52,
paddingHorizontal: 16,
},
companyInfoHeader: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
},
companyLogo: {
width: 64,
height: 'auto',
},
companyInfoHeaderDate: {
paddingTop: 8,
fontSize: 10,
},
companyName: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
},
companyAddress: {
fontSize: 8,
maxWidth: 420,
marginBottom: 10,
},
doubleDivider: {
width: '100%',
height: 6,
borderTopWidth: 2,
borderTopColor: '#000',
borderBottomWidth: 2,
borderBottomColor: '#000',
},
title: {
marginTop: 14,
fontSize: 14,
lineHeight: '150%',
textAlign: 'center',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
},
footer: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
position: 'absolute',
fontSize: 8,
bottom: 22,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
},
section: {
marginTop: 12,
borderWidth: 1,
borderColor: '#000',
padding: 8,
},
sectionHeader: {
marginBottom: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'baseline',
},
sectionTitle: {
fontSize: 10,
fontWeight: 'bold',
},
sectionSubtitle: {
fontSize: 8,
color: '#444',
},
// Simple grid table (label/value pairs)
grid: {
width: '100%',
borderWidth: 1,
borderColor: '#000',
},
gridRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000',
},
gridRowLast: {
borderBottomWidth: 0,
},
gridCellLabel: {
width: '40%',
paddingVertical: 3,
paddingHorizontal: 6,
fontSize: 8,
borderRightWidth: 1,
borderRightColor: '#000',
fontWeight: 'bold',
},
gridCellValue: {
width: '60%',
paddingVertical: 3,
paddingHorizontal: 6,
fontSize: 8,
textAlign: 'right',
},
// Subsection headings
groupTitle: {
marginTop: 8,
marginBottom: 4,
fontSize: 9,
fontWeight: 'bold',
},
emptyText: {
fontSize: 8,
color: '#666',
fontStyle: 'italic',
},
});
function safeNum(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
function valueText(v: unknown) {
if (v === null || v === undefined) return '-';
if (typeof v === 'number') return formatNumber(v);
return String(v);
}
/**
* Render label/value table for one ProductionResult.
* Uses a compact grid to keep page readable.
*/
function ProductionResultGrid({ pr }: { pr: ProductionResult }) {
const rows: Array<[string, string]> = [
['WOA', valueText(pr.woa)],
// BW
['BW', valueText(pr.bw)],
['Std BW', valueText(pr.std_bw)],
['Uniformity', valueText(pr.uniformity)],
['Std Uniformity', valueText(pr.std_uniformity)],
// Dep
['Dep Kum', valueText(pr.dep_kum)],
['Dep Std', valueText(pr.dep_std)],
// Butiran
['Butiran Utuh', valueText(pr.butiran_utuh)],
['Butiran Putih', valueText(pr.butiran_putih)],
['Butiran Retak', valueText(pr.butiran_retak)],
['Butiran Pecah', valueText(pr.butiran_pecah)],
['Butiran Jumlah', valueText(pr.butiran_jumlah)],
['Total Butir', valueText(pr.total_butir)],
// Kg
['Kg Utuh', valueText(pr.kg_utuh)],
['Kg Putih', valueText(pr.kg_putih)],
['Kg Retak', valueText(pr.kg_retak)],
['Kg Pecah', valueText(pr.kg_pecah)],
['Kg Jumlah', valueText(pr.kg_jumlah)],
['Total Kg', valueText(pr.total_kg)],
// %
['% Utuh', valueText(pr.persen_utuh)],
['% Putih', valueText(pr.persen_putih)],
['% Retak', valueText(pr.persen_retak)],
['% Pecah', valueText(pr.persen_pecah)],
// Produksi
['HD', valueText(pr.hd)],
['HD Std', valueText(pr.hd_std)],
['FI', valueText(pr.fi)],
['FI Std', valueText(pr.fi_std)],
['EM', valueText(pr.em)],
['EM Std', valueText(pr.em_std)],
['EW', valueText(pr.ew)],
['EW Std', valueText(pr.ew_std)],
['FCR', valueText(pr.fcr)],
['FCR Std', valueText(pr.fcr_std)],
['HH', valueText(pr.hh)],
['HH Std', valueText(pr.hh_std)],
];
return (
<View style={styles.grid}>
{rows.map(([label, value], idx) => {
const isLast = idx === rows.length - 1;
return (
<View
key={label}
style={[styles.gridRow, ...(isLast ? [styles.gridRowLast] : [])]}
>
<Text style={styles.gridCellLabel}>{label}</Text>
<Text style={styles.gridCellValue}>{value}</Text>
</View>
);
})}
</View>
);
}
/**
* If there are multiple ProductionResult entries for a kandang,
* we show them sequentially with a small header per result.
*
* You can later change this to render only the latest WOA, or group by week.
*/
function ProductionResultList({
productionResults,
}: {
productionResults: ProductionResult[];
}) {
return (
<View>
{productionResults.map((pr, idx) => {
const kandangName =
pr.project_flock?.kandang?.name ||
pr.project_flock?.kandang?.id?.toString() ||
'';
// Optional: show a compact subheader
const headerLeft = `Data #${idx + 1}`;
const headerRight =
kandangName && pr.woa !== undefined
? `${kandangName} • WOA ${safeNum(pr.woa)}`
: pr.woa !== undefined
? `WOA ${safeNum(pr.woa)}`
: '';
return (
<View
key={`${pr.project_flock?.id ?? 'pf'}-${idx}`}
style={{ marginTop: idx === 0 ? 0 : 10 }}
wrap={false}
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{headerLeft}</Text>
<Text style={styles.sectionSubtitle}>{headerRight}</Text>
</View>
<ProductionResultGrid pr={pr} />
</View>
);
})}
</View>
);
}
/**
* Main PDF Component
*/
const ProductionResultReportPDF = ({
mappedProductionResults = [],
}: ProductionResultReportPDFProps) => {
return (
<Document>
<Page style={styles.page} size='A4'>
{/* Header */}
<View>
<View style={styles.companyInfoHeader}>
<Image style={styles.companyLogo} src='/assets/img/lti-logo.png' />
<Text style={styles.companyInfoHeaderDate}>
{formatDate(Date.now(), 'DD MMMM YYYY')}
</Text>
</View>
<View>
<Text style={styles.companyName}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={styles.companyAddress}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={styles.doubleDivider} />
</View>
</View>
<Text style={styles.title}>Laporan Production Result</Text>
{/* Sections per ProjectFlockKandang */}
{mappedProductionResults.length === 0 ? (
<View style={{ marginTop: 16 }}>
<Text style={styles.emptyText}>Tidak ada data.</Text>
</View>
) : (
mappedProductionResults.map((item, idx) => {
const pfk = item.projectFlockKandang;
// Try to display meaningful identifiers.
// Adjust these fields based on your real BaseProjectFlockKandang structure.
const kandangName =
pfk?.kandang?.name ?? `Kandang #${pfk?.kandang_id ?? idx + 1}`;
const projectName = pfk?.project_flock?.name ?? '';
const locationName = pfk?.project_flock?.location?.name ?? '';
const areaName = pfk?.project_flock?.area?.name ?? '';
return (
<View
key={`pfk-${pfk?.id ?? idx}`}
style={styles.section}
break={idx > 0} // each kandang starts on a new page for clarity
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{projectName
? `${projectName}${kandangName}`
: kandangName}
</Text>
<Text style={styles.sectionSubtitle}>
{[areaName, locationName].filter(Boolean).join(' • ')}
</Text>
</View>
{item.productionResult && item.productionResult.length > 0 ? (
<ProductionResultList
productionResults={item.productionResult}
/>
) : (
<Text style={styles.emptyText}>
Tidak ada production result untuk kandang ini.
</Text>
)}
</View>
);
})
)}
{/* Footer */}
<View style={styles.footer} fixed>
<Text
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
</View>
</Page>
</Document>
);
};
export default ProductionResultReportPDF;
@@ -226,7 +226,7 @@ const createPDFDocument = (
<Text>Rentang BW</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Ekor</Text>
<Text>Sisa Butir</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Kg</Text>
@@ -234,12 +234,6 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Bobot (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Produksi Telur (Butir)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Produksi Telur (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Feed (Supplier)</Text>
</View>
@@ -249,16 +243,15 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nilai Nominal Telur</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP Ayam</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>HPP Telur (RP/KG)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<View
style={[
pdfStyles.tableCellHeaderRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>Nominal Sisa</Text>
</View>
</View>
@@ -278,23 +271,15 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>{group.label}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.remaining_chicken_birds)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatNumber(group.remaining_chicken_weight_kg)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(group.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.egg_production_pieces)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.egg_production_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(group.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>
{group.feed_suppliers
@@ -318,17 +303,16 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.average_doc_price_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.egg_value_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(group.hpp_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.egg_hpp_rp_per_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.remaining_value_rp)}</Text>
<View
style={[
pdfStyles.tableCellRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>{formatCurrency(group.egg_value_rp)}</Text>
</View>
</View>
)
@@ -356,16 +340,10 @@ const createPDFDocument = (
<Text>Rata-Rata Bobot (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Ekor</Text>
<Text>Sisa Butir</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Kg (Ayam)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Produksi Telur (Butir)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Produksi Telur (Kg)</Text>
<Text>Sisa Kg (Telur)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Feed (Supplier)</Text>
@@ -376,16 +354,15 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nilai Nominal Telur</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>HPP Ayam</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP Telur (RP/KG)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<View
style={[
pdfStyles.tableCellHeaderRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>Nominal Sisa</Text>
</View>
</View>
@@ -394,12 +371,7 @@ const createPDFDocument = (
{data.rows.map((item: HppPerKandangRow, index: number) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < data.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
style={[pdfStyles.tableRow, pdfStyles.tableBorderBottom]}
>
<View style={[pdfStyles.tableCellCenter, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
@@ -416,12 +388,6 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_birds)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.egg_production_pieces)}</Text>
</View>
@@ -451,20 +417,202 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.average_doc_price_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.egg_value_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatCurrency(item.hpp_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(item.egg_hpp_rp_per_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.remaining_value_rp)}</Text>
<View
style={[
pdfStyles.tableCellRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>{formatCurrency(item.egg_value_rp)}</Text>
</View>
</View>
))}
{/* TOTAL Row */}
{data.summary?.total && (
<View style={pdfStyles.tableRow}>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 0.5,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>TOTAL</Text>
</View>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 1.5,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>ALL</Text>
</View>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 1,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>-</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 1,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatNumber(data.summary.total.average_weight_kg)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 0.8,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatNumber(
data.summary.total.total_egg_production_pieces
)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 0.8,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatNumber(data.summary.total.total_egg_production_kg)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 1.2,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{data.rows
.flatMap((row: HppPerKandangRow) =>
row.feed_suppliers?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
)
.filter(
(v: string, i: number, a: string[]) =>
a.indexOf(v) === i
)
.join(' | ') || '-'}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 1,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{data.rows
.flatMap((row: HppPerKandangRow) =>
row.doc_suppliers?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
)
.filter(
(v: string, i: number, a: string[]) =>
a.indexOf(v) === i
)
.join(' | ') || '-'}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 1.2,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatCurrency(
data.summary.total.total_average_doc_price_rp
)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 1,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatCurrency(
data.summary.total.average_egg_hpp_rp_per_kg
)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 1.2,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
borderRightWidth: 0,
},
]}
>
<Text>
{formatCurrency(data.summary.total.total_egg_value_rp)}
</Text>
</View>
</View>
)}
</View>
</View>
</Page>
@@ -10,7 +10,7 @@ import DateInput from '@/components/input/DateInput';
import NumberInput from '@/components/input/NumberInput';
import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
import { KandangApi } from '@/services/api/master-data';
import { ProjectFlockKandangApi } from '@/services/api/production';
import { SaleReportApi } from '@/services/api/report/marketing-sale';
import Table from '@/components/Table';
import { ColumnDef, Row, flexRender } from '@tanstack/react-table';
@@ -40,6 +40,9 @@ const HppPerKandangTab = () => {
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== VALIDATION STATE =====
const [weightMaxError, setWeightMaxError] = useState<string>('');
// ===== TABLE FILTER STATE =====
const { state: tableFilterState, updateFilter } = useTableFilter({
initial: {
@@ -58,19 +61,32 @@ const HppPerKandangTab = () => {
},
});
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreas,
loadMore: loadMoreAreas,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangs,
loadMore: loadMoreKandangs,
} = useSelect(
ProjectFlockKandangApi.basePath,
'id',
'name',
'name_with_period',
'search'
);
const { options: locationOptions, isLoadingOptions: isLoadingLocations } =
useSelect(LocationApi.basePath, 'id', 'name', 'search');
const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } =
useSelect(KandangApi.basePath, 'id', 'name', 'search');
const showUnrecordedOptions: OptionType[] = [
{ value: 'false', label: 'Sembunyikan' },
{ value: 'true', label: 'Tampilkan' },
@@ -119,8 +135,12 @@ const HppPerKandangTab = () => {
const val = e.target.value;
updateFilter('weight_min', val ? String(parseFloat(val) || 0) : '');
setIsSubmitted(false);
if (weightMaxError) {
setWeightMaxError('');
}
},
[updateFilter]
[updateFilter, weightMaxError]
);
const weightMaxChangeHandler = useCallback<
@@ -128,10 +148,22 @@ const HppPerKandangTab = () => {
>(
(e) => {
const val = e.target.value;
updateFilter('weight_max', val ? String(parseFloat(val) || 0) : '');
const weightMax = val ? parseFloat(val) || 0 : 0;
const weightMin = tableFilterState.weight_min
? parseFloat(tableFilterState.weight_min)
: 0;
if (weightMax < weightMin) {
setWeightMaxError('Rentang bobot max tidak boleh lebih kecil dari min');
toast.error('Rentang bobot max tidak boleh lebih kecil dari min');
return;
}
setWeightMaxError('');
updateFilter('weight_max', val ? String(weightMax) : '');
setIsSubmitted(false);
},
[updateFilter]
[updateFilter, tableFilterState.weight_min]
);
const periodChangeHandler = useCallback<ChangeEventHandler<HTMLInputElement>>(
@@ -317,8 +349,53 @@ const HppPerKandangTab = () => {
const allExportData =
allDataForExport.rows as HppPerKandangReport['rows'];
const perWeightRangeSummary =
allDataForExport.summary.per_weight_range || [];
const summaryTotal = allDataForExport.summary.total;
const rekapitulasiData: { [key: string]: string | number }[] =
perWeightRangeSummary.map(
(item: HppPerKandangPerWeightRange, index: number) => ({
No: index + 1,
'Rentang BW': item.label || '',
'Sisa Butir': item.egg_production_pieces || 0,
'Sisa Kg': item.egg_production_kg || 0,
'Rata-Rata Bobot (Kg)': item.avg_weight_kg || 0,
'Feed (Supplier)':
item.feed_suppliers
?.map(
(s: { alias?: string; name: string }) => s.alias || s.name
)
.join(' | ') || '',
'DOC (Supplier)':
item.doc_suppliers
?.map(
(s: { alias?: string; name: string }) => s.alias || s.name
)
.join(' | ') || '',
'Rata-Rata Harga DOC': item.average_doc_price_rp || 0,
'HPP Telur (RP/KG)': item.egg_hpp_rp_per_kg || 0,
'Nominal Sisa': item.egg_value_rp || 0,
})
);
const rekapitulasiWorksheet = XLSX.utils.json_to_sheet(rekapitulasiData);
const rekapitulasiColWidths = [
{ wch: 5 }, // No
{ wch: 15 }, // Rentang BW
{ wch: 15 }, // Sisa Butir
{ wch: 12 }, // Sisa Kg
{ wch: 18 }, // Rata-Rata Bobot (Kg)
{ wch: 20 }, // Feed (Supplier)
{ wch: 20 }, // DOC (Supplier)
{ wch: 20 }, // Rata-Rata Harga DOC
{ wch: 18 }, // HPP Telur (RP/KG)
{ wch: 25 }, // Nominal Sisa
];
rekapitulasiWorksheet['!cols'] = rekapitulasiColWidths;
const excelData: { [key: string]: string | number }[] = allExportData.map(
(item: HppPerKandangRow, index: number) => ({
No: index + 1,
@@ -327,10 +404,8 @@ const HppPerKandangTab = () => {
? `${formatNumber(item.weight_range.weight_min)} - ${formatNumber(item.weight_range.weight_max)}`
: '',
'Rata-Rata Bobot (KG)': item.avg_weight_kg || 0,
'Sisa Ayam (Ekor)': item.remaining_chicken_birds || 0,
'Sisa Ayam (KG)': item.remaining_chicken_weight_kg || 0,
'Produksi Telur (Butir)': item.egg_production_pieces || 0,
'Produksi Telur (KG)': item.egg_production_kg || 0,
'Sisa Telur (Butir)': item.egg_production_pieces || 0,
'Sisa Telur (KG)': item.egg_production_kg || 0,
'Feed (Supplier)':
item.feed_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
@@ -340,10 +415,8 @@ const HppPerKandangTab = () => {
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '',
'Rata-Rata Harga DOC (RP)': item.average_doc_price_rp || 0,
'Nilai Nominal Telur (RP)': item.egg_value_rp || 0,
'HPP Ayam (RP)': item.hpp_rp || 0,
'HPP Telur (RP/KG)': item.egg_hpp_rp_per_kg || 0,
'Nilai Nominal Sisa Ayam (RP)': item.remaining_value_rp || 0,
'Nilai Nominal Sisa Telur (RP)': item.egg_value_rp || 0,
})
);
@@ -352,20 +425,14 @@ const HppPerKandangTab = () => {
Kandang: 'ALL',
'Rentang Bobot': '-',
'Rata-Rata Bobot (KG)': summaryTotal?.average_weight_kg || 0,
'Sisa Ayam (Ekor)': summaryTotal?.total_remaining_chicken_birds || 0,
'Sisa Ayam (KG)': summaryTotal?.total_remaining_chicken_weight_kg || 0,
'Produksi Telur (Butir)':
summaryTotal?.total_egg_production_pieces || 0,
'Produksi Telur (KG)': summaryTotal?.total_egg_production_kg || 0,
'Sisa Telur (Butir)': summaryTotal?.total_egg_production_pieces || 0,
'Sisa Telur (KG)': summaryTotal?.total_egg_production_kg || 0,
'Feed (Supplier)': allFeedSuppliers,
'DOC (Supplier)': allDocSuppliers,
'Rata-Rata Harga DOC (RP)':
summaryTotal?.total_average_doc_price_rp || 0,
'Nilai Nominal Telur (RP)': summaryTotal?.total_egg_value_rp || 0,
'HPP Ayam (RP)': summaryTotal?.total_hpp_rp || 0,
'HPP Telur (RP/KG)': summaryTotal?.average_egg_hpp_rp_per_kg || 0,
'Nilai Nominal Sisa Ayam (RP)':
summaryTotal?.total_remaining_value_rp || 0,
'Nilai Nominal Sisa Telur (RP)': summaryTotal?.total_egg_value_rp || 0,
});
const worksheet = XLSX.utils.json_to_sheet(excelData);
@@ -375,22 +442,23 @@ const HppPerKandangTab = () => {
{ wch: 30 }, // Kandang
{ wch: 15 }, // Rentang Bobot
{ wch: 18 }, // Rata-Rata Bobot (KG)
{ wch: 15 }, // Sisa Ayam (Ekor)
{ wch: 15 }, // Sisa Ayam (KG)
{ wch: 18 }, // Produksi Telur (Butir)
{ wch: 18 }, // Produksi Telur (KG)
{ wch: 15 }, // Sisa Telur (Butir)
{ wch: 15 }, // Sisa Telur (KG)
{ wch: 20 }, // Feed (Supplier)
{ wch: 20 }, // DOC (Supplier)
{ wch: 20 }, // Rata-Rata Harga DOC (RP)
{ wch: 20 }, // Nilai Nominal Telur (RP)
{ wch: 15 }, // HPP Ayam (RP)
{ wch: 18 }, // HPP Telur (RP/KG)
{ wch: 25 }, // Nilai Nominal Sisa Ayam (RP)
{ wch: 25 }, // Nilai Nominal Sisa Telur (RP)
];
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'HPP Per Kandang');
XLSX.utils.book_append_sheet(
workbook,
rekapitulasiWorksheet,
'Rekapitulasi'
);
XLSX.utils.book_append_sheet(workbook, worksheet, 'Detail Per Kandang');
const filename = `laporan-hpp-harian-kandang-periode-${tableFilterState.period}.xlsx`;
@@ -494,8 +562,8 @@ const HppPerKandangTab = () => {
header: 'Kandang',
accessorKey: 'kandang.name',
cell: (props) => {
const kandang = props.row.original.kandang;
return kandang?.name || '-';
const row = props.row.original;
return row.name_with_periode || row.kandang?.name || '-';
},
footer: () => <div className='font-semibold text-gray-900'>ALL</div>,
},
@@ -525,37 +593,9 @@ const HppPerKandangTab = () => {
</div>
),
},
{
id: 'remaining_chicken_birds',
header: 'Sisa Ayam (Ekor)',
accessorKey: 'remaining_chicken_birds',
cell: (props) => {
const value = props.row.original.remaining_chicken_birds;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_remaining_chicken_birds || 0)}
</div>
),
},
{
id: 'remaining_chicken_weight_kg',
header: 'Sisa Ayam (KG)',
accessorKey: 'remaining_chicken_weight_kg',
cell: (props) => {
const value = props.row.original.remaining_chicken_weight_kg;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_remaining_chicken_weight_kg || 0)}
</div>
),
},
{
id: 'egg_production_pieces',
header: 'Produksi Telur (Butir)',
header: 'Sisa Telur (Butir)',
accessorKey: 'egg_production_pieces',
cell: (props) => {
const value = props.row.original.egg_production_pieces;
@@ -569,7 +609,7 @@ const HppPerKandangTab = () => {
},
{
id: 'egg_production_kg',
header: 'Produksi Telur (KG)',
header: 'Sisa Telur (KG)',
accessorKey: 'egg_production_kg',
cell: (props) => {
const value = props.row.original.egg_production_kg;
@@ -577,7 +617,7 @@ const HppPerKandangTab = () => {
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_remaining_chicken_weight_kg || 0)}
{formatNumber(summaryTotal?.total_egg_production_kg || 0)}
</div>
),
},
@@ -631,34 +671,6 @@ const HppPerKandangTab = () => {
</div>
),
},
{
id: 'egg_value_rp',
header: 'Nilai Nominal Telur (RP)',
accessorKey: 'egg_value_rp',
cell: (props) => {
const value = props.row.original.egg_value_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_egg_value_rp || 0)}
</div>
),
},
{
id: 'hpp_rp',
header: 'HPP Ayam (RP)',
accessorKey: 'hpp_rp',
cell: (props) => {
const value = props.row.original.hpp_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_hpp_rp || 0)}
</div>
),
},
{
id: 'egg_hpp_rp_per_kg',
header: 'HPP Telur (RP/KG)',
@@ -674,16 +686,16 @@ const HppPerKandangTab = () => {
),
},
{
id: 'remaining_value_rp',
header: 'Nilai Nominal Sisa Ayam (RP)',
accessorKey: 'remaining_value_rp',
id: 'egg_value_rp',
header: 'Nilai Nominal Sisa Telur (RP)',
accessorKey: 'egg_value_rp',
cell: (props) => {
const value = props.row.original.remaining_value_rp;
const value = props.row.original.egg_value_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_remaining_value_rp || 0)}
{formatCurrency(summaryTotal?.total_egg_value_rp || 0)}
</div>
),
},
@@ -717,7 +729,7 @@ const HppPerKandangTab = () => {
key={'rekapitulasi-row'}
>
<td
colSpan={15}
colSpan={11}
className='px-4 py-3 text-gray-900 text-center font-semibold'
>
Rekapitulasi per rentang bobot
@@ -739,12 +751,6 @@ const HppPerKandangTab = () => {
<td className='text-right'>
{formatNumber(item.avg_weight_kg)}
</td>
<td className='text-right'>
{formatNumber(item.remaining_chicken_birds)}
</td>
<td className='text-right'>
{formatNumber(item.remaining_chicken_weight_kg)}
</td>
<td className='text-right'>
{formatNumber(item.egg_production_pieces)}
</td>
@@ -764,15 +770,11 @@ const HppPerKandangTab = () => {
<td className='text-right'>
{formatCurrency(item.average_doc_price_rp)}
</td>
<td className='text-right'>
{formatCurrency(item.egg_value_rp)}
</td>
<td className='text-right'>{formatCurrency(item.hpp_rp)}</td>
<td className='text-right'>
{formatCurrency(item.egg_hpp_rp_per_kg)}
</td>
<td className='text-right'>
{formatCurrency(item.remaining_value_rp)}
{formatCurrency(item.egg_value_rp)}
</td>
</tr>
);
@@ -810,7 +812,11 @@ const HppPerKandangTab = () => {
.includes(String(opt.value))
)}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreas}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isClearable
/>
<SelectInput
@@ -824,7 +830,11 @@ const HppPerKandangTab = () => {
.includes(String(opt.value))
)}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocations}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isClearable
/>
<SelectInput
@@ -838,7 +848,11 @@ const HppPerKandangTab = () => {
.includes(String(opt.value))
)}
onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangs}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isClearable
/>
</div>
@@ -858,6 +872,8 @@ const HppPerKandangTab = () => {
placeholder='Masukkan bobot maximum'
value={tableFilterState.weight_max}
onChange={weightMaxChangeHandler}
isError={!!weightMaxError}
errorMessage={weightMaxError}
/>
</div>
<DateInput
@@ -884,7 +900,11 @@ const HppPerKandangTab = () => {
</div>
<div className='mt-4 flex justify-end gap-2 [&_button]:px-4'>
<Button color='primary' onClick={handleSubmit}>
<Button
color='primary'
onClick={handleSubmit}
disabled={!!weightMaxError}
>
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
Cari
</Button>
+1 -17
View File
@@ -74,23 +74,7 @@ export const RECORDING_APPROVAL_LINE: ApprovalLine = [
},
{
step_number: 2,
step_name: 'Approval Head Area',
},
{
step_number: 3,
step_name: 'Approval Business Unit Vice President',
},
{
step_number: 4,
step_name: 'Approval Finance',
},
{
step_number: 5,
step_name: 'Realisasi',
},
{
step_number: 6,
step_name: 'Selesai',
step_name: 'Disetujui',
},
] as const;

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