Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-278/TASK-311-adjustment-purchase-and-expense

This commit is contained in:
rstubryan
2025-12-10 13:08:36 +07:00
36 changed files with 1805 additions and 617 deletions
@@ -0,0 +1,95 @@
'use client';
import { useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Tabs from '@/components/Tabs';
import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable';
import { ClosingGeneralInformation } from '@/types/api/closing';
import ClosingSapronakTabContent from './ClosingSapronakTabContent';
interface ClosingDetailProps {
id: number;
initialValue?: ClosingGeneralInformation;
}
const ClosingDetail: React.FC<ClosingDetailProps> = ({ id, initialValue }) => {
const [activeTab, setActiveTab] = useState<string>('sapronak');
const closingDetailTabs = useMemo(() => {
const validTabs = [
{
id: 'sapronak',
label: 'Sapronak',
content: <ClosingSapronakTabContent projectFlockId={id} />,
},
{
id: 'perhitunganSapronak',
label: 'Perhitungan Sapronak',
content: 'Perhitungan Sapronak',
},
{
id: 'penjualan',
label: 'Penjualan',
content: 'Penjualan',
},
{
id: 'overhead',
label: 'Overhead',
content: 'Overhead',
},
{
id: 'hppEkspedisi',
label: 'HPP Ekspedisi',
content: 'HPP Ekspedisi',
},
{
id: 'dataProduksi',
label: 'Data Produksi',
content: 'Data Produksi',
},
{
id: 'keuangan',
label: 'Keuangan',
content: 'Keuangan',
},
];
return validTabs;
}, [initialValue]);
return (
<>
<section className='w-full max-w-7xl pb-16'>
<header className='flex flex-col gap-4'>
<Button
href='/closing'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>Detail Closing</h1>
</header>
<ClosingGeneralInformationTable initialValue={initialValue} />
<Tabs
activeTabId={activeTab}
onTabChange={setActiveTab}
tabs={closingDetailTabs}
variant='lifted'
className={{
wrapper: 'w-full mt-4',
}}
/>
</section>
</>
);
};
export default ClosingDetail;
@@ -0,0 +1,100 @@
import { ClosingGeneralInformation } from '@/types/api/closing';
interface ClosingGeneralInformationProps {
initialValue?: ClosingGeneralInformation;
}
const ClosingGeneralInformationTable = ({
initialValue,
}: ClosingGeneralInformationProps) => {
return (
<div className='w-full my-4 @container'>
<div className='flex flex-col @sm:flex-row gap-4'>
<div className='w-full'>
<div className='overflow-x-auto'>
<table className='table table-zebra table-sm'>
<tbody>
<tr>
<td>Lokasi</td>
<td>:</td>
<td>{initialValue?.location_name}</td>
</tr>
<tr>
<td>Periode</td>
<td>:</td>
<td>{initialValue?.period}</td>
</tr>
<tr>
<td>Kategori</td>
<td>:</td>
<td>{initialValue?.project_category}</td>
</tr>
<tr>
<td>Populasi</td>
<td>:</td>
<td>{initialValue?.population} Ekor</td>
</tr>
<tr>
<td>Jenis Project</td>
<td>:</td>
<td>{initialValue?.project_type}</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Kandang Aktif</td>
<td>:</td>
<td>{initialValue?.active_house_count} Kandang</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Status Pembayaran Penjualan</td>
<td>:</td>
<td>{initialValue?.sales_payment_status}</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Status Project</td>
<td>:</td>
<td>{initialValue?.project_status}</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Status Closing</td>
<td>:</td>
<td>{initialValue?.closing_status}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className='w-full hidden @sm:block'>
<div className='overflow-x-auto'>
<table className='table table-zebra table-sm'>
<tbody>
<tr>
<td>Kandang Aktif</td>
<td>:</td>
<td>{initialValue?.active_house_count} Kandang</td>
</tr>
<tr>
<td>Status Pembayaran Penjualan</td>
<td>:</td>
<td>{initialValue?.sales_payment_status}</td>
</tr>
<tr>
<td>Status Project</td>
<td>:</td>
<td>{initialValue?.project_status}</td>
</tr>
<tr>
<td>Status Closing</td>
<td>:</td>
<td>{initialValue?.closing_status}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
export default ClosingGeneralInformationTable;
@@ -0,0 +1,209 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingIncomingSapronak } from '@/types/api/closing';
interface ClosingIncomingSapronaksTableProps {
projectFlockId: number;
}
const ClosingIncomingSapronaksTable = ({
projectFlockId,
}: ClosingIncomingSapronaksTableProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`,
ClosingApi.getAllIncomingSapronakFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronak>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'date',
header: 'Tanggal',
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
},
{
accessorKey: 'reference_number',
header: 'No. Referensi',
},
{
accessorKey: 'transaction_type',
header: 'Jenis Transaksi',
},
{
accessorKey: 'product_name',
header: 'Produk',
},
{
accessorKey: 'product_category',
header: 'Kategori Produk',
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan',
},
{
accessorKey: 'quantity',
header: 'Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
},
{
accessorKey: 'notes',
header: 'Keterangan',
},
];
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(incomingSapronaks)
? incomingSapronaks.data.length > 0
: false
);
}
}, [incomingSapronaks, 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'>Sapronak Masuk</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Sapronak Masuk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<ClosingIncomingSapronak>
data={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.data
: []
}
columns={incomingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.meta?.page
: 0
}
totalItems={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingIncomingSapronaks}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronaks) &&
incomingSapronaks?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingIncomingSapronaksTable;
@@ -0,0 +1,209 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingOutgoingSapronak } from '@/types/api/closing';
interface ClosingOutgoingSapronaksTableProps {
projectFlockId: number;
}
const ClosingOutgoingSapronaksTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksTableProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`,
ClosingApi.getAllOutgoingSapronakFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronak>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'date',
header: 'Tanggal',
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
},
{
accessorKey: 'reference_number',
header: 'No. Referensi',
},
{
accessorKey: 'transaction_type',
header: 'Jenis Transaksi',
},
{
accessorKey: 'product_name',
header: 'Produk',
},
{
accessorKey: 'product_category',
header: 'Kategori Produk',
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan',
},
{
accessorKey: 'quantity',
header: 'Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
},
{
accessorKey: 'notes',
header: 'Keterangan',
},
];
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(outgoingSapronaks)
? outgoingSapronaks.data.length > 0
: false
);
}
}, [outgoingSapronaks, 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'>Sapronak Keluar</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Sapronak Keluar'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<ClosingOutgoingSapronak>
data={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.data
: []
}
columns={outgoingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.meta?.page
: 0
}
totalItems={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingOutgoingSapronaks}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronaks) &&
outgoingSapronaks?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingOutgoingSapronaksTable;
@@ -0,0 +1,26 @@
'use client';
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
interface ClosingSapronakTableProps {
projectFlockId?: number;
}
const ClosingSapronakTabContent = ({
projectFlockId,
}: ClosingSapronakTableProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<>
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
</>
)}
</div>
);
};
export default ClosingSapronakTabContent;
@@ -0,0 +1,299 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { LocationApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { ClosingApi } from '@/services/api/closing';
import { Closing } from '@/types/api/closing';
const PROJECT_STATUS_OPTIONS = [
{
value: 1,
label: 'Pengajuan',
},
{
value: 2,
label: 'Aktif',
},
];
const RowOptionsMenu = ({
type = 'dropdown',
props,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Closing, unknown>;
}) => {
return (
<RowOptionsMenuWrapper type={type}>
{/* TODO: apply RBAC */}
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<Button
href={`/closing/detail/?closingId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</div>
</RowOptionsMenuWrapper>
);
};
const ClosingsTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
transactionDate: '',
realizationDate: '',
locationId: '',
projectStatus: '',
userId: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
transactionDate: 'transaction_date',
realizationDate: 'realization_date',
locationId: 'location_id',
projectStatus: 'project_status',
userId: 'user_id',
},
});
const { data: closings, isLoading: isLoadingClosings } = useSWR(
`${ClosingApi.basePath}${getTableFilterQueryString()}`,
ClosingApi.getAllFetcher
);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const closingsColumns: ColumnDef<Closing>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'location_name',
header: 'Lokasi',
},
{
accessorKey: 'project_category',
header: 'Kategori',
},
{
accessorKey: 'period',
header: 'Periode',
},
{
accessorKey: 'closing_date',
header: 'Periode',
cell: (props) =>
formatDate(props.row.original.closing_date, 'DD MMM YYYY'),
},
{
accessorKey: 'shed_label',
header: 'Jumlah Kandang',
},
{
accessorKey: 'sales_paid_amount',
header: 'Jumlah Sudah Bayar',
cell: (props) => (
<span className='text-success'>
{formatCurrency(props.row.original.sales_paid_amount)}
</span>
),
},
{
accessorKey: 'sales_remaining_amount',
header: 'Jumlah Sisa Bayar',
cell: (props) => (
<span className='text-error'>
{formatCurrency(props.row.original.sales_remaining_amount)}
</span>
),
},
{
accessorKey: 'sales_payment_status',
header: 'Status Pembayaran',
},
{
accessorKey: 'project_status',
header: 'Status',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
return (
<>
{currentPageSize > 3 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 3 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
);
},
},
];
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'locationId',
val ? ((val as OptionType).value as string) : ''
);
};
const [selectedProjectStatus, setSelectedProjectStatus] =
useState<OptionType | null>(null);
const projectStatusChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedProjectStatus(val as OptionType);
updateFilter(
'projectStatus',
val ? ((val as OptionType).value as string) : ''
);
};
const 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]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-end items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Closing'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='grid grid-cols-12 justify-end gap-2'>
<SelectInput
label='Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
<SelectInput
label='Status Project'
placeholder='Pilih Status'
options={PROJECT_STATUS_OPTIONS}
value={selectedProjectStatus}
onChange={projectStatusChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
</div>
</div>
</div>
<Table<Closing>
data={isResponseSuccess(closings) ? closings?.data : []}
columns={closingsColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={isResponseSuccess(closings) ? closings?.meta?.page : 0}
totalItems={
isResponseSuccess(closings) ? closings?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoadingClosings}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(closings) && closings?.data?.length === 0,
}),
}}
/>
</div>
</>
);
};
export default ClosingsTable;
@@ -207,7 +207,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.total_price)
(item) => (expenseGrandTotal += item.price)
);
return (
@@ -238,7 +238,7 @@ const ExpenseRealizationContent = ({
<tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.total_price)}</td>
<td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
</tr>
)
@@ -269,7 +269,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.total_price)
(item) => (expenseGrandTotal += item.price)
);
return (
@@ -300,7 +300,7 @@ const ExpenseRealizationContent = ({
<tr key={realisasiIdx}>
<td>{realisasiItem.nonstock.name}</td>
<td>{realisasiItem.qty}</td>
<td>{formatCurrency(realisasiItem.total_price)}</td>
<td>{formatCurrency(realisasiItem.price)}</td>
<td className='w-xs'>{realisasiItem.note ?? '-'}</td>
</tr>
)
@@ -402,7 +402,10 @@ const ExpenseRequestContent = ({
<th>Tanggal Transaksi</th>
<th>:</th>
<td>
{formatDate(initialValues?.expense_date, 'DD MMMM YYYY')}
{formatDate(
initialValues?.transaction_date,
'DD MMMM YYYY'
)}
</td>
</tr>
<tr>
@@ -529,7 +532,7 @@ const ExpenseRequestContent = ({
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.total_price)
(item) => (expenseGrandTotal += item.price)
);
return (
@@ -550,7 +553,7 @@ const ExpenseRequestContent = ({
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Harga Satuan</th>
<th>Catatan</th>
</tr>
</thead>
@@ -560,9 +563,7 @@ const ExpenseRequestContent = ({
<tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td>
<td>
{formatCurrency(pengajuanItem.total_price)}
</td>
<td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'>
{pengajuanItem.note ?? '-'}
</td>
@@ -263,11 +263,11 @@ const ExpensesTable = () => {
},
},
{
accessorKey: 'expense_date',
accessorKey: 'transaction_date',
header: 'Tanggal Pengajuan',
cell: (props) =>
props.row.original.expense_date
? formatDate(props.row.original.expense_date, 'DD MMM YYYY')
props.row.original.transaction_date
? formatDate(props.row.original.transaction_date, 'DD MMM YYYY')
: '-',
},
{
@@ -27,7 +27,7 @@ type ExpenseRealizationFormSchemaType = {
label: string;
};
quantity?: number;
total_cost?: number;
price?: number;
notes?: string;
}[];
}[];
@@ -82,7 +82,7 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
label: Yup.string().required(),
}).required('Nonstock wajib diisi!'),
quantity: Yup.number().required('Total kuantitas wajib diisi!'),
total_cost: Yup.number().required('Total biaya wajib diisi!'),
price: Yup.number().required('Harga satuan wajib diisi!'),
notes: Yup.string(),
})
)
@@ -155,7 +155,7 @@ export const getExpenseRealizationFormInitialValues = (
label: realisasiItem.nonstock.name,
},
quantity: realisasiItem.qty,
total_cost: realisasiItem.total_price,
price: realisasiItem.price,
notes: realisasiItem.note,
};
})
@@ -166,7 +166,7 @@ export const getExpenseRealizationFormInitialValues = (
label: expenseItem.nonstock.name,
},
quantity: expenseItem.qty,
total_cost: expenseItem.total_price,
price: expenseItem.price,
notes: expenseItem.note,
}))
: [];
@@ -98,15 +98,10 @@ const ExpenseRealizationForm = ({
values.realizations.forEach((realization) => {
realization.cost_items.forEach((costItem) => {
const unitPrice =
parseFloat(String(costItem.total_cost)) /
parseFloat(String(costItem.quantity));
const realizationItem = {
expense_nonstock_id: costItem.nonstock?.value as number,
qty: parseFloat(String(costItem.quantity)) as number,
unit_price: unitPrice,
total_price: parseFloat(String(costItem.total_cost)) as number,
price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '',
};
@@ -177,7 +172,7 @@ const ExpenseRealizationForm = ({
{
nonstock: undefined,
quantity: undefined,
total_cost: undefined,
price: undefined,
notes: '',
},
],
@@ -48,7 +48,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
};
const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'total_cost' | 'notes',
column: 'nonstock' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number,
expenseIdx: number
) => {
@@ -112,7 +112,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Harga Satuan</th>
<th>Catatan</th>
</tr>
</thead>
@@ -163,17 +163,17 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
<td className='p-2'>
<NumberInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`}
placeholder='Masukkan Total Biaya'
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Harga Satuan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].total_cost ?? ''
].cost_items[expenseIdx].price ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'total_cost',
'price',
kandangExpenseIdx,
expenseIdx
)}
@@ -20,7 +20,7 @@ type ExpenseFormSchemaType = {
existing_documents?: { id: number; name: string; url: string }[];
deleted_documents?: number[];
documents?: File[];
cost_per_kandangs: {
expense_nonstocks: {
kandang_id: number;
cost_items: {
nonstock?: {
@@ -28,7 +28,7 @@ type ExpenseFormSchemaType = {
label: string;
};
quantity?: number;
total_cost?: number;
price?: number;
notes?: string;
}[];
}[];
@@ -74,7 +74,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
cost_per_kandangs: Yup.array()
expense_nonstocks: Yup.array()
.of(
Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
@@ -86,7 +86,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
label: Yup.string().required(),
}).required('Nonstock wajib diisi!'),
quantity: Yup.number().required('Total kuantitas wajib diisi!'),
total_cost: Yup.number().required('Total biaya wajib diisi!'),
price: Yup.number().required('Harga satuan wajib diisi!'),
notes: Yup.string(),
})
)
@@ -128,8 +128,8 @@ export const getExpenseFormInitialValues = (
label: initialValues.location.name,
}
: undefined,
transaction_date: initialValues?.expense_date
? formatDate(initialValues.expense_date, 'YYYY-MM-DD')
transaction_date: initialValues?.transaction_date
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
: undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.kandang_id,
@@ -148,7 +148,7 @@ export const getExpenseFormInitialValues = (
})),
deleted_documents: [],
documents: [],
cost_per_kandangs: initialValues?.kandangs
expense_nonstocks: initialValues?.kandangs
? initialValues.kandangs.map((kandangExpense) => ({
kandang_id: kandangExpense.kandang_id,
cost_items: kandangExpense.pengajuans
@@ -158,7 +158,7 @@ export const getExpenseFormInitialValues = (
label: expenseItem.nonstock.name,
},
quantity: expenseItem.qty,
total_cost: expenseItem.total_price,
price: expenseItem.price,
notes: expenseItem.note,
}))
: [],
@@ -110,12 +110,12 @@ const ExpenseRequestForm = ({
transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number,
documents: values.documents as File[],
cost_per_kandangs: values.cost_per_kandangs.map((costPerKandang) => ({
kandang_id: costPerKandang.kandang_id,
cost_items: costPerKandang.cost_items.map((costItem) => ({
expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => ({
kandang_id: expenseNonstock.kandang_id,
cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number,
total_cost: parseFloat(String(costItem.total_cost)) as number,
price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '',
})),
})),
@@ -132,13 +132,13 @@ const ExpenseRequestForm = ({
transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number,
documents: values.documents as File[],
cost_per_kandang: values.cost_per_kandangs.map(
(costPerKandang) => ({
kandang_id: costPerKandang.kandang_id,
cost_items: costPerKandang.cost_items.map((costItem) => ({
expense_nonstocks: values.expense_nonstocks.map(
(expenseNonstock) => ({
kandang_id: expenseNonstock.kandang_id,
cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number,
total_cost: parseFloat(String(costItem.total_cost)) as number,
price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '',
})),
})
@@ -179,53 +179,54 @@ const ExpenseRequestForm = ({
formik.setFieldValue('location', val);
formik.setFieldValue('kandangs', []);
formik.setFieldValue('cost_per_kandangs', []);
formik.setFieldValue('expense_nonstocks', []);
};
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs);
const newCostPerKandangs = [...(formik.values.cost_per_kandangs ?? [])];
const newExpenseNonstocks = [...(formik.values.expense_nonstocks ?? [])];
// add new cost_per_kandangs
// add new expense_nonstocks
kandangs.forEach((kandangItem) => {
const isKandangExistInCostPerKandangs = newCostPerKandangs.find(
(costPerKandangItem) => costPerKandangItem.kandang_id === kandangItem.id
const isKandangExistInExpenseNonstocks = newExpenseNonstocks.find(
(expenseNonstockItem) =>
expenseNonstockItem.kandang_id === kandangItem.id
);
if (isKandangExistInCostPerKandangs) return;
if (isKandangExistInExpenseNonstocks) return;
newCostPerKandangs.push({
newExpenseNonstocks.push({
kandang_id: kandangItem.id,
cost_items: [
{
nonstock: undefined,
quantity: undefined,
total_cost: undefined,
price: undefined,
notes: '',
},
],
});
});
// prune cost_per_kandangs
// prune expense_nonstocks
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedCostPerKandangsIdx: number[] = [];
const deletedExpenseNonstocksIdx: number[] = [];
newCostPerKandangs.forEach((costPerKandang, idx) => {
const isCostPerKandangValid = kandangIds.has(costPerKandang.kandang_id);
newExpenseNonstocks.forEach((expenseNonstock, idx) => {
const isExpenseNonstockValid = kandangIds.has(expenseNonstock.kandang_id);
if (!isCostPerKandangValid) {
deletedCostPerKandangsIdx.push(idx);
if (!isExpenseNonstockValid) {
deletedExpenseNonstocksIdx.push(idx);
}
});
deletedCostPerKandangsIdx.forEach((deletedCostPerKandangIdx) => {
newCostPerKandangs.splice(deletedCostPerKandangIdx, 1);
deletedExpenseNonstocksIdx.forEach((deletedExpenseNonstockIdx) => {
newExpenseNonstocks.splice(deletedExpenseNonstockIdx, 1);
});
formik.setFieldValue('cost_per_kandangs', newCostPerKandangs);
formik.setFieldValue('expense_nonstocks', newExpenseNonstocks);
};
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -41,28 +41,28 @@ const ExpenseRequestKandangDetailExpense: React.FC<
val: OptionType | OptionType[] | null
) => {
formik.setFieldTouched(
`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
true
);
formik.setFieldValue(
`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
val
);
};
const addExpenseItemHandler = (kandangExpenseIdx: number) => {
const newExpensesValue = [
...formik.values.cost_per_kandangs[kandangExpenseIdx].cost_items,
...formik.values.expense_nonstocks[kandangExpenseIdx].cost_items,
{
nonstock: undefined,
total_cost: undefined,
price: undefined,
quantity: undefined,
notes: '',
},
];
formik.setFieldValue(
`cost_per_kandangs[${kandangExpenseIdx}].cost_items`,
`expense_nonstocks[${kandangExpenseIdx}].cost_items`,
newExpensesValue
);
};
@@ -71,28 +71,28 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx: number,
expenseIdx: number
) => {
const path = `cost_per_kandangs[${kandangExpenseIdx}].cost_items`;
const path = `expense_nonstocks[${kandangExpenseIdx}].cost_items`;
// trims values, errors, and touched at expenseIdx
removeArrayItemAndSync(formik, path, expenseIdx);
};
const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'total_cost' | 'notes',
column: 'nonstock' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number,
expenseIdx: number
) => {
return (
formik.touched.cost_per_kandangs?.[kandangExpenseIdx]?.cost_items?.[
formik.touched.expense_nonstocks?.[kandangExpenseIdx]?.cost_items?.[
expenseIdx
]?.[column] &&
Boolean(
formik.errors.cost_per_kandangs?.[kandangExpenseIdx] instanceof
formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof
Object &&
formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx
] instanceof Object &&
formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx
]?.[column]
)
@@ -113,7 +113,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div>
<div className='w-full flex flex-col gap-6'>
{(formik.values.cost_per_kandangs.length === 0 ||
{(formik.values.expense_nonstocks.length === 0 ||
!formik.values.supplier?.value) && (
<div>
<p className='text-sm text-gray-400 text-center'>
@@ -122,9 +122,9 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div>
)}
{formik.values.cost_per_kandangs.length > 0 &&
{formik.values.expense_nonstocks.length > 0 &&
formik.values.supplier?.value &&
formik.values.cost_per_kandangs.map(
formik.values.expense_nonstocks.map(
(kandangExpense, kandangExpenseIdx) => {
const kandangName = formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id
@@ -147,7 +147,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Harga Satuan</th>
<th>Catatan</th>
{type !== 'detail' && <th>Aksi</th>}
</tr>
@@ -178,10 +178,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'>
<NumberInput
required
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
name={`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
placeholder='Masukkan Total Kuantitas'
value={
formik.values.cost_per_kandangs[
formik.values.expense_nonstocks[
kandangExpenseIdx
].cost_items[expenseIdx].quantity ?? ''
}
@@ -198,18 +198,17 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'>
<NumberInput
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`}
placeholder='Masukkan Total Biaya'
name={`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Harga Satuan'
value={
formik.values.cost_per_kandangs[
formik.values.expense_nonstocks[
kandangExpenseIdx
].cost_items[expenseIdx].total_cost ??
''
].cost_items[expenseIdx].price ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'total_cost',
'price',
kandangExpenseIdx,
expenseIdx
)}
@@ -224,10 +223,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'>
<TextInput
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
name={`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
placeholder='Tuliskan catatan'
value={
formik.values.cost_per_kandangs[
formik.values.expense_nonstocks[
kandangExpenseIdx
].cost_items[expenseIdx].notes ?? ''
}
@@ -224,7 +224,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
{ label: 'Vendor', value: expense?.supplier.name },
{
label: 'Tanggal Transaksi',
value: formatDate(expense?.expense_date, 'DD MMMM YYYY'),
value: formatDate(expense?.transaction_date, 'DD MMMM YYYY'),
},
{
label: 'Tanggal Realisasi',
@@ -326,7 +326,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRequestTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseRequestTotal += item.total_price)
(item) => (expenseRequestTotal += item.price)
);
return (
@@ -374,7 +374,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya
Harga Satuan
</Text>
</View>
<View
@@ -424,7 +424,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(pengajuan.total_price)}
{formatCurrency(pengajuan.price)}
</Text>
</View>
<View
@@ -484,7 +484,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRealizationTotal = 0;
kandangExpense.realisasi?.forEach(
(item) => (expenseRealizationTotal += item.total_price)
(item) => (expenseRealizationTotal += item.price)
);
return (
@@ -532,7 +532,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya
Harga Satuan
</Text>
</View>
<View
@@ -582,7 +582,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(realisasi.total_price)}
{formatCurrency(realisasi.price)}
</Text>
</View>
<View