mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-21 05:45:46 +00:00
Merge branch 'development' into 'staging'
Development See merge request mbugroup/lti-web-client!215
This commit is contained in:
Generated
+7
@@ -17,6 +17,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"formik": "^2.4.6",
|
||||
"html-to-image": "^1.11.13",
|
||||
"input-otp": "^1.4.2",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
@@ -7380,6 +7381,12 @@
|
||||
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"formik": "^2.4.6",
|
||||
"html-to-image": "^1.11.13",
|
||||
"input-otp": "^1.4.2",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
|
||||
@@ -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') : '';
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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, useEffect } 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,
|
||||
@@ -31,10 +34,10 @@ 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 toast from 'react-hot-toast';
|
||||
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 = (
|
||||
@@ -49,12 +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 {
|
||||
@@ -105,19 +118,22 @@ 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) => {
|
||||
// Save filter values to store
|
||||
setFilterValues(values);
|
||||
|
||||
handleApplyFilter({
|
||||
start_date: values.startDate || '',
|
||||
end_date: values.endDate || '',
|
||||
@@ -132,8 +148,10 @@ const DashboardProduction = () => {
|
||||
|
||||
const handleResetFilter = () => {
|
||||
formik.resetForm();
|
||||
resetFilterValues(); // Clear stored filter values
|
||||
setAnalysisMode('OVERVIEW');
|
||||
setEndpointUrl('/dashboards');
|
||||
setSelectedLocationIds([]);
|
||||
};
|
||||
|
||||
const handleApplyFilter = (values: DashboardFilter) => {
|
||||
@@ -156,25 +174,33 @@ const DashboardProduction = () => {
|
||||
refreshDashboardProductionData();
|
||||
};
|
||||
|
||||
// ===== 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 = () => {
|
||||
setExporting(true);
|
||||
const handleExportPDF = async () => {
|
||||
await generateDashboardPDF({
|
||||
filterValues: formik.values,
|
||||
statsRef,
|
||||
allChartsRef,
|
||||
setExporting,
|
||||
});
|
||||
};
|
||||
|
||||
// Wait for state to render, then trigger print
|
||||
useEffect(() => {
|
||||
if (exporting) {
|
||||
const timer = setTimeout(() => {
|
||||
window.print();
|
||||
setExporting(false);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [exporting]);
|
||||
|
||||
if (isLoadingDashboardProductionData) {
|
||||
return (
|
||||
<div className='w-full min-h-screen flex items-center justify-center'>
|
||||
@@ -219,34 +245,71 @@ const DashboardProduction = () => {
|
||||
</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>
|
||||
|
||||
@@ -475,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 || [];
|
||||
}
|
||||
|
||||
@@ -303,7 +315,7 @@ const DashboardLineChart = ({
|
||||
// For COMPARISON mode, use the first available comparison chart
|
||||
if (isComparisonCharts(data.charts)) {
|
||||
const chartData =
|
||||
data.charts.location ||
|
||||
data.charts.farm ||
|
||||
data.charts.flock ||
|
||||
data.charts.kandang;
|
||||
|
||||
@@ -353,7 +365,7 @@ const DashboardLineChart = ({
|
||||
isComparisonCharts(data.charts)
|
||||
) {
|
||||
const comparisonChart =
|
||||
data.charts.location ||
|
||||
data.charts.farm ||
|
||||
data.charts.flock ||
|
||||
data.charts.kandang;
|
||||
seriesData = comparisonChart?.series || [];
|
||||
@@ -401,7 +413,7 @@ const DashboardLineChart = ({
|
||||
isComparisonCharts(data.charts)
|
||||
) {
|
||||
const comparisonChart =
|
||||
data.charts.location ||
|
||||
data.charts.farm ||
|
||||
data.charts.flock ||
|
||||
data.charts.kandang;
|
||||
seriesData = comparisonChart?.series || [];
|
||||
@@ -452,11 +464,84 @@ const DashboardLineChart = ({
|
||||
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>
|
||||
);
|
||||
}}
|
||||
formatter={(
|
||||
value: number | undefined,
|
||||
name: string | undefined
|
||||
) => {
|
||||
if (value === undefined || name === undefined) return ['', ''];
|
||||
if (
|
||||
value === undefined ||
|
||||
name === undefined ||
|
||||
name.startsWith('STD. ')
|
||||
)
|
||||
return [undefined, undefined];
|
||||
|
||||
// Get series data to find the unit
|
||||
let seriesData: DashboardChartsSeries[] = [];
|
||||
@@ -470,7 +555,7 @@ const DashboardLineChart = ({
|
||||
isComparisonCharts(data.charts)
|
||||
) {
|
||||
const comparisonChart =
|
||||
data.charts.location ||
|
||||
data.charts.farm ||
|
||||
data.charts.flock ||
|
||||
data.charts.kandang;
|
||||
seriesData = comparisonChart?.series || [];
|
||||
@@ -478,9 +563,9 @@ const DashboardLineChart = ({
|
||||
|
||||
// Find the series that matches this line's name
|
||||
const series = seriesData.find((s) => s.label === name);
|
||||
const unit = series?.unit || '';
|
||||
const id = series?.id || '';
|
||||
|
||||
return [`${value} ${unit}`, name];
|
||||
return [value, id];
|
||||
}}
|
||||
/>
|
||||
{/* Dynamic Line rendering based on visible series */}
|
||||
@@ -497,9 +582,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 || [];
|
||||
}
|
||||
|
||||
@@ -557,7 +640,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;
|
||||
dataset = comparisonChart?.dataset || [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@/config/constant';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BankApi } from '@/services/api/master-data';
|
||||
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
@@ -65,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
|
||||
@@ -194,20 +199,25 @@ 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' },
|
||||
@@ -336,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>;
|
||||
},
|
||||
@@ -360,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)',
|
||||
@@ -468,25 +478,41 @@ 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,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
@@ -497,13 +523,6 @@ const FinanceTable = () => {
|
||||
onMenuScrollToBottom={bankLoadMore}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
options={partyTypeOptions}
|
||||
label='Pihak'
|
||||
value={selectedPartyType}
|
||||
onChange={partyTypeChangeHandler}
|
||||
isClearable
|
||||
/>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
label='Cari'
|
||||
|
||||
@@ -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 || '',
|
||||
@@ -153,6 +159,7 @@ const FormFinanceAdd = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,6 +175,7 @@ const FormFinanceAdd = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,6 +215,7 @@ const FormFinanceAdd = ({
|
||||
? formik.errors.party_type_option
|
||||
: ''
|
||||
}
|
||||
isDisabled={type === 'edit' || isSupplier}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
@@ -245,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'
|
||||
@@ -263,6 +272,7 @@ const FormFinanceAdd = ({
|
||||
: ''
|
||||
}
|
||||
required
|
||||
disabled={isSupplier}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Metode Pembayaran'
|
||||
@@ -284,6 +294,7 @@ const FormFinanceAdd = ({
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
isDisabled={isSupplier}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Bank'
|
||||
@@ -324,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'}`}
|
||||
@@ -344,6 +356,7 @@ const FormFinanceAdd = ({
|
||||
}
|
||||
required
|
||||
readOnly
|
||||
disabled={isSupplier}
|
||||
/>
|
||||
<TextInput
|
||||
label='Nomor Referensi'
|
||||
@@ -363,6 +376,7 @@ const FormFinanceAdd = ({
|
||||
: ''
|
||||
}
|
||||
required
|
||||
disabled={isSupplier}
|
||||
/>
|
||||
<NumberInput
|
||||
label='Nominal'
|
||||
@@ -378,6 +392,7 @@ const FormFinanceAdd = ({
|
||||
: ''
|
||||
}
|
||||
required
|
||||
disabled={isSupplier}
|
||||
/>
|
||||
<TextArea
|
||||
label='Catatan'
|
||||
@@ -393,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'
|
||||
|
||||
+1
-7
@@ -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 || '',
|
||||
@@ -147,6 +149,7 @@ const FormFinanceAddInitialBalance = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,6 +169,7 @@ const FormFinanceAddInitialBalance = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -211,6 +215,7 @@ const FormFinanceAddInitialBalance = ({
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isDisabled={type === 'edit'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
@@ -277,7 +282,6 @@ const FormFinanceAddInitialBalance = ({
|
||||
? formik.errors.bank_id_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
<TextInput
|
||||
@@ -362,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 || '',
|
||||
@@ -103,6 +106,7 @@ const FormFinanceInjection = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,6 +123,7 @@ const FormFinanceInjection = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -230,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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -95,7 +95,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
isLoadingOptions: isLoadingWarehouses,
|
||||
loadMore: loadMoreWarehouses,
|
||||
rawData: warehouses,
|
||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search');
|
||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search', {
|
||||
flag: 'EKSPEDISI',
|
||||
});
|
||||
|
||||
// ===== SELECT INPUT DATA =====
|
||||
const {
|
||||
@@ -261,6 +263,47 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const prevSourceWarehouseIdRef = useRef<number | null>(
|
||||
formik.values.source_warehouse_id
|
||||
);
|
||||
|
||||
// ===== 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,
|
||||
@@ -347,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 || []),
|
||||
{
|
||||
@@ -363,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 =
|
||||
@@ -387,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 || []),
|
||||
{
|
||||
@@ -410,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 =
|
||||
@@ -437,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);
|
||||
@@ -482,7 +677,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[formik]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDeliveryCostChangeWrapper = useCallback(
|
||||
@@ -957,43 +1152,7 @@ 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}
|
||||
@@ -1057,41 +1216,7 @@ 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}
|
||||
@@ -1165,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',
|
||||
@@ -1213,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',
|
||||
@@ -1235,24 +1339,7 @@ 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}
|
||||
@@ -1379,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',
|
||||
@@ -1474,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',
|
||||
@@ -1500,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
|
||||
@@ -1568,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}
|
||||
@@ -1677,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',
|
||||
@@ -520,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>
|
||||
|
||||
@@ -361,6 +361,8 @@ const MarketingForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const memoSalesOrder = formik.values.sales_order;
|
||||
|
||||
// ================== FORM REPEATER HANDLER ==================
|
||||
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
|
||||
setIsLoading(true);
|
||||
@@ -471,13 +473,25 @@ const MarketingForm = ({
|
||||
}, [deleteModal]);
|
||||
|
||||
// ================== SALES ORDER HANDLER ==================
|
||||
const handleDeleteSO = useCallback((id: number) => {
|
||||
const currentProducts = formik.values.sales_order;
|
||||
formik.setFieldValue(
|
||||
'sales_order',
|
||||
currentProducts.filter((p) => p.id != id)
|
||||
);
|
||||
}, []);
|
||||
const handleDeleteSO = useCallback(
|
||||
(id: number) => {
|
||||
const currentProducts = formik.values.sales_order;
|
||||
formik.setFieldValue(
|
||||
'sales_order',
|
||||
currentProducts.filter((p) => p.id != id)
|
||||
);
|
||||
},
|
||||
[memoSalesOrder]
|
||||
);
|
||||
const handleEditSO = useCallback(
|
||||
(id: number) => {
|
||||
const currentProducts = formik.values.sales_order;
|
||||
const selectedProduct = currentProducts.find((p) => p.id == id);
|
||||
setSelectedMarketingProduct(selectedProduct ?? null);
|
||||
addSOModal.openModal();
|
||||
},
|
||||
[memoSalesOrder]
|
||||
);
|
||||
const handleBulkDeleteSO = useCallback(() => {
|
||||
const currentProducts = formik.values.sales_order;
|
||||
formik.setFieldValue(
|
||||
@@ -487,7 +501,7 @@ const MarketingForm = ({
|
||||
)
|
||||
);
|
||||
setRowSOSelection({});
|
||||
}, [selectedRowSOIds]);
|
||||
}, [selectedRowSOIds, memoSalesOrder]);
|
||||
const handleAddSOClick = useCallback(() => {
|
||||
setSelectedMarketingProduct(null);
|
||||
addSOModal.openModal();
|
||||
@@ -523,7 +537,7 @@ const MarketingForm = ({
|
||||
|
||||
addSOModal.closeModal();
|
||||
},
|
||||
[addSOModal]
|
||||
[addSOModal, memoSalesOrder]
|
||||
);
|
||||
|
||||
// ================== DELIVERY ORDER HANDLER ==================
|
||||
@@ -568,8 +582,30 @@ const MarketingForm = ({
|
||||
},
|
||||
[addDOModal]
|
||||
);
|
||||
|
||||
const memoSalesOrder = formik.values.sales_order;
|
||||
const handleDeleteDO = useCallback(
|
||||
async (id: number) => {
|
||||
setDeliveryOrderValues((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id
|
||||
? {
|
||||
...product,
|
||||
...{
|
||||
unit_price: '',
|
||||
total_weight: '',
|
||||
qty: '',
|
||||
avg_weight: '',
|
||||
total_price: '',
|
||||
delivery_date: '',
|
||||
},
|
||||
}
|
||||
: product
|
||||
)
|
||||
);
|
||||
addDOModal.closeModal();
|
||||
setSelectedDeliveryProduct(null);
|
||||
},
|
||||
[addDOModal]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
formik.setFieldValue('delivery_order', deliveryOrderValues);
|
||||
@@ -621,7 +657,9 @@ const MarketingForm = ({
|
||||
isClearable
|
||||
placeholder='Pilih Pelanggan'
|
||||
isDisabled={
|
||||
formType === 'add_deliver' || formType === 'edit_deliver'
|
||||
formType === 'add_deliver' ||
|
||||
formType === 'edit_deliver' ||
|
||||
formType === 'edit'
|
||||
}
|
||||
/>
|
||||
<DateInput
|
||||
@@ -652,6 +690,7 @@ const MarketingForm = ({
|
||||
setRowSelection={setRowSOSelection}
|
||||
selectedRowIds={selectedRowSOIds}
|
||||
onDelete={handleDeleteSO}
|
||||
onEdit={handleEditSO}
|
||||
onBulkDelete={handleBulkDeleteSO}
|
||||
onAddProductClick={handleAddSOClick}
|
||||
/>
|
||||
@@ -671,6 +710,7 @@ const MarketingForm = ({
|
||||
formType={formType}
|
||||
data={deliveryOrderValues}
|
||||
onEdit={handleEditDO}
|
||||
onDelete={handleDeleteDO}
|
||||
onAddProductClick={handleAddDOClick}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -106,6 +106,7 @@ const DeliveryOrderProductForm = ({
|
||||
await onUpdateForm?.(values.marketing_product_id as number, values);
|
||||
}
|
||||
handleResetForm();
|
||||
setSelectedProduct(null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -124,7 +125,7 @@ const DeliveryOrderProductForm = ({
|
||||
marketing_product: undefined,
|
||||
},
|
||||
});
|
||||
setSelectedProduct(null);
|
||||
// setSelectedProduct(null);
|
||||
};
|
||||
|
||||
const handleBlurField = (field: string) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ type SalesOrderProductSchemaType = {
|
||||
avg_weight: string | number | undefined;
|
||||
total_price: string | number | undefined;
|
||||
vehicle_number?: string | undefined;
|
||||
uom?: string | null | undefined;
|
||||
};
|
||||
|
||||
export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
|
||||
@@ -57,6 +58,7 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
|
||||
total_price: Yup.number()
|
||||
.min(1, 'Total Penjualan wajib diisi!')
|
||||
.required('Total Penjualan wajib diisi!'),
|
||||
uom: Yup.string().nullable().optional().notRequired(),
|
||||
});
|
||||
|
||||
export type SalesOrderProductFormValues = Yup.InferType<
|
||||
|
||||
@@ -61,16 +61,17 @@ const SalesOrderProductForm = ({
|
||||
const formik = useFormik<SalesOrderProductFormValues>({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
vehicle_number: initialValues?.vehicle_number || undefined,
|
||||
vehicle_number: initialValues?.vehicle_number || '',
|
||||
kandang_id: initialValues?.kandang_id || undefined,
|
||||
kandang: initialValues?.kandang || undefined,
|
||||
product_warehouse: initialValues?.product_warehouse || undefined,
|
||||
kandang: initialValues?.kandang || null,
|
||||
product_warehouse: initialValues?.product_warehouse || null,
|
||||
product_warehouse_id: initialValues?.product_warehouse_id || undefined,
|
||||
unit_price: initialValues?.unit_price || undefined,
|
||||
total_weight: initialValues?.total_weight || undefined,
|
||||
qty: initialValues?.qty || undefined,
|
||||
avg_weight: initialValues?.avg_weight || undefined,
|
||||
total_price: initialValues?.total_price || undefined,
|
||||
unit_price: initialValues?.unit_price || '',
|
||||
total_weight: initialValues?.total_weight || '',
|
||||
qty: initialValues?.qty || '',
|
||||
avg_weight: initialValues?.avg_weight || '',
|
||||
total_price: initialValues?.total_price || '',
|
||||
uom: initialValues?.uom || '',
|
||||
},
|
||||
validationSchema: SalesOrderProductSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -220,7 +221,19 @@ const SalesOrderProductForm = ({
|
||||
};
|
||||
|
||||
// ===== Formik Error List =====
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
|
||||
formik,
|
||||
{
|
||||
onBeforeSubmit(e) {
|
||||
e.preventDefault();
|
||||
handleBlurField(currentInput);
|
||||
formik.setFieldValue(
|
||||
'uom',
|
||||
isResponseSuccess(productData) ? productData?.data?.uom.name : ''
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -16,6 +16,7 @@ type DeliveryOrderProductTableProps = {
|
||||
data: DeliveryOrderProductFormValues[];
|
||||
formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onAddProductClick: () => void;
|
||||
};
|
||||
|
||||
@@ -23,10 +24,13 @@ const DeliveryOrderProductTable = ({
|
||||
data,
|
||||
formType,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddProductClick,
|
||||
}: DeliveryOrderProductTableProps) => {
|
||||
const onEditRef = useRef(onEdit);
|
||||
onEditRef.current = onEdit;
|
||||
const onDeleteRef = useRef(onDelete);
|
||||
onDeleteRef.current = onDelete;
|
||||
|
||||
const canAddData = data.filter((item) => !Boolean(item.qty));
|
||||
|
||||
@@ -144,16 +148,29 @@ const DeliveryOrderProductTable = ({
|
||||
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
|
||||
<>
|
||||
{props.row.original.qty && (
|
||||
<Button
|
||||
color='warning'
|
||||
className='px-2 py-1 text-sm'
|
||||
onClick={() =>
|
||||
onEditRef.current(props.row.original.id as number)
|
||||
}
|
||||
type='button'
|
||||
>
|
||||
<Icon icon='mdi:edit' width={16} height={16} /> Edit
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
color='warning'
|
||||
className='px-2 py-1 text-sm'
|
||||
onClick={() =>
|
||||
onEditRef.current(props.row.original.id as number)
|
||||
}
|
||||
type='button'
|
||||
>
|
||||
<Icon icon='mdi:edit' width={16} height={16} /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
color='error'
|
||||
className='px-2 py-1 text-sm'
|
||||
onClick={() =>
|
||||
onDeleteRef.current(props.row.original.id as number)
|
||||
}
|
||||
type='button'
|
||||
disabled={!!props.row.original.do_number}
|
||||
>
|
||||
<Icon icon='mdi:delete' width={16} height={16} /> Hapus
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!props.row.original.qty && '-'}
|
||||
</>
|
||||
|
||||
@@ -23,6 +23,7 @@ type SalesOrderProductTableProps = {
|
||||
>;
|
||||
selectedRowIds: number[];
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
onBulkDelete: () => void;
|
||||
onAddProductClick: () => void;
|
||||
};
|
||||
@@ -34,11 +35,14 @@ const SalesOrderProductTable = ({
|
||||
setRowSelection,
|
||||
selectedRowIds,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onBulkDelete,
|
||||
onAddProductClick,
|
||||
}: SalesOrderProductTableProps) => {
|
||||
const onDeleteRef = useRef(onDelete);
|
||||
onDeleteRef.current = onDelete;
|
||||
const onEditRef = useRef(onEdit);
|
||||
onEditRef.current = onEdit;
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
@@ -92,17 +96,26 @@ const SalesOrderProductTable = ({
|
||||
},
|
||||
{
|
||||
accessorFn: (row: SalesOrderProductFormValues) =>
|
||||
formatNumber(parseFloat(row.total_weight as string)),
|
||||
formatNumber(parseFloat(row.total_weight as string), undefined, 0, 5),
|
||||
header: 'Total Bobot (Kg)',
|
||||
},
|
||||
{
|
||||
accessorFn: (row: SalesOrderProductFormValues) =>
|
||||
formatNumber(parseFloat(row.qty as string)),
|
||||
header: 'Kuantitas',
|
||||
cell: ({ row }: { row: TanStack.Row<SalesOrderProductFormValues> }) =>
|
||||
formatNumber(
|
||||
parseFloat(row.original.qty as string),
|
||||
undefined,
|
||||
0,
|
||||
5
|
||||
) +
|
||||
' ' +
|
||||
(row.original.uom ?? ''),
|
||||
},
|
||||
{
|
||||
accessorFn: (row: SalesOrderProductFormValues) =>
|
||||
formatNumber(parseFloat(row.avg_weight as string)),
|
||||
formatNumber(parseFloat(row.avg_weight as string), undefined, 0, 5),
|
||||
header: 'Avg. Bobot (Kg)',
|
||||
},
|
||||
{
|
||||
@@ -116,6 +129,14 @@ const SalesOrderProductTable = ({
|
||||
props: TanStack.CellContext<SalesOrderProductFormValues, unknown>
|
||||
) => (
|
||||
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
|
||||
<Button
|
||||
color='warning'
|
||||
className='p-1'
|
||||
onClick={() => onEditRef.current(props.row.original.id as number)}
|
||||
type='button'
|
||||
>
|
||||
<Icon icon='mdi:pencil' width={16} height={16} /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
color='error'
|
||||
className='p-1'
|
||||
@@ -124,7 +145,7 @@ const SalesOrderProductTable = ({
|
||||
}
|
||||
type='button'
|
||||
>
|
||||
<Icon icon='mdi:trash' width={16} height={16} />
|
||||
<Icon icon='mdi:trash' width={16} height={16} /> Hapus
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -686,10 +686,18 @@ const RecordingTable = () => {
|
||||
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 || '-',
|
||||
@@ -722,12 +730,6 @@ const RecordingTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'warehouse.name',
|
||||
header: 'Gudang',
|
||||
cell: (props) => props.row.original.warehouse?.name,
|
||||
},
|
||||
{
|
||||
accessorKey: 'record_date',
|
||||
header: 'Waktu Recording',
|
||||
cell: (props) =>
|
||||
formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'),
|
||||
@@ -1011,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'
|
||||
@@ -1036,7 +1023,7 @@ const RecordingTable = () => {
|
||||
}}
|
||||
onClick={openApprovalHistory}
|
||||
>
|
||||
{getStatusText(approval.action)}
|
||||
{approval.step_name || approval.action}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -33,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;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -52,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({
|
||||
@@ -75,28 +75,19 @@ 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> =
|
||||
@@ -163,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 =
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
GROWING_RECORDING_APPROVAL_LINE,
|
||||
LAYING_RECORDING_APPROVAL_LINE,
|
||||
} from '@/config/approval-line';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
|
||||
interface RecordingFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -227,7 +228,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const [, setApprovalNotes] = useState('');
|
||||
const [recordingFormErrorMessage, setRecordingFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [, setNewRecordingData] = useState<Recording | null>(null);
|
||||
const [nextDayRecording, setNextDayRecording] =
|
||||
@@ -309,6 +309,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
// ===== PAYLOAD CREATION HELPERS =====
|
||||
const createGrowingPayload = useCallback(
|
||||
(values: RecordingGrowingFormValues) => {
|
||||
const depletions = values.depletions
|
||||
?.filter((d) => d.product_warehouse_id && d.qty)
|
||||
.map((depletion) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id!,
|
||||
qty: Number(depletion.qty) || 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
project_flock_kandang_id: values.project_flock_kandang_id,
|
||||
record_date: values.record_date,
|
||||
@@ -316,10 +323,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
product_warehouse_id: stock.product_warehouse_id,
|
||||
qty: Number(stock.qty) || 0,
|
||||
})),
|
||||
depletions: (values.depletions ?? []).map((depletion) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id,
|
||||
qty: Number(depletion.qty) || 0,
|
||||
})),
|
||||
...(depletions && depletions.length > 0 && { depletions }),
|
||||
};
|
||||
},
|
||||
[]
|
||||
@@ -327,25 +331,33 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
const createLayingPayload = useCallback(
|
||||
(values: RecordingLayingFormValues) => {
|
||||
return {
|
||||
project_flock_kandang_id: values.project_flock_kandang_id,
|
||||
record_date: values.record_date,
|
||||
stocks: (values.stocks ?? []).map((stock) => ({
|
||||
product_warehouse_id: stock.product_warehouse_id,
|
||||
qty: Number(stock.qty) || 0,
|
||||
})),
|
||||
depletions: (values.depletions ?? []).map((depletion) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id,
|
||||
const depletions = values.depletions
|
||||
?.filter((d) => d.product_warehouse_id && d.qty)
|
||||
.map((depletion) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id!,
|
||||
qty: Number(depletion.qty) || 0,
|
||||
})),
|
||||
eggs: (values.eggs ?? []).map((egg) => ({
|
||||
product_warehouse_id: egg.product_warehouse_id,
|
||||
}));
|
||||
|
||||
const eggs = values.eggs
|
||||
?.filter((e) => e.product_warehouse_id && e.qty && e.weight)
|
||||
.map((egg) => ({
|
||||
product_warehouse_id: egg.product_warehouse_id!,
|
||||
qty: Number(egg.qty) || 0,
|
||||
weight:
|
||||
typeof egg.weight === 'number'
|
||||
? egg.weight
|
||||
: parseFloat(String(egg.weight)) || 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
project_flock_kandang_id: values.project_flock_kandang_id,
|
||||
record_date: values.record_date,
|
||||
stocks: values.stocks.map((stock) => ({
|
||||
product_warehouse_id: stock.product_warehouse_id,
|
||||
qty: Number(stock.qty) || 0,
|
||||
})),
|
||||
...(depletions && depletions.length > 0 && { depletions }),
|
||||
...(eggs && eggs.length > 0 && { eggs }),
|
||||
};
|
||||
},
|
||||
[]
|
||||
@@ -905,10 +917,58 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
baseValues = getRecordingGrowingFormInitialValues(initialValues);
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
baseValues.location = selectedLocation
|
||||
? {
|
||||
value: Number(selectedLocation.value),
|
||||
label: selectedLocation.label,
|
||||
}
|
||||
: null;
|
||||
baseValues.location_id = selectedLocation
|
||||
? Number(selectedLocation.value)
|
||||
: 0;
|
||||
baseValues.project_flock = selectedProjectFlock
|
||||
? {
|
||||
value: Number(selectedProjectFlock.value),
|
||||
label: selectedProjectFlock.label,
|
||||
}
|
||||
: null;
|
||||
baseValues.project_flock_id = selectedProjectFlock
|
||||
? Number(selectedProjectFlock.value)
|
||||
: 0;
|
||||
baseValues.kandang = selectedKandang
|
||||
? {
|
||||
value: Number(selectedKandang.value),
|
||||
label: selectedKandang.label,
|
||||
}
|
||||
: null;
|
||||
baseValues.kandang_id = selectedKandang
|
||||
? Number(selectedKandang.value)
|
||||
: 0;
|
||||
}
|
||||
|
||||
if (projectFlockKandangDetail && (type === 'edit' || type === 'detail')) {
|
||||
baseValues.project_flock_kandang = {
|
||||
value: projectFlockKandangDetail.project_flock.id,
|
||||
label: projectFlockKandangDetail.project_flock.flock_name || '',
|
||||
baseValues = {
|
||||
...baseValues,
|
||||
project_flock_kandang: {
|
||||
value: projectFlockKandangDetail.project_flock?.id,
|
||||
label: projectFlockKandangDetail.project_flock?.flock_name || '',
|
||||
},
|
||||
project_flock: {
|
||||
value: projectFlockKandangDetail.project_flock?.id,
|
||||
label: projectFlockKandangDetail.project_flock?.flock_name || '',
|
||||
},
|
||||
project_flock_id: projectFlockKandangDetail.project_flock?.id,
|
||||
location: {
|
||||
value: projectFlockKandangDetail.project_flock?.location?.id,
|
||||
label: projectFlockKandangDetail.project_flock?.location?.name || '',
|
||||
},
|
||||
location_id: projectFlockKandangDetail.project_flock?.location?.id,
|
||||
kandang: {
|
||||
value: projectFlockKandangDetail.kandang?.id,
|
||||
label: projectFlockKandangDetail.kandang?.name || '',
|
||||
},
|
||||
kandang_id: projectFlockKandangDetail.kandang?.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -995,22 +1055,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
// ===== HELPER FUNCTIONS =====
|
||||
const getAvailableStock = useCallback(
|
||||
(productWarehouseId: number) => {
|
||||
@@ -1266,6 +1310,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
[formik, duplicateErrorShown]
|
||||
);
|
||||
|
||||
const { formErrorList, handleFormSubmit, close } = useFormikErrorList(formik);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectFlockKandangLookup?.project_flock_kandang_id) {
|
||||
const projectFlockKandangId =
|
||||
@@ -1655,10 +1701,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
<AlertErrorList formErrorList={formErrorList} onClose={close} />
|
||||
)}
|
||||
|
||||
{/* Basic Info Card */}
|
||||
@@ -2520,24 +2563,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th>
|
||||
Kondisi
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom '
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
Jumlah
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom '
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
</span>
|
||||
</th>
|
||||
<th>Kondisi</th>
|
||||
<th>Jumlah</th>
|
||||
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
|
||||
<th>Action</th>
|
||||
)}
|
||||
@@ -2615,7 +2642,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
required
|
||||
name={`depletions.${idx}.qty`}
|
||||
value={depletion.qty ?? ''}
|
||||
onChange={handleDepletionQtyChangeWrapper(idx)}
|
||||
@@ -2731,33 +2757,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th>
|
||||
Kondisi Telur
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom '
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
Jumlah
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom '
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
Berat (gram)
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom '
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
</span>
|
||||
</th>
|
||||
<th>Kondisi Telur</th>
|
||||
<th>Jumlah</th>
|
||||
<th>Berat (gram)</th>
|
||||
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
|
||||
<th>Action</th>
|
||||
)}
|
||||
@@ -2792,7 +2794,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)}
|
||||
<td>
|
||||
<SelectInput
|
||||
required
|
||||
value={
|
||||
eggProducts.find(
|
||||
(product) =>
|
||||
@@ -2835,7 +2836,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
required
|
||||
name={`eggs.${idx}.qty`}
|
||||
value={egg.qty ?? ''}
|
||||
onChange={handleEggQtyChangeWrapper(idx)}
|
||||
@@ -2860,7 +2860,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
required
|
||||
name={`eggs.${idx}.weight`}
|
||||
value={egg.weight ?? ''}
|
||||
onChange={handleEggWeightChangeWrapper(idx)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -300,7 +300,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
|
||||
<Text>Rata-Rata</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
|
||||
<Text>Harga Awal</Text>
|
||||
<Text>Harga/Unit</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
|
||||
<Text>Harga Akhir</Text>
|
||||
@@ -378,7 +378,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
|
||||
<Text>{formatNumber(item.average_weight)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>{formatCurrency(item.price)}</Text>
|
||||
<Text>{formatCurrency(item.unit_price)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>{formatCurrency(item.final_price)}</Text>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const generateCustomerPaymentExcel = (
|
||||
'Ekor/Qty': formatNumber(item.qty || 0),
|
||||
'Berat (Kg)': formatNumber(item.weight || 0),
|
||||
AVG: formatNumber(item.average_weight || 0),
|
||||
'Harga Awal': formatCurrency(item.price || 0),
|
||||
'Harga/Unit': formatCurrency(item.unit_price || 0),
|
||||
'Harga Akhir': formatCurrency(item.final_price || 0),
|
||||
Total: formatCurrency(item.total_price || 0),
|
||||
Pembayaran: formatCurrency(item.payment_amount || 0),
|
||||
@@ -62,7 +62,7 @@ export const generateCustomerPaymentExcel = (
|
||||
'Ekor/Qty': formatNumber(customerReport.summary.total_qty || 0),
|
||||
'Berat (Kg)': formatNumber(customerReport.summary.total_weight || 0),
|
||||
AVG: '',
|
||||
'Harga Awal': '',
|
||||
'Harga/Unit': '',
|
||||
'Harga Akhir': formatCurrency(
|
||||
customerReport.summary.total_final_amount || 0
|
||||
),
|
||||
@@ -89,7 +89,7 @@ export const generateCustomerPaymentExcel = (
|
||||
{ wch: 10 }, // Ekor/Qty
|
||||
{ wch: 12 }, // Berat
|
||||
{ wch: 10 }, // AVG
|
||||
{ wch: 15 }, // Harga Awal
|
||||
{ wch: 15 }, // Harga/Unit
|
||||
{ wch: 15 }, // Harga Akhir
|
||||
{ wch: 15 }, // Total
|
||||
{ wch: 15 }, // Pembayaran
|
||||
|
||||
@@ -106,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 =====
|
||||
@@ -159,7 +163,7 @@ const CustomerPaymentTab = () => {
|
||||
isSubmitted
|
||||
? () => {
|
||||
const params = {
|
||||
customer_id:
|
||||
customer_ids:
|
||||
filterCustomer.length > 0
|
||||
? filterCustomer.map((v) => String(v.value)).join(',')
|
||||
: undefined,
|
||||
@@ -180,7 +184,7 @@ const CustomerPaymentTab = () => {
|
||||
: null,
|
||||
([, params]) =>
|
||||
FinanceApi.getCustomerPaymentReport(
|
||||
params.customer_id,
|
||||
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,
|
||||
@@ -203,7 +207,7 @@ const CustomerPaymentTab = () => {
|
||||
CustomerPaymentReport[] | null
|
||||
> => {
|
||||
const params = {
|
||||
customer_id:
|
||||
customer_ids:
|
||||
filterCustomer.length > 0
|
||||
? filterCustomer.map((v) => String(v.value)).join(',')
|
||||
: undefined,
|
||||
@@ -219,7 +223,7 @@ const CustomerPaymentTab = () => {
|
||||
};
|
||||
|
||||
const response = await FinanceApi.getCustomerPaymentReport(
|
||||
params.customer_id,
|
||||
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,
|
||||
@@ -336,7 +340,9 @@ const CustomerPaymentTab = () => {
|
||||
const value = props.row.original.aging_day;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value && value > 0 ? `${formatNumber(value)} hari` : '-'}
|
||||
{value !== null && value !== undefined
|
||||
? `${formatNumber(value)} hari`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -405,12 +411,12 @@ 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: () => (
|
||||
@@ -510,7 +516,7 @@ const CustomerPaymentTab = () => {
|
||||
status: getPaymentStatusIndicatorColor(value),
|
||||
}}
|
||||
>
|
||||
<span>{getPaymentStatusText(value)}</span>
|
||||
<span className='capitalize'>{getPaymentStatusText(value)}</span>
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -246,7 +246,12 @@ const createPDFDocument = (
|
||||
<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>
|
||||
@@ -301,7 +306,12 @@ const createPDFDocument = (
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>{formatCurrency(group.egg_hpp_rp_per_kg)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<View
|
||||
style={[
|
||||
pdfStyles.tableCellRight,
|
||||
{ flex: 1.2, borderRightWidth: 0 },
|
||||
]}
|
||||
>
|
||||
<Text>{formatCurrency(group.egg_value_rp)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -347,7 +357,12 @@ const createPDFDocument = (
|
||||
<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>
|
||||
@@ -356,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>
|
||||
@@ -410,11 +420,199 @@ const createPDFDocument = (
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
|
||||
<Text>{formatCurrency(item.egg_hpp_rp_per_kg)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<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: {
|
||||
@@ -77,7 +80,12 @@ const HppPerKandangTab = () => {
|
||||
options: kandangOptions,
|
||||
isLoadingOptions: isLoadingKandangs,
|
||||
loadMore: loadMoreKandangs,
|
||||
} = useSelect(KandangApi.basePath, 'id', 'name', 'search');
|
||||
} = useSelect(
|
||||
ProjectFlockKandangApi.basePath,
|
||||
'id',
|
||||
'name_with_period',
|
||||
'search'
|
||||
);
|
||||
|
||||
const showUnrecordedOptions: OptionType[] = [
|
||||
{ value: 'false', label: 'Sembunyikan' },
|
||||
@@ -127,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<
|
||||
@@ -136,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>>(
|
||||
@@ -325,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,
|
||||
@@ -384,7 +453,12 @@ const HppPerKandangTab = () => {
|
||||
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`;
|
||||
|
||||
@@ -488,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>,
|
||||
},
|
||||
@@ -741,6 +815,8 @@ const HppPerKandangTab = () => {
|
||||
onInputChange={setAreaInputValue}
|
||||
onMenuScrollToBottom={loadMoreAreas}
|
||||
isLoading={isLoadingAreas}
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
@@ -757,6 +833,8 @@ const HppPerKandangTab = () => {
|
||||
onInputChange={setLocationInputValue}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isLoading={isLoadingLocations}
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
@@ -773,6 +851,8 @@ const HppPerKandangTab = () => {
|
||||
onInputChange={setKandangInputValue}
|
||||
onMenuScrollToBottom={loadMoreKandangs}
|
||||
isLoading={isLoadingKandangs}
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
@@ -792,6 +872,8 @@ const HppPerKandangTab = () => {
|
||||
placeholder='Masukkan bobot maximum'
|
||||
value={tableFilterState.weight_max}
|
||||
onChange={weightMaxChangeHandler}
|
||||
isError={!!weightMaxError}
|
||||
errorMessage={weightMaxError}
|
||||
/>
|
||||
</div>
|
||||
<DateInput
|
||||
@@ -818,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
ClosingSapronakCalculation,
|
||||
ClosingProductionData,
|
||||
ClosingHppExpedition,
|
||||
ClosingIncomingSapronakSummary,
|
||||
ClosingOutgoingSapronakSummary,
|
||||
} from '@/types/api/closing';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import { httpClient, httpClientFetcher } from '@/services/http/client';
|
||||
@@ -62,6 +64,14 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
|
||||
);
|
||||
}
|
||||
|
||||
async getAllIncomingSapronakSummaryFetcher(
|
||||
endpoint: string
|
||||
): Promise<BaseApiResponse<ClosingIncomingSapronakSummary[]>> {
|
||||
return await httpClientFetcher<
|
||||
BaseApiResponse<ClosingIncomingSapronakSummary[]>
|
||||
>(endpoint);
|
||||
}
|
||||
|
||||
async getAllOutgoingSapronakFetcher(
|
||||
endpoint: string
|
||||
): Promise<BaseApiResponse<ClosingOutgoingSapronak[]>> {
|
||||
@@ -70,6 +80,14 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
|
||||
);
|
||||
}
|
||||
|
||||
async getAllOutgoingSapronakSummaryFetcher(
|
||||
endpoint: string
|
||||
): Promise<BaseApiResponse<ClosingOutgoingSapronakSummary[]>> {
|
||||
return await httpClientFetcher<
|
||||
BaseApiResponse<ClosingOutgoingSapronakSummary[]>
|
||||
>(endpoint);
|
||||
}
|
||||
|
||||
async getGeneralInfo(
|
||||
id: number
|
||||
): Promise<BaseApiResponse<ClosingGeneralInformation> | undefined> {
|
||||
|
||||
@@ -48,8 +48,7 @@ export class SalesOrderService extends BaseApiService<
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error approve marketing:', error);
|
||||
return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +71,7 @@ export class SalesOrderService extends BaseApiService<
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error bulk approve marketing:', error);
|
||||
return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +93,7 @@ export class SalesOrderService extends BaseApiService<
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error delivery marketing:', error);
|
||||
return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,7 @@ export class ChickinService extends BaseApiService<
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error approve chickin:', error);
|
||||
return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class FinanceApiService extends BaseApiService<
|
||||
}
|
||||
|
||||
async getCustomerPaymentReport(
|
||||
customer_id?: string,
|
||||
customer_ids?: string,
|
||||
// TODO: Uncomment when BE is ready
|
||||
// sales_id?: string,
|
||||
// filter_by?: 'do_date',
|
||||
@@ -28,7 +28,7 @@ export class FinanceApiService extends BaseApiService<
|
||||
{
|
||||
method: 'GET',
|
||||
params: {
|
||||
customer_id: customer_id,
|
||||
customer_ids: customer_ids,
|
||||
// TODO: Uncomment when BE is ready
|
||||
// sales_id: sales_id,
|
||||
// filter_by: filter_by,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { createDashboardFilterSlice } from '@/stores/dashboard/slices/dashboard-filter.slice';
|
||||
import { DashboardFilterSlice } from '@/types/stores';
|
||||
|
||||
export type DashboardStore = DashboardFilterSlice;
|
||||
|
||||
export const useDashboardStore = create<DashboardStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(...args) => ({
|
||||
...createDashboardFilterSlice(...args),
|
||||
}),
|
||||
{
|
||||
name: 'dashboard-filter-cache',
|
||||
}
|
||||
),
|
||||
{
|
||||
name: 'DashboardStore',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useDashboardStore } from './dashboard.store';
|
||||
export type { DashboardStore } from './dashboard.store';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { DashboardFilterSlice } from '@/types/stores';
|
||||
import { StateCreator } from 'zustand';
|
||||
|
||||
export const createDashboardFilterSlice: StateCreator<
|
||||
DashboardFilterSlice,
|
||||
[],
|
||||
[],
|
||||
DashboardFilterSlice
|
||||
> = (set) => ({
|
||||
// Initial state
|
||||
filterValues: {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
analysisMode: 'OVERVIEW',
|
||||
comparisonType: undefined,
|
||||
location: [],
|
||||
locationIds: undefined,
|
||||
flock: undefined,
|
||||
flockIds: undefined,
|
||||
kandang: undefined,
|
||||
kandangIds: undefined,
|
||||
},
|
||||
|
||||
// Actions
|
||||
setFilterValues: (values) => set({ filterValues: values }),
|
||||
|
||||
resetFilterValues: () => {
|
||||
return set({
|
||||
filterValues: {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
analysisMode: 'OVERVIEW',
|
||||
comparisonType: undefined,
|
||||
location: [],
|
||||
locationIds: undefined,
|
||||
flock: undefined,
|
||||
flockIds: undefined,
|
||||
kandang: undefined,
|
||||
kandangIds: undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
Vendored
+9
@@ -11,6 +11,7 @@ import { Product } from '@type/api/master-data/product';
|
||||
import { Customer } from '@type/api/master-data/customer';
|
||||
import { BaseMetadata } from '@/types/api/api-general';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
import { BaseUom } from '@/types/api/master-data/uom';
|
||||
|
||||
export type BaseSales = {
|
||||
id: number;
|
||||
@@ -104,8 +105,16 @@ export type ClosingIncomingSapronak = {
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type ClosingIncomingSapronakSummary = {
|
||||
category: string;
|
||||
total_qty: number;
|
||||
uom: BaseUom;
|
||||
};
|
||||
|
||||
export type ClosingOutgoingSapronak = ClosingIncomingSapronak;
|
||||
|
||||
export type ClosingOutgoingSapronakSummary = ClosingIncomingSapronakSummary;
|
||||
|
||||
export type ClosingProductionData = {
|
||||
purchase: {
|
||||
initial_population: number;
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ export interface Dashboard {
|
||||
}
|
||||
|
||||
export interface DashboardComparisonCharts {
|
||||
location: DashboardCharts;
|
||||
farm: DashboardCharts;
|
||||
flock: DashboardCharts;
|
||||
kandang: DashboardCharts;
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -34,7 +34,7 @@ export type BaseExpense = {
|
||||
nonstock_id: number;
|
||||
qty: number;
|
||||
price: number;
|
||||
note?: string;
|
||||
notes?: string;
|
||||
nonstock: Pick<BaseNonstock, 'id' | 'name' | 'flags'>;
|
||||
created_at: string;
|
||||
}[];
|
||||
@@ -43,7 +43,7 @@ export type BaseExpense = {
|
||||
expense_nonstock_id: number;
|
||||
qty: number;
|
||||
price: number;
|
||||
note?: string;
|
||||
notes?: string;
|
||||
nonstock: Pick<BaseNonstock, 'id' | 'name' | 'flags'>;
|
||||
created_at: string;
|
||||
}[];
|
||||
|
||||
@@ -10,6 +10,7 @@ export type BaseProjectFlockKandang = {
|
||||
kandang_id: number;
|
||||
kandang: Kandang;
|
||||
project_flock: ProjectFlock;
|
||||
name_with_period?: string;
|
||||
approval: BaseApproval;
|
||||
chickins?: Chickin[];
|
||||
available_qtys?: AvailableQty[];
|
||||
|
||||
+9
-5
@@ -1,6 +1,8 @@
|
||||
import { BaseApproval, BaseMetadata, User } from '@/types/api/api-general';
|
||||
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
|
||||
export type ProductionStandard = {
|
||||
id: number;
|
||||
@@ -87,6 +89,8 @@ export type Recording = BaseMetadata &
|
||||
approval?: BaseApproval;
|
||||
created_user: User;
|
||||
warehouse?: Warehouse;
|
||||
kandang?: Kandang;
|
||||
location?: Location;
|
||||
product_category?: 'GROWING' | 'LAYING';
|
||||
depletions?: RecordingDepletion[];
|
||||
stocks?: RecordingStock[];
|
||||
@@ -107,15 +111,15 @@ export type CreateGrowingRecordingPayload = {
|
||||
qty: number;
|
||||
}[];
|
||||
depletions?: {
|
||||
product_warehouse_id: number;
|
||||
qty: number;
|
||||
product_warehouse_id?: number;
|
||||
qty?: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type CreateEggPayload = {
|
||||
product_warehouse_id: number;
|
||||
qty: number;
|
||||
weight: number;
|
||||
product_warehouse_id?: number;
|
||||
qty?: number;
|
||||
weight?: number;
|
||||
};
|
||||
|
||||
export type CreateLayingRecordingPayload = CreateGrowingRecordingPayload & {
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export type CustomerPaymentRow = {
|
||||
qty: number;
|
||||
weight: number;
|
||||
average_weight: number;
|
||||
price: number;
|
||||
unit_price: number;
|
||||
final_price: number;
|
||||
total_price: number;
|
||||
payment_amount: number;
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ import { Kandang } from '@/types/api/master-data/kandang';
|
||||
export type HppPerKandangRow = {
|
||||
id: number;
|
||||
kandang: Kandang;
|
||||
name_with_periode?: string;
|
||||
weight_range: {
|
||||
weight_min: number;
|
||||
weight_max: number;
|
||||
|
||||
Vendored
+11
@@ -1,3 +1,4 @@
|
||||
import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
|
||||
import type { ProductionStandardRepeaterFormSchemaValues } from '@/components/pages/master-data/production-standard/form/ProductionStandardForm.schema';
|
||||
import type {
|
||||
UniformityFormData,
|
||||
@@ -70,3 +71,13 @@ export type UniformitySlice = {
|
||||
setCreatedUniformity: (data: UniformityDetail | null) => void;
|
||||
resetUniformity: () => void;
|
||||
};
|
||||
|
||||
// Dashboard Filter Slice
|
||||
export type DashboardFilterSlice = {
|
||||
// State
|
||||
filterValues: DashboardFilterType;
|
||||
|
||||
// Actions
|
||||
setFilterValues: (values: DashboardFilterType) => void;
|
||||
resetFilterValues: () => void;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user