mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Merge branch 'feat/refactor-report-module-ui' into 'development'
[FEAT/FE] Refactor UI on Report Module and Additional Adjustment (Project Flock, Product Stock, Marketing) See merge request mbugroup/lti-web-client!324
This commit is contained in:
@@ -1,13 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable';
|
import ReportExpenseTabs from '@/components/pages/report/expense/ReportExpenseTabs';
|
||||||
|
|
||||||
const ReportExpense = () => {
|
const ReportExpense = () => {
|
||||||
return (
|
return <ReportExpenseTabs />;
|
||||||
<div className='w-full p-4'>
|
|
||||||
<ReportExpenseTable />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ReportExpense;
|
export default ReportExpense;
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import MarketingReportContent from '@/components/pages/report/marketing/MarketingReportContent';
|
import MarketingReportContent from '@/components/pages/report/marketing/MarketingTabs';
|
||||||
|
|
||||||
const MarketingReportPage = () => {
|
const MarketingReportPage = () => {
|
||||||
return (
|
return <MarketingReportContent />;
|
||||||
<section className='w-full p-4'>
|
|
||||||
<MarketingReportContent />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MarketingReportPage;
|
export default MarketingReportPage;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import ProductionResultContent from '@/components/pages/report/production-result/ProductionResultContent';
|
import ProductionResultTabs from '@/components/pages/report/production-result/ProductionResultTabs';
|
||||||
|
|
||||||
const ProductionResultReportPage = () => {
|
const ProductionResultReportPage = () => {
|
||||||
return (
|
return (
|
||||||
<section className='w-full max-w-full'>
|
<section className='w-full max-w-full'>
|
||||||
<ProductionResultContent />
|
<ProductionResultTabs />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -284,23 +284,22 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
|||||||
isDisabled && !readOnly,
|
isDisabled && !readOnly,
|
||||||
'bg-transparent! cursor-not-allowed!': readOnly,
|
'bg-transparent! cursor-not-allowed!': readOnly,
|
||||||
'cursor-pointer!': !readOnly && !isDisabled,
|
'cursor-pointer!': !readOnly && !isDisabled,
|
||||||
'border-red-500! ring-2 ring-red-200': isError,
|
'border-error!': isError,
|
||||||
|
'ring-2 ring-error/20': isError,
|
||||||
'border-indigo-500 ring-2 ring-indigo-200':
|
'border-indigo-500 ring-2 ring-indigo-200':
|
||||||
isFocused && !startAdornment,
|
isFocused && !startAdornment && !isError,
|
||||||
'border-base-content/10!': !isError && !isFocused,
|
'border-base-content/10!': !isError && !isFocused,
|
||||||
'rounded-l-none!': inputPrefix && !startAdornment,
|
'rounded-l-none!': inputPrefix && !startAdornment,
|
||||||
'rounded-r-none!': inputSuffix && !startAdornment,
|
'rounded-r-none!': inputSuffix && !startAdornment,
|
||||||
}),
|
}),
|
||||||
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
||||||
placeholder: () =>
|
placeholder: () =>
|
||||||
cn({
|
cn('text-gray-400 text-sm leading-tight', {
|
||||||
'text-gray-400 text-sm leading-tight': !isError,
|
'text-error!': isError,
|
||||||
'text-red-300!': isError,
|
|
||||||
}),
|
}),
|
||||||
singleValue: () =>
|
singleValue: () =>
|
||||||
cn({
|
cn('m-0! text-gray-900 text-sm leading-tight', {
|
||||||
'm-0! text-gray-900 text-sm leading-tight': !isError,
|
'text-error!': isError && !readOnly,
|
||||||
'text-error!': isError,
|
|
||||||
'text-gray-900!': readOnly,
|
'text-gray-900!': readOnly,
|
||||||
}),
|
}),
|
||||||
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
||||||
@@ -404,32 +403,26 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
|||||||
className={cn('w-full', className?.select)}
|
className={cn('w-full', className?.select)}
|
||||||
classNames={{
|
classNames={{
|
||||||
control: ({ isFocused, isDisabled }) =>
|
control: ({ isFocused, isDisabled }) =>
|
||||||
cn(
|
cn('w-full border transition-shadow rounded-lg!', {
|
||||||
'w-full border transition-shadow',
|
'bg-base-100!': !isDisabled && !readOnly,
|
||||||
// Gunakan rounded-lg untuk semua kasus
|
'bg-base-200! text-gray-400 cursor-not-allowed':
|
||||||
'rounded-lg!',
|
isDisabled && !readOnly,
|
||||||
{
|
'bg-transparent! cursor-not-allowed!': readOnly,
|
||||||
'bg-base-100!': !isDisabled && !readOnly,
|
'cursor-pointer!': !readOnly && !isDisabled,
|
||||||
'bg-base-200! text-gray-400 cursor-not-allowed':
|
'border-error!': isError,
|
||||||
isDisabled && !readOnly,
|
'ring-2 ring-error/20': isError,
|
||||||
'bg-transparent! cursor-not-allowed!': readOnly,
|
'border-indigo-500 ring-2 ring-indigo-200':
|
||||||
'cursor-pointer!': !readOnly && !isDisabled,
|
isFocused && !startAdornment && !isError,
|
||||||
'border-red-500! ring-2 ring-red-200': isError,
|
'border-base-content/10!': !isError && !isFocused,
|
||||||
'border-indigo-500 ring-2 ring-indigo-200':
|
}),
|
||||||
isFocused && !startAdornment,
|
|
||||||
'border-base-content/10!': !isError && !isFocused,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
||||||
placeholder: () =>
|
placeholder: () =>
|
||||||
cn({
|
cn('text-gray-400 text-sm leading-tight', {
|
||||||
'text-gray-400 text-sm leading-tight': !isError,
|
'text-error!': isError,
|
||||||
'text-red-300!': isError,
|
|
||||||
}),
|
}),
|
||||||
singleValue: () =>
|
singleValue: () =>
|
||||||
cn({
|
cn('m-0! text-gray-900 text-sm leading-tight', {
|
||||||
'm-0! text-gray-900 text-sm leading-tight': !isError,
|
'text-error!': isError && !readOnly,
|
||||||
'text-error!': isError,
|
|
||||||
'text-gray-900!': readOnly,
|
'text-gray-900!': readOnly,
|
||||||
}),
|
}),
|
||||||
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ const StockLogTable = ({
|
|||||||
header: 'Gudang',
|
header: 'Gudang',
|
||||||
accessorKey: 'warehouse_name',
|
accessorKey: 'warehouse_name',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: 'Stock Akhir',
|
||||||
|
accessorKey: 'stock',
|
||||||
|
cell: (props) => {
|
||||||
|
return formatNumber(props.row.original.stock);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Peningkatan',
|
header: 'Peningkatan',
|
||||||
accessorKey: 'increase',
|
accessorKey: 'increase',
|
||||||
|
|||||||
@@ -49,17 +49,19 @@ const SalesOrderProductTable = ({
|
|||||||
>
|
>
|
||||||
<Icon icon='heroicons:pencil' width={20} height={20} />
|
<Icon icon='heroicons:pencil' width={20} height={20} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{data.length > 1 && (
|
||||||
type='button'
|
<Button
|
||||||
variant='ghost'
|
type='button'
|
||||||
color='none'
|
variant='ghost'
|
||||||
onClick={() => {
|
color='none'
|
||||||
onDeleteRef.current(item.id as number);
|
onClick={() => {
|
||||||
}}
|
onDeleteRef.current(item.id as number);
|
||||||
className='p-0 text-error hover:text-base-content'
|
}}
|
||||||
>
|
className='p-0 text-error hover:text-base-content'
|
||||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
>
|
||||||
</Button>
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import StatusBadge from '@/components/helper/StatusBadge';
|
|||||||
import PopoverButton from '@/components/popover/PopoverButton';
|
import PopoverButton from '@/components/popover/PopoverButton';
|
||||||
import PopoverContent from '@/components/popover/PopoverContent';
|
import PopoverContent from '@/components/popover/PopoverContent';
|
||||||
import ProjectFlockConfirmationModal from './ProjectFlockConfirmationModal';
|
import ProjectFlockConfirmationModal from './ProjectFlockConfirmationModal';
|
||||||
import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store';
|
import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store';
|
||||||
import { ProjectFlockFormValues } from './form/ProjectFlockForm.schema';
|
import { ProjectFlockFormValues } from './form/ProjectFlockForm.schema';
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
@@ -384,7 +384,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => {
|
header: ({ table }) => {
|
||||||
const allRows = table.getRowModel().rows;
|
const allRows = table.getRowModel().rows;
|
||||||
const selectableRows = allRows;
|
const selectableRows = allRows.filter((row) => {
|
||||||
|
const projectFlock = row.original;
|
||||||
|
return (
|
||||||
|
projectFlock.approval?.step_number === 1 &&
|
||||||
|
projectFlock.approval?.action !== 'REJECTED'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const allSelected =
|
const allSelected =
|
||||||
selectableRows.every((row) => row.getIsSelected()) &&
|
selectableRows.every((row) => row.getIsSelected()) &&
|
||||||
@@ -398,6 +404,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
selectableRows.forEach((row) => row.toggleSelected(shouldSelect));
|
selectableRows.forEach((row) => row.toggleSelected(shouldSelect));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasNoSelectableRows = selectableRows.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full flex flex-row justify-center'>
|
<div className='w-full flex flex-row justify-center'>
|
||||||
<CheckboxInput
|
<CheckboxInput
|
||||||
@@ -405,6 +413,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
checked={allSelected}
|
checked={allSelected}
|
||||||
indeterminate={someSelected}
|
indeterminate={someSelected}
|
||||||
onChange={toggleSelectableRows}
|
onChange={toggleSelectableRows}
|
||||||
|
disabled={hasNoSelectableRows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -845,6 +854,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
setRowSelection={setRowSelection}
|
setRowSelection={setRowSelection}
|
||||||
|
enableRowSelection={(row) => {
|
||||||
|
const projectFlock = row.original;
|
||||||
|
return (
|
||||||
|
projectFlock.approval?.step_number === 1 &&
|
||||||
|
projectFlock.approval?.action !== 'REJECTED'
|
||||||
|
);
|
||||||
|
}}
|
||||||
withCheckbox
|
withCheckbox
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn('p-3', {
|
containerClassName: cn('p-3', {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import Table from '@/components/Table';
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import StatusBadge from '@/components/helper/StatusBadge';
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||||
import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store';
|
import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store';
|
||||||
|
|
||||||
interface ProjectFlockFormProps {
|
interface ProjectFlockFormProps {
|
||||||
formType?: 'add' | 'edit' | 'detail';
|
formType?: 'add' | 'edit' | 'detail';
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import UniformityTableSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityTableSkeleton';
|
import UniformityTableSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityTableSkeleton';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
|
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
|
||||||
import FloatingActionsButton from '@/components/FloatingActionsButton';
|
import FloatingActionsButton from '@/components/FloatingActionsButton';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import SelectInput, {
|
import SelectInput, {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Icon } from '@iconify/react';
|
|||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||||
import { useUiStore } from '@/stores/ui/ui.store';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
|
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import DateInput from '@/components/input/DateInput';
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Button from '@/components/Button';
|
|||||||
import Tooltip from '@/components/Tooltip';
|
import Tooltip from '@/components/Tooltip';
|
||||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||||
import { useUiStore } from '@/stores/ui/ui.store';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
|
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Button from '@/components/Button';
|
|||||||
import Tooltip from '@/components/Tooltip';
|
import Tooltip from '@/components/Tooltip';
|
||||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||||
import { useUiStore } from '@/stores/ui/ui.store';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
|
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|||||||
@@ -1,901 +0,0 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
|
||||||
import { ChangeEventHandler } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
import DateInput from '@/components/input/DateInput';
|
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
|
||||||
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
|
||||||
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
|
||||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
|
||||||
import { ReportExpense } from '@/types/api/report/report-expense';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { ReportExpenseApi } from '@/services/api/report';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
|
||||||
import Pagination from '@/components/Pagination';
|
|
||||||
import Dropdown from '@/components/dropdown/Dropdown';
|
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
|
||||||
import * as XLSX from 'xlsx';
|
|
||||||
import { generateReportExpensePDF } from './pdf/ReportExpenseExport';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import {
|
|
||||||
KandangApi,
|
|
||||||
LocationApi,
|
|
||||||
NonstockApi,
|
|
||||||
SupplierApi,
|
|
||||||
} from '@/services/api/master-data';
|
|
||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
|
||||||
import { Kandang } from '@/types/api/master-data/kandang';
|
|
||||||
import { Nonstock } from '@/types/api/master-data/nonstock';
|
|
||||||
|
|
||||||
const ReportExpenseTable = () => {
|
|
||||||
// ===== STATE MANAGEMENT =====
|
|
||||||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
|
||||||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
const [pdfProgress, setPdfProgress] = useState(0);
|
|
||||||
const [excelProgress, setExcelProgress] = useState(0);
|
|
||||||
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
|
||||||
|
|
||||||
// ===== SUBMISSION STATE =====
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
||||||
|
|
||||||
// ===== TABLE FILTER STATE =====
|
|
||||||
const {
|
|
||||||
state: filterState,
|
|
||||||
updateFilter,
|
|
||||||
setPage,
|
|
||||||
setPageSize,
|
|
||||||
reset: resetFilterState,
|
|
||||||
toQueryString,
|
|
||||||
} = useTableFilter({
|
|
||||||
initial: {
|
|
||||||
location_id: '',
|
|
||||||
supplier_id: '',
|
|
||||||
kandang_id: '',
|
|
||||||
nonstock_id: '',
|
|
||||||
realization_date: '',
|
|
||||||
category: '',
|
|
||||||
search: '',
|
|
||||||
},
|
|
||||||
paramMap: {
|
|
||||||
page: 'page',
|
|
||||||
pageSize: 'limit',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== SELECT OPTIONS =====
|
|
||||||
const {
|
|
||||||
setInputValue: setLocationInputValue,
|
|
||||||
options: locationOptions,
|
|
||||||
isLoadingOptions: isLoadingLocationOptions,
|
|
||||||
loadMore: loadMoreLocations,
|
|
||||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setSupplierInputValue,
|
|
||||||
options: supplierOptions,
|
|
||||||
isLoadingOptions: isLoadingSupplierOptions,
|
|
||||||
loadMore: loadMoreSuppliers,
|
|
||||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setKandangInputValue,
|
|
||||||
options: kandangOptions,
|
|
||||||
isLoadingOptions: isLoadingKandangOptions,
|
|
||||||
loadMore: loadMoreKandangs,
|
|
||||||
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setNonstockInputValue,
|
|
||||||
options: nonstockOptions,
|
|
||||||
isLoadingOptions: isLoadingNonstockOptions,
|
|
||||||
loadMore: loadMoreNonstocks,
|
|
||||||
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const categoryOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{ value: 'BOP', label: 'BOP' },
|
|
||||||
{ value: 'NON-BOP', label: 'Non BOP' },
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mendapatkan value option select dari filter state
|
|
||||||
const selectedLocation = useMemo(
|
|
||||||
() =>
|
|
||||||
locationOptions.find(
|
|
||||||
(opt) => String(opt.value) === filterState.location_id
|
|
||||||
) || null,
|
|
||||||
[locationOptions, filterState.location_id]
|
|
||||||
);
|
|
||||||
const selectedSupplier = useMemo(
|
|
||||||
() =>
|
|
||||||
supplierOptions.find(
|
|
||||||
(opt) => String(opt.value) === filterState.supplier_id
|
|
||||||
) || null,
|
|
||||||
[supplierOptions, filterState.supplier_id]
|
|
||||||
);
|
|
||||||
const selectedKandang = useMemo(
|
|
||||||
() =>
|
|
||||||
kandangOptions.find(
|
|
||||||
(opt) => String(opt.value) === filterState.kandang_id
|
|
||||||
) || null,
|
|
||||||
[kandangOptions, filterState.kandang_id]
|
|
||||||
);
|
|
||||||
const selectedNonstock = useMemo(
|
|
||||||
() =>
|
|
||||||
nonstockOptions.find(
|
|
||||||
(opt) => String(opt.value) === filterState.nonstock_id
|
|
||||||
) || null,
|
|
||||||
[nonstockOptions, filterState.nonstock_id]
|
|
||||||
);
|
|
||||||
const selectedCategory = useMemo(
|
|
||||||
() =>
|
|
||||||
categoryOptions.find((opt) => opt.value === filterState.category) || null,
|
|
||||||
[categoryOptions, filterState.category]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== FILTER CHANGE HANDLERS =====
|
|
||||||
const locationChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('location_id', option ? String(option.value) : '');
|
|
||||||
updateFilter('kandang_id', '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const kandangChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('kandang_id', option ? String(option.value) : '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const supplierChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('supplier_id', option ? String(option.value) : '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const nonstockChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('nonstock_id', option ? String(option.value) : '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const categoryChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const option = val as OptionType;
|
|
||||||
updateFilter('category', option ? String(option.value) : '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const realizationDateChangeHandler = useCallback<
|
|
||||||
ChangeEventHandler<HTMLInputElement>
|
|
||||||
>(
|
|
||||||
(e) => {
|
|
||||||
updateFilter('realization_date', e.target.value || '');
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchChangeHandler = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
updateFilter('search', e.target.value);
|
|
||||||
setIsSubmitted(false);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== RESET FILTERS =====
|
|
||||||
const resetFilters = useCallback(() => {
|
|
||||||
resetFilterState();
|
|
||||||
setIsSubmitted(false);
|
|
||||||
}, [resetFilterState]);
|
|
||||||
|
|
||||||
// ===== SUBMIT HANDLER =====
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setPage(1);
|
|
||||||
}, [setPage]);
|
|
||||||
|
|
||||||
// ===== DATA FETCHING FOR TABLE =====
|
|
||||||
const { data: reportExpenseResponse, isLoading } = useSWR(
|
|
||||||
isSubmitted
|
|
||||||
? () => {
|
|
||||||
return ['report-expense', toQueryString()];
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
([, query]) => {
|
|
||||||
const endpoint = `${ReportExpenseApi.basePath}${query}`;
|
|
||||||
return ReportExpenseApi.getAllFetcher(endpoint);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data: ReportExpense[] = useMemo(
|
|
||||||
() =>
|
|
||||||
isResponseSuccess(reportExpenseResponse)
|
|
||||||
? (reportExpenseResponse?.data as ReportExpense[]) || []
|
|
||||||
: [],
|
|
||||||
[reportExpenseResponse]
|
|
||||||
);
|
|
||||||
|
|
||||||
const meta = useMemo(
|
|
||||||
() =>
|
|
||||||
isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta
|
|
||||||
? reportExpenseResponse.meta
|
|
||||||
: null,
|
|
||||||
[reportExpenseResponse]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== EXPORT DATA FETCHER =====
|
|
||||||
const reportExpenseExport = useCallback(async (): Promise<
|
|
||||||
ReportExpense[] | null
|
|
||||||
> => {
|
|
||||||
const params = new URLSearchParams(toQueryString().replace('?', ''));
|
|
||||||
params.set('limit', 'limit');
|
|
||||||
params.set('page', '1');
|
|
||||||
|
|
||||||
const endpoint = `${ReportExpenseApi.basePath}?${params.toString()}`;
|
|
||||||
const response = await ReportExpenseApi.getAllFetcher(endpoint);
|
|
||||||
|
|
||||||
return isResponseSuccess(response) ? response.data : null;
|
|
||||||
}, [toQueryString]);
|
|
||||||
|
|
||||||
// ===== EXPORT HANDLERS =====
|
|
||||||
const handleExportPdf = useCallback(async () => {
|
|
||||||
if (isPdfExportLoading) return;
|
|
||||||
setIsPdfExportLoading(true);
|
|
||||||
setPdfProgress(0);
|
|
||||||
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
requestAnimationFrame(() => resolve(undefined))
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Stage 1: Fetching data (0-20%)
|
|
||||||
setPdfProgress(10);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
const allData = await reportExpenseExport();
|
|
||||||
if (!allData || allData.length === 0) {
|
|
||||||
toast.error('Tidak ada data untuk diekspor.');
|
|
||||||
setIsPdfExportLoading(false);
|
|
||||||
setPdfProgress(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 2: Data fetched - langsung loncat ke progress tinggi
|
|
||||||
setPdfProgress(30);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
setPdfProgress((prev) => {
|
|
||||||
// Increment kecil dan random antara 0.5-2%
|
|
||||||
const increment = Math.random() * 1.5 + 0.5;
|
|
||||||
const newProgress = Math.min(prev + increment, 50);
|
|
||||||
return newProgress;
|
|
||||||
});
|
|
||||||
}, 300); // Update setiap 300ms
|
|
||||||
|
|
||||||
const pdfParams = {
|
|
||||||
location_name: selectedLocation?.label,
|
|
||||||
supplier_name: selectedSupplier?.label,
|
|
||||||
kandang_name: selectedKandang?.label,
|
|
||||||
nonstock_name: selectedNonstock?.label,
|
|
||||||
category: selectedCategory?.label,
|
|
||||||
realization_date: filterState.realization_date,
|
|
||||||
search: filterState.search,
|
|
||||||
};
|
|
||||||
|
|
||||||
setDropdownOpen(false);
|
|
||||||
|
|
||||||
// Stage 3: Langsung loncat ke 80-85% untuk menghindari stuck
|
|
||||||
const baseProgress = 80 + Math.floor(Math.random() * 16); // Random 80-85%
|
|
||||||
setPdfProgress(baseProgress);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
// Stage 4: Berikan jeda untuk UI update
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
requestAnimationFrame(() => resolve(undefined))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Proses PDF yang sebenarnya
|
|
||||||
await generateReportExpensePDF(allData, pdfParams);
|
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
|
|
||||||
// Stage 5: Finalizing (98-100%)
|
|
||||||
setPdfProgress(99);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
setPdfProgress(100);
|
|
||||||
toast.success('PDF berhasil dibuat dan diunduh.');
|
|
||||||
|
|
||||||
// Reset progress setelah selesai
|
|
||||||
setTimeout(() => setPdfProgress(0), 500);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('PDF Export Error:', error);
|
|
||||||
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
|
||||||
setPdfProgress(0);
|
|
||||||
} finally {
|
|
||||||
setIsPdfExportLoading(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
reportExpenseExport,
|
|
||||||
selectedLocation,
|
|
||||||
selectedSupplier,
|
|
||||||
selectedKandang,
|
|
||||||
selectedNonstock,
|
|
||||||
selectedCategory,
|
|
||||||
filterState.realization_date,
|
|
||||||
filterState.search,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleExportExcel = useCallback(async () => {
|
|
||||||
if (isExcelExportLoading) return;
|
|
||||||
setIsExcelExportLoading(true);
|
|
||||||
setExcelProgress(0);
|
|
||||||
setDropdownOpen(false);
|
|
||||||
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
requestAnimationFrame(() => resolve(undefined))
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Stage 1: Fetching data (0-20%)
|
|
||||||
setExcelProgress(15);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
const allDataForExport = await reportExpenseExport();
|
|
||||||
|
|
||||||
if (!allDataForExport || allDataForExport.length === 0) {
|
|
||||||
toast.error('Tidak ada data untuk diekspor.');
|
|
||||||
setIsExcelExportLoading(false);
|
|
||||||
setExcelProgress(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 2: Data fetched (20-40%)
|
|
||||||
setExcelProgress(30);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
// Stage 3: Grouping data (40-60%)
|
|
||||||
setExcelProgress(50);
|
|
||||||
const groupedBySupplier: Record<string, ReportExpense[]> = {};
|
|
||||||
allDataForExport.forEach((item) => {
|
|
||||||
const supplierName = item.supplier?.name || 'Unknown Supplier';
|
|
||||||
if (!groupedBySupplier[supplierName]) {
|
|
||||||
groupedBySupplier[supplierName] = [];
|
|
||||||
}
|
|
||||||
groupedBySupplier[supplierName].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
// Stage 4: Creating workbook (60-80%)
|
|
||||||
setExcelProgress(70);
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
|
|
||||||
const supplierEntries = Object.entries(groupedBySupplier);
|
|
||||||
const totalSuppliers = supplierEntries.length;
|
|
||||||
|
|
||||||
for (let i = 0; i < supplierEntries.length; i++) {
|
|
||||||
const [supplierName, supplierData] = supplierEntries[i];
|
|
||||||
|
|
||||||
// Update progress per supplier
|
|
||||||
const progressIncrement = (20 / totalSuppliers) * (i + 1);
|
|
||||||
setExcelProgress(70 + progressIncrement);
|
|
||||||
|
|
||||||
const totals = supplierData.reduce(
|
|
||||||
(acc, item) => ({
|
|
||||||
qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0),
|
|
||||||
total_pengajuan:
|
|
||||||
acc.total_pengajuan +
|
|
||||||
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
|
|
||||||
qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0),
|
|
||||||
total_realisasi:
|
|
||||||
acc.total_realisasi +
|
|
||||||
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
qty_pengajuan: 0,
|
|
||||||
total_pengajuan: 0,
|
|
||||||
qty_realisasi: 0,
|
|
||||||
total_realisasi: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const excelData = supplierData.map((item, index) => ({
|
|
||||||
No: index + 1,
|
|
||||||
'No. PO': item.po_number || '',
|
|
||||||
'No. Referensi': item.reference_number || '',
|
|
||||||
'Tanggal Realisasi': item.realization_date
|
|
||||||
? formatDate(item.realization_date, 'DD MMM YYYY')
|
|
||||||
: '',
|
|
||||||
'Tanggal Transaksi': item.transaction_date
|
|
||||||
? formatDate(item.transaction_date, 'DD MMM YYYY')
|
|
||||||
: '',
|
|
||||||
Kategori: item.category || '',
|
|
||||||
Produk: item.pengajuan?.nonstock?.name || '',
|
|
||||||
Lokasi: item.kandang?.location?.name || '',
|
|
||||||
Kandang: item.kandang?.name || '',
|
|
||||||
'Qty Pengajuan': item.pengajuan?.qty || 0,
|
|
||||||
'Harga Pengajuan': item.pengajuan?.price || 0,
|
|
||||||
'Total Pengajuan':
|
|
||||||
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
|
|
||||||
'Qty Realisasi': item.realisasi?.qty || 0,
|
|
||||||
'Harga Realisasi': item.realisasi?.price || 0,
|
|
||||||
'Total Realisasi':
|
|
||||||
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
|
|
||||||
'Status Pencairan': item.latest_approval?.step_name || '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
excelData.push({
|
|
||||||
No: 'Total' as unknown as number,
|
|
||||||
'No. PO': '',
|
|
||||||
'No. Referensi': '',
|
|
||||||
'Tanggal Realisasi': '',
|
|
||||||
'Tanggal Transaksi': '',
|
|
||||||
Kategori: '',
|
|
||||||
Produk: '',
|
|
||||||
Lokasi: '',
|
|
||||||
Kandang: '',
|
|
||||||
'Qty Pengajuan': totals.qty_pengajuan,
|
|
||||||
'Harga Pengajuan': 0,
|
|
||||||
'Total Pengajuan': totals.total_pengajuan,
|
|
||||||
'Qty Realisasi': totals.qty_realisasi,
|
|
||||||
'Harga Realisasi': 0,
|
|
||||||
'Total Realisasi': totals.total_realisasi,
|
|
||||||
'Status Pencairan': '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
|
||||||
const colWidths = [
|
|
||||||
{ wch: 5 }, // No
|
|
||||||
{ wch: 20 }, // No. PO
|
|
||||||
{ wch: 20 }, // No. Referensi
|
|
||||||
{ wch: 15 }, // Tanggal Realisasi
|
|
||||||
{ wch: 15 }, // Tanggal Transaksi
|
|
||||||
{ wch: 15 }, // Kategori
|
|
||||||
{ wch: 30 }, // Produk
|
|
||||||
{ wch: 20 }, // Lokasi
|
|
||||||
{ wch: 15 }, // Kandang
|
|
||||||
{ wch: 15 }, // Qty Pengajuan
|
|
||||||
{ wch: 15 }, // Harga Pengajuan
|
|
||||||
{ wch: 20 }, // Total Pengajuan
|
|
||||||
{ wch: 15 }, // Qty Realisasi
|
|
||||||
{ wch: 15 }, // Harga Realisasi
|
|
||||||
{ wch: 20 }, // Total Realisasi
|
|
||||||
{ wch: 20 }, // Status Pencairan
|
|
||||||
];
|
|
||||||
worksheet['!cols'] = colWidths;
|
|
||||||
|
|
||||||
const sheetName = supplierName.slice(0, 31);
|
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
|
||||||
|
|
||||||
// Small delay to allow UI update
|
|
||||||
if (i < supplierEntries.length - 1) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 5: Writing file (90-100%)
|
|
||||||
setExcelProgress(95);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
|
|
||||||
XLSX.writeFile(workbook, filename);
|
|
||||||
|
|
||||||
setExcelProgress(100);
|
|
||||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
|
||||||
|
|
||||||
// Reset progress
|
|
||||||
setTimeout(() => setExcelProgress(0), 500);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Excel Export Error:', error);
|
|
||||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
|
||||||
setExcelProgress(0);
|
|
||||||
} finally {
|
|
||||||
setIsExcelExportLoading(false);
|
|
||||||
}
|
|
||||||
}, [isExcelExportLoading, reportExpenseExport]);
|
|
||||||
|
|
||||||
// ===== PAGINATION HANDLERS =====
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowChange = (pageSize: number) => {
|
|
||||||
setPageSize(pageSize);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextPage = () => {
|
|
||||||
if (meta && filterState.page < meta.total_pages) {
|
|
||||||
setPage(filterState.page + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
|
||||||
if (filterState.page > 1) {
|
|
||||||
setPage(filterState.page - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== TABLE COLUMNS DEFINITION =====
|
|
||||||
const columns = useMemo((): ColumnDef<ReportExpense>[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: 'No',
|
|
||||||
accessorFn: (_, index) =>
|
|
||||||
(filterState.page - 1) * filterState.pageSize + index + 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'No. PO',
|
|
||||||
accessorKey: 'po_number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'No. Referensi',
|
|
||||||
accessorKey: 'reference_number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Tanggal Realisasi',
|
|
||||||
accessorKey: 'realization_date',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Tanggal Transaksi',
|
|
||||||
accessorKey: 'transaction_date',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Kategori',
|
|
||||||
accessorKey: 'category',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Produk',
|
|
||||||
accessorFn: (row) => row.pengajuan?.nonstock?.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Supplier',
|
|
||||||
accessorFn: (row) => row.supplier?.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Lokasi',
|
|
||||||
accessorFn: (row) => row.kandang?.location?.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Kandang',
|
|
||||||
accessorFn: (row) => row.kandang?.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Pengajuan',
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: 'Qty',
|
|
||||||
id: 'qty_pengajuan',
|
|
||||||
accessorFn: (row) => row.pengajuan?.qty,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Harga',
|
|
||||||
id: 'harga_pengajuan',
|
|
||||||
accessorFn: (row) => row.pengajuan?.price,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
formatCurrency(row.original.pengajuan?.price || 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Total',
|
|
||||||
id: 'total_pengajuan',
|
|
||||||
accessorFn: (row) =>
|
|
||||||
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const total =
|
|
||||||
(row.original.pengajuan?.qty || 0) *
|
|
||||||
(row.original.pengajuan?.price || 0);
|
|
||||||
return formatCurrency(total);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Realisasi',
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: 'Qty',
|
|
||||||
id: 'qty_realisasi',
|
|
||||||
accessorFn: (row) => row.realisasi?.qty,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Harga',
|
|
||||||
id: 'harga_realisasi',
|
|
||||||
accessorFn: (row) => row.realisasi?.price,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
formatCurrency(row.original.realisasi?.price || 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Total',
|
|
||||||
id: 'total_realisasi',
|
|
||||||
accessorFn: (row) =>
|
|
||||||
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const total =
|
|
||||||
(row.original.realisasi?.qty || 0) *
|
|
||||||
(row.original.realisasi?.price || 0);
|
|
||||||
return formatCurrency(total);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Status Pencairan',
|
|
||||||
cell: (props) => (
|
|
||||||
<RealizationStatusBadge
|
|
||||||
approval={props.row.original?.latest_approval}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Status BOP',
|
|
||||||
cell: (props) => (
|
|
||||||
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [filterState.page, filterState.pageSize]);
|
|
||||||
|
|
||||||
// ===== RENDER =====
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
{isAnyExportLoading && (
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<progress
|
|
||||||
className='progress progress-primary w-full'
|
|
||||||
value={
|
|
||||||
isPdfExportLoading
|
|
||||||
? pdfProgress
|
|
||||||
: isExcelExportLoading
|
|
||||||
? excelProgress
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
max='100'
|
|
||||||
></progress>
|
|
||||||
{((isPdfExportLoading && pdfProgress > 0) ||
|
|
||||||
(isExcelExportLoading && excelProgress > 0)) && (
|
|
||||||
<div className='text-sm text-center text-gray-600'>
|
|
||||||
<div className='font-semibold'>
|
|
||||||
{(() => {
|
|
||||||
const currentProgress = isPdfExportLoading
|
|
||||||
? pdfProgress
|
|
||||||
: excelProgress;
|
|
||||||
const exportType = isPdfExportLoading ? 'PDF' : 'Excel';
|
|
||||||
|
|
||||||
if (currentProgress < 20)
|
|
||||||
return 'Mengambil data dari server...';
|
|
||||||
if (currentProgress < 30) return 'Memproses data laporan...';
|
|
||||||
if (currentProgress < 40)
|
|
||||||
return `Menyiapkan struktur dokumen ${exportType}...`;
|
|
||||||
if (currentProgress < 50)
|
|
||||||
return 'Mengelompokkan data per supplier...';
|
|
||||||
if (currentProgress < 70)
|
|
||||||
return 'Merender tabel dan kalkulasi...';
|
|
||||||
if (currentProgress < 96)
|
|
||||||
return `Memformat dokumen ${exportType}...`;
|
|
||||||
if (currentProgress < 100)
|
|
||||||
return 'Menyelesaikan dan mengunduh...';
|
|
||||||
return 'Selesai!';
|
|
||||||
})()}{' '}
|
|
||||||
{Math.round(isPdfExportLoading ? pdfProgress : excelProgress)}%
|
|
||||||
</div>
|
|
||||||
{((isPdfExportLoading && pdfProgress >= 35 && pdfProgress < 90) ||
|
|
||||||
(isExcelExportLoading &&
|
|
||||||
excelProgress >= 35 &&
|
|
||||||
excelProgress < 90)) && (
|
|
||||||
<div className='text-xs text-gray-500 mt-1'>
|
|
||||||
{(isPdfExportLoading ? pdfProgress : excelProgress) < 96
|
|
||||||
? 'Proses ini membutuhkan waktu lebih lama untuk data dalam jumlah besar. Mohon bersabar...'
|
|
||||||
: 'Sedang memproses baris data. Hampir selesai...'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Card
|
|
||||||
title='Laporan Biaya Operasional'
|
|
||||||
variant='bordered'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
footer={
|
|
||||||
<div className='flex flex-col gap-6'>
|
|
||||||
<div className='flex flex-row items-center justify-end gap-2'>
|
|
||||||
<div className='flex flex-row items-center gap-2'>
|
|
||||||
<Button className='min-w-24' onClick={handleSubmit}>
|
|
||||||
Cari
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className='min-w-24'
|
|
||||||
color='warning'
|
|
||||||
onClick={resetFilters}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Dropdown
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
color='success'
|
|
||||||
className='min-w-24'
|
|
||||||
isLoading={isAnyExportLoading}
|
|
||||||
onClick={() => {
|
|
||||||
setDropdownOpen(!dropdownOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
align='end'
|
|
||||||
direction='bottom'
|
|
||||||
open={dropdownOpen}
|
|
||||||
>
|
|
||||||
<Menu className='w-32'>
|
|
||||||
<MenuItem title='Excel' onClick={handleExportExcel} />
|
|
||||||
<MenuItem title='PDF' onClick={handleExportPdf} />
|
|
||||||
</Menu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Lokasi'
|
|
||||||
options={locationOptions}
|
|
||||||
isLoading={isLoadingLocationOptions}
|
|
||||||
placeholder='Lokasi'
|
|
||||||
value={selectedLocation}
|
|
||||||
onChange={locationChangeHandler}
|
|
||||||
onInputChange={setLocationInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreLocations}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Kandang'
|
|
||||||
options={kandangOptions}
|
|
||||||
isLoading={isLoadingKandangOptions}
|
|
||||||
placeholder='Kandang'
|
|
||||||
value={selectedKandang}
|
|
||||||
onChange={kandangChangeHandler}
|
|
||||||
onInputChange={setKandangInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreKandangs}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Supplier'
|
|
||||||
options={supplierOptions}
|
|
||||||
isLoading={isLoadingSupplierOptions}
|
|
||||||
placeholder='Supplier'
|
|
||||||
value={selectedSupplier}
|
|
||||||
onChange={supplierChangeHandler}
|
|
||||||
onInputChange={setSupplierInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreSuppliers}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Produk'
|
|
||||||
options={nonstockOptions}
|
|
||||||
isLoading={isLoadingNonstockOptions}
|
|
||||||
placeholder='Produk'
|
|
||||||
value={selectedNonstock}
|
|
||||||
onChange={nonstockChangeHandler}
|
|
||||||
onInputChange={setNonstockInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreNonstocks}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
isClearable
|
|
||||||
label='Kategori'
|
|
||||||
options={categoryOptions}
|
|
||||||
placeholder='Kategori'
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={categoryChangeHandler}
|
|
||||||
/>
|
|
||||||
<DateInput
|
|
||||||
label='Tanggal Realisasi'
|
|
||||||
value={filterState.realization_date}
|
|
||||||
onChange={realizationDateChangeHandler}
|
|
||||||
name='realization_date'
|
|
||||||
placeholder='Tanggal Realisasi'
|
|
||||||
/>
|
|
||||||
<DebouncedTextInput
|
|
||||||
label='Cari'
|
|
||||||
name='search'
|
|
||||||
value={filterState.search}
|
|
||||||
onChange={searchChangeHandler}
|
|
||||||
placeholder='Cari'
|
|
||||||
startAdornment={<Icon icon='mdi:magnify' width={24} height={24} />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ===== TABLE CONTENT ===== */}
|
|
||||||
{!isSubmitted ? (
|
|
||||||
<div className='mt-6 text-center text-gray-500'>
|
|
||||||
Silakan pilih filter dan klik tombol Cari untuk menampilkan data.
|
|
||||||
</div>
|
|
||||||
) : isLoading ? (
|
|
||||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
|
||||||
<span className='loading loading-spinner loading-xl' />
|
|
||||||
</div>
|
|
||||||
) : data.length === 0 ? (
|
|
||||||
<div className='mt-6 text-center text-gray-500'>
|
|
||||||
Tidak ada data yang dapat ditampilkan...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table<ReportExpense>
|
|
||||||
columns={columns}
|
|
||||||
data={data}
|
|
||||||
pageSize={10}
|
|
||||||
className={{
|
|
||||||
containerClassName: 'mb-0',
|
|
||||||
headerRowClassName: cn(
|
|
||||||
TABLE_DEFAULT_STYLING,
|
|
||||||
'whitespace-nowrap'
|
|
||||||
),
|
|
||||||
bodyRowClassName: cn(TABLE_DEFAULT_STYLING, 'whitespace-nowrap'),
|
|
||||||
paginationClassName: 'hidden',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{meta && (
|
|
||||||
<div className='mt-6'>
|
|
||||||
<Pagination
|
|
||||||
currentPage={meta.page}
|
|
||||||
totalItems={meta.total_results}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
onRowChange={handleRowChange}
|
|
||||||
onNextPage={handleNextPage}
|
|
||||||
onPrevPage={handlePrevPage}
|
|
||||||
rowOptions={[10, 25, 50, 100]}
|
|
||||||
itemsPerPage={meta.limit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ReportExpenseTable;
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Tabs from '@/components/Tabs';
|
||||||
|
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
import ReportExpenseTab from './tab/ReportExpenseTab';
|
||||||
|
|
||||||
|
const ReportExpenseTabs = () => {
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Laporan Biaya Operasional',
|
||||||
|
content: <ReportExpenseTab tabId={'1'} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className='w-full'>
|
||||||
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
variant='boxed'
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onTabChange={setActiveTabId}
|
||||||
|
className={{
|
||||||
|
tabHeaderWrapper:
|
||||||
|
'justify-between items-center p-3 border-b border-base-content/10',
|
||||||
|
tab: 'w-fit',
|
||||||
|
content: 'p-0 m-0',
|
||||||
|
}}
|
||||||
|
sideContent={tabActions[activeTabId] || null}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportExpenseTabs;
|
||||||
+1
@@ -2,6 +2,7 @@ import { ReportExpense } from '@/types/api/report/report-expense';
|
|||||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
import autoTable, { UserOptions } from 'jspdf-autotable';
|
import autoTable, { UserOptions } from 'jspdf-autotable';
|
||||||
|
|
||||||
interface jsPDFWithAutoTable extends jsPDF {
|
interface jsPDFWithAutoTable extends jsPDF {
|
||||||
lastAutoTable: {
|
lastAutoTable: {
|
||||||
finalY: number;
|
finalY: number;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||||
|
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||||
|
|
||||||
|
export const generateReportExpenseExcel = async (
|
||||||
|
data: ReportExpense[]
|
||||||
|
): Promise<void> => {
|
||||||
|
// Group by supplier
|
||||||
|
const groupedBySupplier: Record<string, ReportExpense[]> = {};
|
||||||
|
data.forEach((item) => {
|
||||||
|
const supplierName = item.supplier?.name || 'Unknown Supplier';
|
||||||
|
if (!groupedBySupplier[supplierName]) {
|
||||||
|
groupedBySupplier[supplierName] = [];
|
||||||
|
}
|
||||||
|
groupedBySupplier[supplierName].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
Object.entries(groupedBySupplier).forEach(([supplierName, supplierData]) => {
|
||||||
|
const totals = supplierData.reduce(
|
||||||
|
(acc, item) => ({
|
||||||
|
qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0),
|
||||||
|
total_pengajuan:
|
||||||
|
acc.total_pengajuan +
|
||||||
|
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
|
||||||
|
qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0),
|
||||||
|
total_realisasi:
|
||||||
|
acc.total_realisasi +
|
||||||
|
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
qty_pengajuan: 0,
|
||||||
|
total_pengajuan: 0,
|
||||||
|
qty_realisasi: 0,
|
||||||
|
total_realisasi: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const excelData = supplierData.map((item, index) => ({
|
||||||
|
No: index + 1,
|
||||||
|
'No. PO': item.po_number || '',
|
||||||
|
'No. Referensi': item.reference_number || '',
|
||||||
|
'Tanggal Realisasi': item.realization_date
|
||||||
|
? formatDate(item.realization_date, 'DD MMM YYYY')
|
||||||
|
: '',
|
||||||
|
'Tanggal Transaksi': item.transaction_date
|
||||||
|
? formatDate(item.transaction_date, 'DD MMM YYYY')
|
||||||
|
: '',
|
||||||
|
Kategori: item.category || '',
|
||||||
|
Produk: item.pengajuan?.nonstock?.name || '',
|
||||||
|
Lokasi: item.kandang?.location?.name || '',
|
||||||
|
Kandang: item.kandang?.name || '',
|
||||||
|
'Qty Pengajuan': item.pengajuan?.qty || 0,
|
||||||
|
'Harga Pengajuan': item.pengajuan?.price || 0,
|
||||||
|
'Total Pengajuan':
|
||||||
|
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
|
||||||
|
'Qty Realisasi': item.realisasi?.qty || 0,
|
||||||
|
'Harga Realisasi': item.realisasi?.price || 0,
|
||||||
|
'Total Realisasi':
|
||||||
|
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
|
||||||
|
'Status Pencairan': item.latest_approval?.step_name || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
excelData.push({
|
||||||
|
No: 'Total' as unknown as number,
|
||||||
|
'No. PO': '',
|
||||||
|
'No. Referensi': '',
|
||||||
|
'Tanggal Realisasi': '',
|
||||||
|
'Tanggal Transaksi': '',
|
||||||
|
Kategori: '',
|
||||||
|
Produk: '',
|
||||||
|
Lokasi: '',
|
||||||
|
Kandang: '',
|
||||||
|
'Qty Pengajuan': totals.qty_pengajuan,
|
||||||
|
'Harga Pengajuan': 0,
|
||||||
|
'Total Pengajuan': totals.total_pengajuan,
|
||||||
|
'Qty Realisasi': totals.qty_realisasi,
|
||||||
|
'Harga Realisasi': 0,
|
||||||
|
'Total Realisasi': totals.total_realisasi,
|
||||||
|
'Status Pencairan': '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
||||||
|
const colWidths = [
|
||||||
|
{ wch: 5 },
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 30 },
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 20 },
|
||||||
|
];
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
const sheetName = supplierName.slice(0, 31);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
|
||||||
|
XLSX.writeFile(workbook, filename);
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type ReportExpenseFilterProps = {
|
||||||
|
location_id: string | null;
|
||||||
|
supplier_id: string | null;
|
||||||
|
kandang_id: string | null;
|
||||||
|
nonstock_id: string | null;
|
||||||
|
realization_date: string | null;
|
||||||
|
category: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportExpenseFilterFormType = {
|
||||||
|
location_id: OptionType | null;
|
||||||
|
supplier_id: OptionType | null;
|
||||||
|
kandang_id: OptionType | null;
|
||||||
|
nonstock_id: OptionType | null;
|
||||||
|
realization_date: string | null;
|
||||||
|
category: OptionType | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportExpenseFilterSchema = yup.object({
|
||||||
|
location_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Lokasi wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
supplier_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Supplier wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
kandang_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Kandang wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
nonstock_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Produk wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
realization_date: yup.string().nullable(),
|
||||||
|
category: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.nullable()
|
||||||
|
.test('is-not-empty', 'Kategori wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
}) as yup.ObjectSchema<ReportExpenseFilterFormType>;
|
||||||
|
|
||||||
|
export type ReportExpenseFilterValues = yup.InferType<
|
||||||
|
typeof ReportExpenseFilterSchema
|
||||||
|
>;
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
import { StyleSheet } from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
const pdfStyles = StyleSheet.create({
|
|
||||||
page: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontFamily: 'Helvetica',
|
|
||||||
padding: 20,
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
logo: {
|
|
||||||
width: 120,
|
|
||||||
height: 30,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
companyInfo: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: 4,
|
|
||||||
color: '#1f74bf',
|
|
||||||
},
|
|
||||||
address: {
|
|
||||||
fontSize: 7,
|
|
||||||
color: '#666666',
|
|
||||||
maxWidth: 400,
|
|
||||||
marginBottom: 10,
|
|
||||||
},
|
|
||||||
divider: {
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
marginBottom: 15,
|
|
||||||
},
|
|
||||||
titleSection: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
marginBottom: 20,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
flex: 3,
|
|
||||||
color: '#1f74bf',
|
|
||||||
},
|
|
||||||
poInfo: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: 8,
|
|
||||||
color: '#1f74bf',
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#000000',
|
|
||||||
marginBottom: 15,
|
|
||||||
},
|
|
||||||
tableRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
|
||||||
tableHeader: {
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCell: {
|
|
||||||
flex: 1,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellHeader: {
|
|
||||||
flex: 1,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellHeaderLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellRight: {
|
|
||||||
flex: 1,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableCellRightLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableCellNarrow: {
|
|
||||||
width: '1%',
|
|
||||||
minWidth: 20,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
tableCellNarrowHeader: {
|
|
||||||
width: '1%',
|
|
||||||
minWidth: 20,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
tableCellWrap: {
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: 80,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
},
|
|
||||||
tableCellWrapHeader: {
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: 80,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
// Nested header styles
|
|
||||||
tableHeaderGroup: {
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableHeaderGroupLast: {
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableHeaderGroupTitle: {
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'center',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
},
|
|
||||||
tableSubHeaderRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
|
||||||
// Specific width columns
|
|
||||||
tableCellXSmall: {
|
|
||||||
width: 30,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellXSmallHeader: {
|
|
||||||
width: 30,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellSmall: {
|
|
||||||
width: 40,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellSmallHeader: {
|
|
||||||
width: 40,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellMedium: {
|
|
||||||
width: 60,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
tableCellMediumHeader: {
|
|
||||||
width: 60,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
tableCellRightXSmall: {
|
|
||||||
width: 30,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableCellRightSmall: {
|
|
||||||
width: 40,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableCellRightMedium: {
|
|
||||||
width: 60,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
tableBorderBottom: {
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
},
|
|
||||||
grandTotalRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: '#000000',
|
|
||||||
borderTopStyle: 'solid',
|
|
||||||
},
|
|
||||||
grandTotalLabel: {
|
|
||||||
flex: 3,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'right',
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
},
|
|
||||||
grandTotalValue: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'right',
|
|
||||||
borderRightWidth: 0,
|
|
||||||
},
|
|
||||||
allocationSection: {
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
allocationTable: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#000000',
|
|
||||||
},
|
|
||||||
innerTable: {
|
|
||||||
marginTop: 5,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#000000',
|
|
||||||
},
|
|
||||||
innerRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
},
|
|
||||||
innerCell: {
|
|
||||||
flex: 1,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
innerCellLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
},
|
|
||||||
innerCellRight: {
|
|
||||||
flex: 1,
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#000000',
|
|
||||||
borderRightStyle: 'solid',
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
innerCellRightLast: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 3,
|
|
||||||
fontSize: 7,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
marginTop: 30,
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
},
|
|
||||||
footerCompany: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'right',
|
|
||||||
flex: 1,
|
|
||||||
color: '#1f74bf',
|
|
||||||
},
|
|
||||||
specialInstructionTable: {
|
|
||||||
width: '60%',
|
|
||||||
maxWidth: 300,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#000000',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default pdfStyles;
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
type ReportExpenseColumn =
|
||||||
|
| ColumnDef<ReportExpense>
|
||||||
|
| {
|
||||||
|
header: string;
|
||||||
|
columns: Array<{
|
||||||
|
header: string;
|
||||||
|
accessorKey?: string;
|
||||||
|
cell?: (props: { row: { original: ReportExpense } }) => React.ReactNode;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReportExpenseSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ReportExpenseColumn[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportExpenseSkeleton;
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Dropdown from '@/components/dropdown/Dropdown';
|
||||||
|
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import {
|
||||||
|
ReportExpenseFilterSchema,
|
||||||
|
type ReportExpenseFilterValues,
|
||||||
|
} from '@/components/pages/report/expense/filter/ReportExpenseFilter';
|
||||||
|
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
||||||
|
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
||||||
|
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||||
|
import { ReportExpenseApi } from '@/services/api/report';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
|
import Pagination from '@/components/Pagination';
|
||||||
|
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
|
||||||
|
import { generateReportExpensePDF } from '../export/ReportExpenseExportPDF';
|
||||||
|
import { generateReportExpenseExcel } from '../export/ReportExpenseExportXLSX';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import {
|
||||||
|
KandangApi,
|
||||||
|
LocationApi,
|
||||||
|
NonstockApi,
|
||||||
|
SupplierApi,
|
||||||
|
} from '@/services/api/master-data';
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
import { Kandang } from '@/types/api/master-data/kandang';
|
||||||
|
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { httpClient } from '@/services/http/client';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
interface ReportExpenseTabProps {
|
||||||
|
tabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterParams {
|
||||||
|
location_id?: string;
|
||||||
|
supplier_id?: string;
|
||||||
|
kandang_id?: string;
|
||||||
|
nonstock_id?: string;
|
||||||
|
realization_date?: string;
|
||||||
|
category?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||||
|
// ===== STATE MANAGEMENT =====
|
||||||
|
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||||
|
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||||
|
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||||
|
|
||||||
|
// ===== SUBMISSION STATE =====
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||||
|
|
||||||
|
// ===== PAGINATION STATE =====
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocations,
|
||||||
|
loadMore: loadMoreLocations,
|
||||||
|
} = useSelect<Kandang>(LocationApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setSupplierInputValue,
|
||||||
|
options: supplierOptions,
|
||||||
|
isLoadingOptions: isLoadingSuppliers,
|
||||||
|
loadMore: loadMoreSuppliers,
|
||||||
|
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setKandangInputValue,
|
||||||
|
options: kandangOptions,
|
||||||
|
isLoadingOptions: isLoadingKandangs,
|
||||||
|
loadMore: loadMoreKandangs,
|
||||||
|
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setNonstockInputValue,
|
||||||
|
options: nonstockOptions,
|
||||||
|
isLoadingOptions: isLoadingNonstocks,
|
||||||
|
loadMore: loadMoreNonstocks,
|
||||||
|
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'BOP', label: 'BOP' },
|
||||||
|
{ value: 'NON-BOP', label: 'Non BOP' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<ReportExpenseFilterValues>({
|
||||||
|
initialValues: {
|
||||||
|
location_id: null,
|
||||||
|
supplier_id: null,
|
||||||
|
kandang_id: null,
|
||||||
|
nonstock_id: null,
|
||||||
|
realization_date: null,
|
||||||
|
category: null,
|
||||||
|
},
|
||||||
|
validationSchema: ReportExpenseFilterSchema,
|
||||||
|
onSubmit: (values) => {
|
||||||
|
setFilterParams({
|
||||||
|
location_id: values.location_id?.value
|
||||||
|
? String(values.location_id.value)
|
||||||
|
: undefined,
|
||||||
|
supplier_id: values.supplier_id?.value
|
||||||
|
? String(values.supplier_id.value)
|
||||||
|
: undefined,
|
||||||
|
kandang_id: values.kandang_id?.value
|
||||||
|
? String(values.kandang_id.value)
|
||||||
|
: undefined,
|
||||||
|
nonstock_id: values.nonstock_id?.value
|
||||||
|
? String(values.nonstock_id.value)
|
||||||
|
: undefined,
|
||||||
|
realization_date: values.realization_date || undefined,
|
||||||
|
category: values.category?.value
|
||||||
|
? String(values.category.value)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
filterModal.closeModal();
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
setFilterParams({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== FILTER VALUES =====
|
||||||
|
const locationValue = useMemo(
|
||||||
|
() => formik.values.location_id,
|
||||||
|
[formik.values.location_id]
|
||||||
|
);
|
||||||
|
const supplierValue = useMemo(
|
||||||
|
() => formik.values.supplier_id,
|
||||||
|
[formik.values.supplier_id]
|
||||||
|
);
|
||||||
|
const kandangValue = useMemo(
|
||||||
|
() => formik.values.kandang_id,
|
||||||
|
[formik.values.kandang_id]
|
||||||
|
);
|
||||||
|
const nonstockValue = useMemo(
|
||||||
|
() => formik.values.nonstock_id,
|
||||||
|
[formik.values.nonstock_id]
|
||||||
|
);
|
||||||
|
const categoryValue = useMemo(
|
||||||
|
() => formik.values.category,
|
||||||
|
[formik.values.category]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== ACTIVE FILTERS COUNT =====
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
if (filterParams.location_id) count += 1;
|
||||||
|
if (filterParams.supplier_id) count += 1;
|
||||||
|
if (filterParams.kandang_id) count += 1;
|
||||||
|
if (filterParams.nonstock_id) count += 1;
|
||||||
|
if (filterParams.realization_date) count += 1;
|
||||||
|
if (filterParams.category) count += 1;
|
||||||
|
return count;
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
const hasFilters = activeFiltersCount > 0;
|
||||||
|
|
||||||
|
// ===== DATA FETCHING =====
|
||||||
|
const { data: reportExpenseResponse, isLoading } = useSWR(
|
||||||
|
isSubmitted
|
||||||
|
? () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterParams.location_id)
|
||||||
|
params.append('location_id', filterParams.location_id);
|
||||||
|
if (filterParams.supplier_id)
|
||||||
|
params.append('supplier_id', filterParams.supplier_id);
|
||||||
|
if (filterParams.kandang_id)
|
||||||
|
params.append('kandang_id', filterParams.kandang_id);
|
||||||
|
if (filterParams.nonstock_id)
|
||||||
|
params.append('nonstock_id', filterParams.nonstock_id);
|
||||||
|
if (filterParams.realization_date)
|
||||||
|
params.append('realization_date', filterParams.realization_date);
|
||||||
|
if (filterParams.category)
|
||||||
|
params.append('category', filterParams.category);
|
||||||
|
params.append('page', String(page));
|
||||||
|
params.append('limit', String(pageSize));
|
||||||
|
|
||||||
|
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: ReportExpense[] = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(reportExpenseResponse)
|
||||||
|
? (reportExpenseResponse.data as ReportExpense[]) || []
|
||||||
|
: [],
|
||||||
|
[reportExpenseResponse]
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta
|
||||||
|
? reportExpenseResponse.meta
|
||||||
|
: null,
|
||||||
|
[reportExpenseResponse]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== EXPORT DATA FETCHER =====
|
||||||
|
const reportExpenseExport = useCallback(async (): Promise<
|
||||||
|
ReportExpense[] | null
|
||||||
|
> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterParams.location_id)
|
||||||
|
params.append('location_id', filterParams.location_id);
|
||||||
|
if (filterParams.supplier_id)
|
||||||
|
params.append('supplier_id', filterParams.supplier_id);
|
||||||
|
if (filterParams.kandang_id)
|
||||||
|
params.append('kandang_id', filterParams.kandang_id);
|
||||||
|
if (filterParams.nonstock_id)
|
||||||
|
params.append('nonstock_id', filterParams.nonstock_id);
|
||||||
|
if (filterParams.realization_date)
|
||||||
|
params.append('realization_date', filterParams.realization_date);
|
||||||
|
if (filterParams.category) params.append('category', filterParams.category);
|
||||||
|
params.append('limit', '100');
|
||||||
|
params.append('page', '1');
|
||||||
|
|
||||||
|
const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
|
||||||
|
`${ReportExpenseApi.basePath}?${params.toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return isResponseSuccess(response) ? response.data : null;
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
// ===== EXPORT HANDLERS =====
|
||||||
|
const handleExportExcel = useCallback(async () => {
|
||||||
|
setIsExcelExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allDataForExport = await reportExpenseExport();
|
||||||
|
|
||||||
|
if (!allDataForExport || allDataForExport.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateReportExpenseExcel(allDataForExport);
|
||||||
|
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsExcelExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [reportExpenseExport]);
|
||||||
|
|
||||||
|
const handleExportPDF = useCallback(async () => {
|
||||||
|
setIsPdfExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allData = await reportExpenseExport();
|
||||||
|
if (!allData || allData.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfParams = {
|
||||||
|
location_name: locationValue?.label,
|
||||||
|
supplier_name: supplierValue?.label,
|
||||||
|
realization_date: formik.values.realization_date || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await generateReportExpensePDF(allData, pdfParams);
|
||||||
|
|
||||||
|
toast.success('PDF berhasil dibuat dan diunduh.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsPdfExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
reportExpenseExport,
|
||||||
|
locationValue,
|
||||||
|
supplierValue,
|
||||||
|
kandangValue,
|
||||||
|
nonstockValue,
|
||||||
|
categoryValue,
|
||||||
|
formik.values.realization_date,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTabActions(
|
||||||
|
tabId,
|
||||||
|
<div className='flex flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
onClick={() => filterModal.openModal()}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||||
|
{
|
||||||
|
'border-primary-gradient text-primary': hasFilters,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
Filter
|
||||||
|
{hasFilters && (
|
||||||
|
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||||
|
{activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
isLoading={isAnyExportLoading}
|
||||||
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
|
>
|
||||||
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:cloud-arrow-down'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
isLoading={isExcelExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
|
Export to Excel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
tabId,
|
||||||
|
hasFilters,
|
||||||
|
activeFiltersCount,
|
||||||
|
isAnyExportLoading,
|
||||||
|
handleExportExcel,
|
||||||
|
handleExportPDF,
|
||||||
|
setTabActions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTabActions(tabId);
|
||||||
|
};
|
||||||
|
}, [tabId, clearTabActions]);
|
||||||
|
|
||||||
|
// ===== TABLE COLUMNS DEFINITION =====
|
||||||
|
const columns = useMemo((): ColumnDef<ReportExpense>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
cell: (props) => (page - 1) * pageSize + props.row.index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No. PO',
|
||||||
|
accessorKey: 'po_number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No. Referensi',
|
||||||
|
accessorKey: 'reference_number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Tanggal Realisasi',
|
||||||
|
accessorKey: 'realization_date',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Tanggal Transaksi',
|
||||||
|
accessorKey: 'transaction_date',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Kategori',
|
||||||
|
accessorKey: 'category',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Produk',
|
||||||
|
accessorFn: (row) => row.pengajuan?.nonstock?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Supplier',
|
||||||
|
accessorFn: (row) => row.supplier?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Lokasi',
|
||||||
|
accessorFn: (row) => row.kandang?.location?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Kandang',
|
||||||
|
accessorFn: (row) => row.kandang?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Pengajuan',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: 'Qty',
|
||||||
|
id: 'qty_pengajuan',
|
||||||
|
accessorFn: (row) => row.pengajuan?.qty,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Harga',
|
||||||
|
id: 'harga_pengajuan',
|
||||||
|
accessorFn: (row) => row.pengajuan?.price,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
formatCurrency(row.original.pengajuan?.price || 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Total',
|
||||||
|
id: 'total_pengajuan',
|
||||||
|
accessorFn: (row) =>
|
||||||
|
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const total =
|
||||||
|
(row.original.pengajuan?.qty || 0) *
|
||||||
|
(row.original.pengajuan?.price || 0);
|
||||||
|
return formatCurrency(total);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Realisasi',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: 'Qty',
|
||||||
|
id: 'qty_realisasi',
|
||||||
|
accessorFn: (row) => row.realisasi?.qty,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Harga',
|
||||||
|
id: 'harga_realisasi',
|
||||||
|
accessorFn: (row) => row.realisasi?.price,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
formatCurrency(row.original.realisasi?.price || 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Total',
|
||||||
|
id: 'total_realisasi',
|
||||||
|
accessorFn: (row) =>
|
||||||
|
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const total =
|
||||||
|
(row.original.realisasi?.qty || 0) *
|
||||||
|
(row.original.realisasi?.price || 0);
|
||||||
|
return formatCurrency(total);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status Pencairan',
|
||||||
|
cell: (props) => (
|
||||||
|
<RealizationStatusBadge
|
||||||
|
approval={props.row.original?.latest_approval}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status BOP',
|
||||||
|
cell: (props) => (
|
||||||
|
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||||
|
{!isSubmitted ? (
|
||||||
|
<ReportExpenseSkeleton
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:funnel'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='No Filters Selected'
|
||||||
|
subtitle='Please choose filters to narrow down your results and make your search easier.'
|
||||||
|
/>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !data || data.length === 0 ? (
|
||||||
|
<ReportExpenseSkeleton
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:chart-bar'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='Data Not Yet Available'
|
||||||
|
subtitle='Please change your filters to get the data.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={pageSize}
|
||||||
|
page={meta?.page || 1}
|
||||||
|
totalItems={meta?.total_results || 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName: 'overflow-x-auto',
|
||||||
|
tableClassName: 'w-full table-auto text-sm',
|
||||||
|
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
|
||||||
|
headerColumnClassName:
|
||||||
|
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
|
||||||
|
bodyRowClassName:
|
||||||
|
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
|
paginationClassName: 'hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{meta && (
|
||||||
|
<div className='max-w-sm ml-auto'>
|
||||||
|
<Pagination
|
||||||
|
totalItems={meta.total_results || 0}
|
||||||
|
itemsPerPage={meta.limit || 0}
|
||||||
|
currentPage={meta.page || 0}
|
||||||
|
onPrevPage={() =>
|
||||||
|
setPage((currPage) =>
|
||||||
|
currPage > 1 ? currPage - 1 : currPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onNextPage={() =>
|
||||||
|
setPage((currPage) =>
|
||||||
|
meta && meta.total_pages && currPage < meta.total_pages
|
||||||
|
? currPage + 1
|
||||||
|
: currPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPageChange={(pageNumber) => setPage(pageNumber)}
|
||||||
|
rowOptions={[10, 20, 50, 100]}
|
||||||
|
onRowChange={setPageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className='p-4 flex flex-col gap-3'>
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
isLoading={isLoadingLocations}
|
||||||
|
value={locationValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('location_id', val);
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreLocations}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Kandang'
|
||||||
|
placeholder='Pilih Kandang'
|
||||||
|
options={kandangOptions}
|
||||||
|
isLoading={isLoadingKandangs}
|
||||||
|
value={kandangValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('kandang_id', val);
|
||||||
|
}}
|
||||||
|
onInputChange={setKandangInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreKandangs}
|
||||||
|
isClearable
|
||||||
|
isDisabled={!formik.values.location_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Supplier'
|
||||||
|
placeholder='Pilih Supplier'
|
||||||
|
options={supplierOptions}
|
||||||
|
isLoading={isLoadingSuppliers}
|
||||||
|
value={supplierValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('supplier_id', val);
|
||||||
|
}}
|
||||||
|
onInputChange={setSupplierInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreSuppliers}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Produk'
|
||||||
|
placeholder='Pilih Produk'
|
||||||
|
options={nonstockOptions}
|
||||||
|
isLoading={isLoadingNonstocks}
|
||||||
|
value={nonstockValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('nonstock_id', val);
|
||||||
|
}}
|
||||||
|
onInputChange={setNonstockInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreNonstocks}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Kategori'
|
||||||
|
placeholder='Pilih Kategori'
|
||||||
|
options={categoryOptions}
|
||||||
|
value={categoryValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('category', val);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
label='Tanggal Realisasi'
|
||||||
|
name='realization_date'
|
||||||
|
value={formik.values.realization_date || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'realization_date',
|
||||||
|
e.target.value || null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='reset'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportExpenseTab;
|
||||||
@@ -4,11 +4,11 @@ import { useState } from 'react';
|
|||||||
import Tabs from '@/components/Tabs';
|
import Tabs from '@/components/Tabs';
|
||||||
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
|
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
|
||||||
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
|
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
|
||||||
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
|
||||||
const FinanceTabs = () => {
|
const FinanceTabs = () => {
|
||||||
const [activeTabId, setActiveTabId] = useState<string>('1');
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
const tabActions = useFinanceTabStore((state) => state.tabActions);
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type CustomerPaymentFilterType = {
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
customer_ids: string | null;
|
||||||
|
filter_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomerPaymentFilterSchema = yup.object({
|
||||||
|
start_date: yup.string().optional().nullable(),
|
||||||
|
end_date: yup
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.test(
|
||||||
|
'is-greater-than-start',
|
||||||
|
'Tanggal akhir tidak boleh masa lampau',
|
||||||
|
function (value) {
|
||||||
|
const { start_date } = this.parent;
|
||||||
|
if (!start_date || !value) return true;
|
||||||
|
return new Date(value) >= new Date(start_date);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
customer_ids: yup.string().nullable(),
|
||||||
|
filter_by: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CustomerPaymentFilterValues = yup.InferType<
|
||||||
|
typeof CustomerPaymentFilterSchema
|
||||||
|
>;
|
||||||
@@ -2,17 +2,22 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Badge from '@/components/Badge';
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
import { useSelect } from '@/components/input/SelectInput';
|
import { useSelect } from '@/components/input/SelectInput';
|
||||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
import DateInput from '@/components/input/DateInput';
|
import DateInput from '@/components/input/DateInput';
|
||||||
import { CustomerApi } from '@/services/api/master-data';
|
import { CustomerApi } from '@/services/api/master-data';
|
||||||
import { FinanceApi } from '@/services/api/report/finance-report';
|
import { FinanceApi } from '@/services/api/report/finance-report';
|
||||||
// import { UserApi } from '@/services/api/user';
|
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { formatCurrency, formatDate, formatNumber, cn } from '@/lib/helper';
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
formatNumber,
|
||||||
|
formatTitleCase,
|
||||||
|
cn,
|
||||||
|
} from '@/lib/helper';
|
||||||
import {
|
import {
|
||||||
CustomerPaymentReport,
|
CustomerPaymentReport,
|
||||||
CustomerPaymentSummary,
|
CustomerPaymentSummary,
|
||||||
@@ -20,20 +25,31 @@ import {
|
|||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Dropdown from '@/components/Dropdown';
|
import Dropdown from '@/components/Dropdown';
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import Modal from '@/components/Modal';
|
|
||||||
import { useModal } from '@/components/Modal';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import {
|
||||||
|
CustomerPaymentFilterSchema,
|
||||||
|
CustomerPaymentFilterType,
|
||||||
|
} from '@/components/pages/report/finance/filter/CustomerPaymentFilter';
|
||||||
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
|
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
|
||||||
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
|
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
|
||||||
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
|
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
|
||||||
|
import { OptionType } from '@/components/table/TableRowSizeSelector';
|
||||||
|
import { Color } from '@/types/theme';
|
||||||
|
|
||||||
interface CustomerPaymentTabProps {
|
interface CustomerPaymentTabProps {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FilterParams {
|
||||||
|
customer_ids?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
filter_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||||
// ===== STATE MANAGEMENT =====
|
// ===== STATE MANAGEMENT =====
|
||||||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||||
@@ -46,31 +62,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
|
|
||||||
// ===== SUBMISSION STATE =====
|
// ===== SUBMISSION STATE =====
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||||
// ===== FILTER STATE =====
|
|
||||||
const [appliedFilterCustomer, setAppliedFilterCustomer] = useState<
|
|
||||||
typeof customerOptions
|
|
||||||
>([]);
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// const [appliedFilterSales, setAppliedFilterSales] = useState<
|
|
||||||
// typeof salesOptions
|
|
||||||
// >([]);
|
|
||||||
const [appliedFilterByType, setAppliedFilterByType] = useState<
|
|
||||||
(typeof dataTypeOptions)[0] | null
|
|
||||||
>(null);
|
|
||||||
const [appliedFilterStartDate, setAppliedFilterStartDate] = useState('');
|
|
||||||
const [appliedFilterEndDate, setAppliedFilterEndDate] = useState('');
|
|
||||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||||
const [hasDateError, setHasDateError] = useState(false);
|
const [hasDateError, setHasDateError] = useState(false);
|
||||||
|
|
||||||
const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>(
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
|
|
||||||
const [filterStartDate, setFilterStartDate] = useState('');
|
|
||||||
const [filterEndDate, setFilterEndDate] = useState('');
|
|
||||||
|
|
||||||
const filterModal = useModal();
|
const filterModal = useModal();
|
||||||
|
|
||||||
const dataTypeOptions = useMemo(
|
const dataTypeOptions = useMemo(
|
||||||
@@ -81,10 +76,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [filterByType, setFilterByType] = useState<
|
|
||||||
(typeof dataTypeOptions)[0] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
options: customerOptions,
|
options: customerOptions,
|
||||||
setInputValue: setCustomerInputValue,
|
setInputValue: setCustomerInputValue,
|
||||||
@@ -92,108 +83,67 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
loadMore: loadMoreCustomers,
|
loadMore: loadMoreCustomers,
|
||||||
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
|
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
// TODO: Uncomment when BE is ready
|
const handleFilterModalOpen = () => {
|
||||||
// const {
|
|
||||||
// options: salesOptions,
|
|
||||||
// setInputValue: setSalesInputValue,
|
|
||||||
// isLoadingOptions: isLoadingSales,
|
|
||||||
// loadMore: loadMoreSales,
|
|
||||||
// hasMore: hasMoreSales,
|
|
||||||
// } = useSelect(UserApi.basePath, 'id', 'name', 'search');
|
|
||||||
|
|
||||||
const getPaymentStatusColor = (notes: string) => {
|
|
||||||
const normalizedValue = notes.toLowerCase();
|
|
||||||
|
|
||||||
if (normalizedValue === 'lunas') {
|
|
||||||
return 'bg-info/10 text-black border-info';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedValue.includes('belum')) {
|
|
||||||
return 'bg-warning/10 text-black border-warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bg-gray-100 text-black border-gray-300';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPaymentStatusIndicatorColor = (notes: string) => {
|
|
||||||
const normalizedValue = notes.toLowerCase();
|
|
||||||
|
|
||||||
if (normalizedValue === 'lunas') {
|
|
||||||
return 'bg-info';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedValue.includes('belum')) {
|
|
||||||
return 'bg-warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bg-gray-400';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPaymentStatusText = (notes: string) => {
|
|
||||||
return notes
|
|
||||||
.toLowerCase()
|
|
||||||
.split(' ')
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== FILTER HANDLERS =====
|
|
||||||
const handleFilterModalOpen = useCallback(() => {
|
|
||||||
setFilterCustomer(appliedFilterCustomer);
|
|
||||||
// setFilterSales(appliedFilterSales);
|
|
||||||
setFilterByType(appliedFilterByType);
|
|
||||||
setFilterStartDate(appliedFilterStartDate);
|
|
||||||
setFilterEndDate(appliedFilterEndDate);
|
|
||||||
filterModal.openModal();
|
filterModal.openModal();
|
||||||
}, [
|
formik.validateForm();
|
||||||
filterModal,
|
};
|
||||||
appliedFilterCustomer,
|
|
||||||
appliedFilterByType,
|
|
||||||
appliedFilterStartDate,
|
|
||||||
appliedFilterEndDate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleResetFilters = useCallback(() => {
|
// ===== FORMIK SETUP =====
|
||||||
setIsSubmitted(false);
|
const formik = useFormik<CustomerPaymentFilterType>({
|
||||||
setFilterCustomer([]);
|
initialValues: {
|
||||||
setFilterByType(null);
|
start_date: null,
|
||||||
setFilterStartDate('');
|
end_date: null,
|
||||||
setFilterEndDate('');
|
customer_ids: null,
|
||||||
setAppliedFilterCustomer([]);
|
filter_by: null,
|
||||||
setAppliedFilterByType(null);
|
},
|
||||||
setAppliedFilterStartDate('');
|
validationSchema: CustomerPaymentFilterSchema,
|
||||||
setAppliedFilterEndDate('');
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
setHasDateError(false);
|
setFilterParams({
|
||||||
if (dateErrorShown) {
|
start_date: values.start_date || undefined,
|
||||||
toast.dismiss();
|
end_date: values.end_date || undefined,
|
||||||
setDateErrorShown(false);
|
customer_ids: values.customer_ids || undefined,
|
||||||
|
filter_by: values.filter_by || undefined,
|
||||||
|
});
|
||||||
|
filterModal.closeModal();
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
setFilterParams({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setHasDateError(false);
|
||||||
|
if (dateErrorShown) {
|
||||||
|
toast.dismiss();
|
||||||
|
setDateErrorShown(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPaymentStatusBadgeColor = (notes: string): Color => {
|
||||||
|
const normalizedValue = notes.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedValue === 'lunas') {
|
||||||
|
return 'primary';
|
||||||
}
|
}
|
||||||
}, [dateErrorShown]);
|
|
||||||
|
|
||||||
const handleApplyFilters = useCallback(() => {
|
if (normalizedValue.includes('belum')) {
|
||||||
setAppliedFilterCustomer(filterCustomer);
|
return 'warning';
|
||||||
setAppliedFilterByType(filterByType);
|
}
|
||||||
setAppliedFilterStartDate(filterStartDate);
|
|
||||||
setAppliedFilterEndDate(filterEndDate);
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setCurrentPage(1);
|
|
||||||
filterModal.closeModal();
|
|
||||||
}, [
|
|
||||||
filterModal,
|
|
||||||
filterCustomer,
|
|
||||||
filterByType,
|
|
||||||
filterStartDate,
|
|
||||||
filterEndDate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== DATE CHANGE HANDLERS =====
|
||||||
const handleStartDateChange = useCallback(
|
const handleStartDateChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setFilterStartDate(value);
|
formik.setFieldValue('start_date', value || null);
|
||||||
|
|
||||||
if (value && filterEndDate) {
|
if (value && formik.values.end_date) {
|
||||||
const startDate = new Date(value);
|
const startDate = new Date(value);
|
||||||
const endDateObj = new Date(filterEndDate);
|
const endDateObj = new Date(formik.values.end_date);
|
||||||
|
|
||||||
if (endDateObj < startDate) {
|
if (endDateObj < startDate) {
|
||||||
setHasDateError(true);
|
setHasDateError(true);
|
||||||
@@ -214,16 +164,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
setHasDateError(false);
|
setHasDateError(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filterEndDate, dateErrorShown]
|
[formik, dateErrorShown]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEndDateChange = useCallback(
|
const handleEndDateChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setFilterEndDate(value);
|
formik.setFieldValue('end_date', value || null);
|
||||||
|
|
||||||
if (value && filterStartDate) {
|
if (value && formik.values.start_date) {
|
||||||
const startDateObj = new Date(filterStartDate);
|
const startDateObj = new Date(formik.values.start_date);
|
||||||
const endDate = new Date(value);
|
const endDate = new Date(value);
|
||||||
|
|
||||||
if (endDate < startDateObj) {
|
if (endDate < startDateObj) {
|
||||||
@@ -244,41 +194,46 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
setDateErrorShown(false);
|
setDateErrorShown(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filterStartDate, dateErrorShown]
|
[formik, dateErrorShown]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const customerIdsValue = useMemo(() => {
|
||||||
|
if (!formik.values.customer_ids) return [];
|
||||||
|
return customerOptions.filter((opt) =>
|
||||||
|
formik.values.customer_ids?.split(',').includes(String(opt.value))
|
||||||
|
);
|
||||||
|
}, [formik.values.customer_ids, customerOptions]);
|
||||||
|
|
||||||
|
const filterByValue = useMemo(() => {
|
||||||
|
if (!formik.values.filter_by) return null;
|
||||||
|
return (
|
||||||
|
dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.filter_by]);
|
||||||
|
|
||||||
// ===== ACTIVE FILTERS COUNT =====
|
// ===== ACTIVE FILTERS COUNT =====
|
||||||
const activeFiltersCount = useMemo(() => {
|
const activeFiltersCount = useMemo(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
// Date filter (start_date + end_date = 1 filter)
|
// Date filter (start_date + end_date = 1 filter)
|
||||||
if (appliedFilterStartDate || appliedFilterEndDate) {
|
if (filterParams.start_date || filterParams.end_date) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customer filter
|
// Customer filter
|
||||||
if (appliedFilterCustomer.length > 0) {
|
if (filterParams.customer_ids) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by type filter (hanya dihitung jika ada nilai yang dipilih)
|
// Filter by type filter (hanya dihitung jika ada nilai yang dipilih)
|
||||||
if (appliedFilterByType) {
|
if (filterParams.filter_by) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// // Sales filter
|
|
||||||
// if (appliedFilterSales.length > 0) {
|
|
||||||
// count += 1;
|
|
||||||
// }
|
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
}, [
|
}, [filterParams]);
|
||||||
appliedFilterStartDate,
|
|
||||||
appliedFilterEndDate,
|
|
||||||
appliedFilterCustomer,
|
|
||||||
appliedFilterByType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasFilters = activeFiltersCount > 0;
|
const hasFilters = activeFiltersCount > 0;
|
||||||
|
|
||||||
@@ -287,21 +242,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
isSubmitted
|
isSubmitted
|
||||||
? () => {
|
? () => {
|
||||||
const params = {
|
const params = {
|
||||||
customer_ids:
|
customer_ids: filterParams.customer_ids,
|
||||||
appliedFilterCustomer.length > 0
|
filter_by: filterParams.filter_by as
|
||||||
? appliedFilterCustomer.map((v) => String(v.value)).join(',')
|
|
||||||
: undefined,
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// sales_id:
|
|
||||||
// appliedFilterSales.length > 0
|
|
||||||
// ? appliedFilterSales.map((v) => String(v.value)).join(',')
|
|
||||||
// : undefined,
|
|
||||||
filter_by: appliedFilterByType?.value as
|
|
||||||
| 'trans_date'
|
| 'trans_date'
|
||||||
| 'realization_date'
|
| 'realization_date'
|
||||||
| undefined,
|
| undefined,
|
||||||
start_date: appliedFilterStartDate || undefined,
|
start_date: filterParams.start_date,
|
||||||
end_date: appliedFilterEndDate || undefined,
|
end_date: filterParams.end_date,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
};
|
};
|
||||||
@@ -333,21 +280,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
CustomerPaymentReport[] | null
|
CustomerPaymentReport[] | null
|
||||||
> => {
|
> => {
|
||||||
const params = {
|
const params = {
|
||||||
customer_ids:
|
customer_ids: filterParams.customer_ids,
|
||||||
appliedFilterCustomer.length > 0
|
filter_by: filterParams.filter_by as
|
||||||
? appliedFilterCustomer.map((v) => String(v.value)).join(',')
|
|
||||||
: undefined,
|
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// sales_id:
|
|
||||||
// appliedFilterSales.length > 0
|
|
||||||
// ? appliedFilterSales.map((v) => String(v.value)).join(',')
|
|
||||||
// : undefined,
|
|
||||||
filter_by: appliedFilterByType?.value as
|
|
||||||
| 'trans_date'
|
| 'trans_date'
|
||||||
| 'realization_date'
|
| 'realization_date'
|
||||||
| undefined,
|
| undefined,
|
||||||
start_date: appliedFilterStartDate || undefined,
|
start_date: filterParams.start_date,
|
||||||
end_date: appliedFilterEndDate || undefined,
|
end_date: filterParams.end_date,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
page: 1,
|
page: 1,
|
||||||
};
|
};
|
||||||
@@ -364,13 +303,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
return isResponseSuccess(response)
|
return isResponseSuccess(response)
|
||||||
? (response.data as unknown as CustomerPaymentReport[])
|
? (response.data as unknown as CustomerPaymentReport[])
|
||||||
: null;
|
: null;
|
||||||
}, [
|
}, [filterParams]);
|
||||||
appliedFilterCustomer,
|
|
||||||
// appliedFilterSales,
|
|
||||||
appliedFilterStartDate,
|
|
||||||
appliedFilterEndDate,
|
|
||||||
appliedFilterByType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ===== EXPORT HANDLERS =====
|
// ===== EXPORT HANDLERS =====
|
||||||
const handleExportExcel = useCallback(async () => {
|
const handleExportExcel = useCallback(async () => {
|
||||||
@@ -410,21 +343,22 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customerName = filterParams.customer_ids
|
||||||
|
? customerOptions
|
||||||
|
.filter((opt) =>
|
||||||
|
filterParams.customer_ids?.split(',').includes(String(opt.value))
|
||||||
|
)
|
||||||
|
.map((opt) => opt.label)
|
||||||
|
.join(', ') || 'Semua Customer'
|
||||||
|
: 'Semua Customer';
|
||||||
|
|
||||||
await generateCustomerPaymentPDF({
|
await generateCustomerPaymentPDF({
|
||||||
data: allDataForExport,
|
data: allDataForExport,
|
||||||
params: {
|
params: {
|
||||||
customer_name:
|
customer_name: customerName,
|
||||||
appliedFilterCustomer.length > 0
|
start_date: filterParams.start_date,
|
||||||
? appliedFilterCustomer.map((c) => c.label).join(', ')
|
end_date: filterParams.end_date,
|
||||||
: undefined,
|
filter_by: filterParams.filter_by as
|
||||||
// TODO: Uncomment when BE is ready
|
|
||||||
// sales:
|
|
||||||
// appliedFilterSales.length > 0
|
|
||||||
// ? appliedFilterSales.map((s) => s.label).join(', ')
|
|
||||||
// : undefined,
|
|
||||||
start_date: appliedFilterStartDate || undefined,
|
|
||||||
end_date: appliedFilterEndDate || undefined,
|
|
||||||
filter_by: appliedFilterByType?.value as
|
|
||||||
| 'trans_date'
|
| 'trans_date'
|
||||||
| 'realization_date'
|
| 'realization_date'
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -436,11 +370,11 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsPdfExportLoading(false);
|
setIsPdfExportLoading(false);
|
||||||
}
|
}
|
||||||
}, [customerPaymentExport]);
|
}, [customerPaymentExport, filterParams, customerOptions]);
|
||||||
|
|
||||||
// ===== REGISTER TAB ACTIONS TO STORE =====
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTabActions(
|
setTabActions(
|
||||||
@@ -451,13 +385,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
color='none'
|
color='none'
|
||||||
onClick={handleFilterModalOpen}
|
onClick={handleFilterModalOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3 py-2.5',
|
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||||
'rounded-lg! font-semibold text-sm gap-1.5',
|
{
|
||||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
|
'border-primary-gradient text-primary': hasFilters,
|
||||||
hasFilters && 'border-primary-gradient text-primary'
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon icon='heroicons:funnel' width={18} height={18} />
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
Filter
|
Filter
|
||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||||
@@ -467,42 +401,55 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='none'
|
color='none'
|
||||||
isLoading={isAnyExportLoading}
|
isLoading={isAnyExportLoading}
|
||||||
className={cn(
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
'px-3 py-2.5',
|
|
||||||
'rounded-lg font-semibold text-sm gap-1.5',
|
|
||||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
Export
|
<Icon
|
||||||
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
|
icon='heroicons:cloud-arrow-down'
|
||||||
<Icon width={14} height={14} icon='heroicons:chevron-down' />
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
align='end'
|
|
||||||
className={{
|
|
||||||
content:
|
|
||||||
'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Menu className='p-0 w-full'>
|
<Button
|
||||||
<MenuItem
|
variant='ghost'
|
||||||
className='text-sm p-3'
|
color='none'
|
||||||
title='Excel'
|
onClick={handleExportExcel}
|
||||||
onClick={handleExportExcel}
|
isLoading={isExcelExportLoading}
|
||||||
/>
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
<MenuItem
|
>
|
||||||
className='text-sm p-3'
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
title='PDF'
|
Export to Excel
|
||||||
onClick={handleExportPdf}
|
</Button>
|
||||||
/>
|
<Button
|
||||||
</Menu>
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -517,7 +464,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
setTabActions,
|
setTabActions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearTabActions(tabId);
|
clearTabActions(tabId);
|
||||||
@@ -735,17 +681,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<StatusBadge
|
||||||
statusIndicator={true}
|
color={getPaymentStatusBadgeColor(value)}
|
||||||
size='sm'
|
text={formatTitleCase(value)}
|
||||||
variant='soft'
|
/>
|
||||||
className={{
|
|
||||||
badge: `py-2.5 px-2 font-thin text-xs border border-gray-200 rounded-xl justify-start ${getPaymentStatusColor(value)}`,
|
|
||||||
status: getPaymentStatusIndicatorColor(value),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getPaymentStatusText(value)}
|
|
||||||
</Badge>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -931,95 +870,86 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
|||||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className='p-4 flex flex-col gap-1.5'>
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
<div>
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
<label className='block text-xs font-semibold text-base-content py-2'>
|
<div>
|
||||||
Tanggal
|
<label className='block text-xs font-semibold text-base-content py-2'>
|
||||||
</label>
|
Tanggal
|
||||||
<div className='flex flex-row gap-1.5 items-center justify-between'>
|
</label>
|
||||||
<DateInput
|
<div className='flex flex-row gap-1.5 items-center justify-between'>
|
||||||
name='start_date'
|
<DateInput
|
||||||
value={filterStartDate}
|
name='start_date'
|
||||||
onChange={handleStartDateChange}
|
value={formik.values.start_date || ''}
|
||||||
className={{ wrapper: 'w-full' }}
|
onChange={handleStartDateChange}
|
||||||
isNestedModal
|
className={{ wrapper: 'w-full' }}
|
||||||
/>
|
isNestedModal
|
||||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
/>
|
||||||
|
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||||
|
|
||||||
<DateInput
|
<DateInput
|
||||||
name='end_date'
|
name='end_date'
|
||||||
value={filterEndDate}
|
value={formik.values.end_date || ''}
|
||||||
onChange={handleEndDateChange}
|
onChange={handleEndDateChange}
|
||||||
className={{ wrapper: 'w-full' }}
|
className={{ wrapper: 'w-full' }}
|
||||||
isNestedModal
|
isNestedModal
|
||||||
/>
|
isError={hasDateError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SelectInputCheckbox
|
||||||
|
label='Customer'
|
||||||
|
placeholder='Pilih Customer'
|
||||||
|
options={customerOptions}
|
||||||
|
value={customerIdsValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'customer_ids',
|
||||||
|
Array.isArray(val) && val.length > 0
|
||||||
|
? val.map((v: OptionType) => String(v.value)).join(',')
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onInputChange={setCustomerInputValue}
|
||||||
|
isLoading={isLoadingCustomers}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreCustomers}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Filter Berdasarkan'
|
||||||
|
placeholder='Pilih Filter Berdasarkan'
|
||||||
|
options={dataTypeOptions}
|
||||||
|
value={filterByValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
formik.setFieldValue('filter_by', val?.value || null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
isClearable={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectInputCheckbox
|
{/* Modal Footer */}
|
||||||
label='Customer'
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
placeholder='Pilih Customer'
|
<Button
|
||||||
options={customerOptions}
|
type='reset'
|
||||||
value={filterCustomer}
|
variant='soft'
|
||||||
onChange={(val) => {
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
setFilterCustomer(Array.isArray(val) ? val : val ? [val] : []);
|
>
|
||||||
}}
|
Reset Filter
|
||||||
onInputChange={setCustomerInputValue}
|
</Button>
|
||||||
isLoading={isLoadingCustomers}
|
<Button
|
||||||
isClearable
|
type='submit'
|
||||||
onMenuScrollToBottom={loadMoreCustomers}
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
className={{ wrapper: 'w-full' }}
|
disabled={hasDateError || !formik.isValid || formik.isSubmitting}
|
||||||
/>
|
>
|
||||||
|
Apply Filter
|
||||||
{/* TODO: Uncomment when BE is ready */}
|
</Button>
|
||||||
{/* <div>
|
</div>
|
||||||
<SelectInputCheckbox
|
</form>
|
||||||
label='Sales'
|
|
||||||
placeholder='Pilih Sales'
|
|
||||||
options={salesOptions}
|
|
||||||
value={filterSales}
|
|
||||||
onChange={(val) => {
|
|
||||||
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
|
|
||||||
}}
|
|
||||||
onInputChange={setSalesInputValue}
|
|
||||||
isLoading={isLoadingSales}
|
|
||||||
isClearable
|
|
||||||
onMenuScrollToBottom={loadMoreSales}
|
|
||||||
className={{ wrapper: 'w-full' }}
|
|
||||||
/>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
<SelectInputRadio
|
|
||||||
label='Filter Berdasarkan'
|
|
||||||
placeholder='Pilih Filter Berdasarkan'
|
|
||||||
options={dataTypeOptions}
|
|
||||||
value={filterByType}
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val && !Array.isArray(val)) {
|
|
||||||
setFilterByType(val);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={{ wrapper: 'w-full' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
|
||||||
<Button
|
|
||||||
variant='soft'
|
|
||||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
|
||||||
onClick={handleResetFilters}
|
|
||||||
>
|
|
||||||
Reset Filter
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
|
||||||
onClick={handleApplyFilters}
|
|
||||||
disabled={hasDateError}
|
|
||||||
>
|
|
||||||
Apply Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import Card from '@/components/Card';
|
|||||||
import Dropdown from '@/components/Dropdown';
|
import Dropdown from '@/components/Dropdown';
|
||||||
import DateInput from '@/components/input/DateInput';
|
import DateInput from '@/components/input/DateInput';
|
||||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
|
||||||
import Modal, { useModal } from '@/components/Modal';
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
@@ -33,7 +31,7 @@ import { Color } from '@/types/theme';
|
|||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
import StatusBadge from '@/components/helper/StatusBadge';
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
|
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
|
||||||
|
|
||||||
@@ -271,13 +269,13 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
|||||||
}, [debtSupplierExport]);
|
}, [debtSupplierExport]);
|
||||||
|
|
||||||
// ===== REGISTER TAB ACTIONS TO STORE =====
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTabActions(
|
setTabActions(
|
||||||
tabId,
|
tabId,
|
||||||
<div className='flex flex-row gap-3 '>
|
<div className='flex flex-row gap-3'>
|
||||||
<ButtonFilter
|
<ButtonFilter
|
||||||
values={formik.values}
|
values={formik.values}
|
||||||
onClick={handleFilterModalOpen}
|
onClick={handleFilterModalOpen}
|
||||||
@@ -286,42 +284,55 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='none'
|
color='none'
|
||||||
isLoading={isAnyExportLoading}
|
isLoading={isAnyExportLoading}
|
||||||
className={cn(
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
'px-3 py-2.5',
|
|
||||||
'rounded-lg font-semibold text-sm gap-1.5',
|
|
||||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
Export
|
<Icon
|
||||||
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
|
icon='heroicons:cloud-arrow-down'
|
||||||
<Icon width={14} height={14} icon='heroicons:chevron-down' />
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
align='end'
|
|
||||||
className={{
|
|
||||||
content:
|
|
||||||
'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Menu className='p-0 w-full'>
|
<Button
|
||||||
<MenuItem
|
variant='ghost'
|
||||||
className='text-sm p-3'
|
color='none'
|
||||||
title='Excel'
|
onClick={handleExportExcel}
|
||||||
onClick={handleExportExcel}
|
isLoading={isExcelExportLoading}
|
||||||
/>
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
<MenuItem
|
>
|
||||||
className='text-sm p-3'
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
title='PDF'
|
Export to Excel
|
||||||
onClick={handleExportPdf}
|
</Button>
|
||||||
/>
|
<Button
|
||||||
</Menu>
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Tabs from '@/components/Tabs';
|
import Tabs from '@/components/Tabs';
|
||||||
import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab';
|
import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
|
||||||
const LogisticStockTabs = () => {
|
const LogisticStockTabs = () => {
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
label: 'Rekapitulasi Pembelian Per Supplier',
|
label: 'Rekapitulasi Pembelian Per Supplier',
|
||||||
content: <PurchasesPerSupplierTab />,
|
content: <PurchasesPerSupplierTab tabId='1' />,
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// id: '2',
|
// id: '2',
|
||||||
@@ -23,8 +28,20 @@ const LogisticStockTabs = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className='w-full p-4'>
|
<section className='w-full'>
|
||||||
<Tabs tabs={tabs} variant='lifted' />
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
variant='boxed'
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onTabChange={setActiveTabId}
|
||||||
|
className={{
|
||||||
|
tabHeaderWrapper:
|
||||||
|
'justify-between items-center p-3 border-b border-base-content/10',
|
||||||
|
tab: 'w-fit',
|
||||||
|
content: 'p-0 m-0',
|
||||||
|
}}
|
||||||
|
sideContent={tabActions[activeTabId] || null}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type PurchasesPerSupplierFilterType = {
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
area_ids: string | null;
|
||||||
|
supplier_ids: string | null;
|
||||||
|
product_ids: string | null;
|
||||||
|
product_category_ids: string | null;
|
||||||
|
filter_by: string | null;
|
||||||
|
sort_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PurchasesPerSupplierFilterSchema = yup.object({
|
||||||
|
start_date: yup.string().optional().nullable(),
|
||||||
|
end_date: yup
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.test(
|
||||||
|
'is-greater-than-start',
|
||||||
|
'Tanggal akhir tidak boleh masa lampau',
|
||||||
|
function (value) {
|
||||||
|
const { start_date } = this.parent;
|
||||||
|
if (!start_date || !value) return true;
|
||||||
|
return new Date(value) >= new Date(start_date);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
area_ids: yup.string().nullable(),
|
||||||
|
supplier_ids: yup.string().nullable(),
|
||||||
|
product_ids: yup.string().nullable(),
|
||||||
|
product_category_ids: yup.string().nullable(),
|
||||||
|
filter_by: yup.string().nullable(),
|
||||||
|
sort_by: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PurchasesPerSupplierFilterValues = yup.InferType<
|
||||||
|
typeof PurchasesPerSupplierFilterSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const PurchasePerSupplierSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<LogisticPurchasePerSupplierReport['rows'][0]>[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchasePerSupplierSkeleton;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,289 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Table from '@/components/Table';
|
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
import Collapse from '@/components/Collapse';
|
|
||||||
|
|
||||||
import {
|
|
||||||
cn,
|
|
||||||
formatCurrency,
|
|
||||||
formatDate,
|
|
||||||
formatNumber,
|
|
||||||
formatVechicleNumber,
|
|
||||||
} from '@/lib/helper';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { DailyMarketingRow } from '@/types/api/report/marketing';
|
|
||||||
import { MarketingReportApi } from '@/services/api/report/marketing-report';
|
|
||||||
|
|
||||||
interface DailyMarketingsTableProps {
|
|
||||||
dailyMarketingsReportUrl: string;
|
|
||||||
onSetPage: (page: number) => void;
|
|
||||||
pageSize: number;
|
|
||||||
onSetPageSize: (pageSize: number) => void;
|
|
||||||
searchValue: string;
|
|
||||||
onSearchChange: ChangeEventHandler<HTMLInputElement>;
|
|
||||||
onFilterByChange: (filterBy: string) => void;
|
|
||||||
onSortByChange: (sort: 'asc' | 'desc' | '') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DailyMarketingsTable = ({
|
|
||||||
dailyMarketingsReportUrl,
|
|
||||||
onSetPage,
|
|
||||||
pageSize,
|
|
||||||
onSetPageSize,
|
|
||||||
searchValue,
|
|
||||||
onSearchChange,
|
|
||||||
onFilterByChange,
|
|
||||||
onSortByChange,
|
|
||||||
}: DailyMarketingsTableProps) => {
|
|
||||||
const { data: dailyMarketings, isLoading: isLoadingDailyMarketings } = useSWR(
|
|
||||||
dailyMarketingsReportUrl,
|
|
||||||
MarketingReportApi.getAllDailyMarketingFetcher,
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
|
|
||||||
const dailyMarketingColumns: ColumnDef<DailyMarketingRow>[] = [
|
|
||||||
{
|
|
||||||
header: 'No',
|
|
||||||
cell: (props) => props.row.index + 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'so_date',
|
|
||||||
header: 'Tanggal Jual',
|
|
||||||
cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'),
|
|
||||||
footer: 'Total',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'realization_date',
|
|
||||||
header: 'Tanggal Realisasi',
|
|
||||||
cell: (props) =>
|
|
||||||
formatDate(props.row.original.realization_date, 'DD-MMM-YYYY'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'aging_days',
|
|
||||||
header: 'Aging',
|
|
||||||
cell: (props) => `${props.row.original.aging_days} hari`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'warehouse',
|
|
||||||
header: 'Gudang',
|
|
||||||
cell: ({ row }) => row.original.warehouse.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'customer',
|
|
||||||
header: 'Pelanggan',
|
|
||||||
cell: ({ row }) => row.original.customer.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'do_number',
|
|
||||||
header: 'No. DO',
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'sales_person',
|
|
||||||
header: 'Sales/Marketing',
|
|
||||||
cell: (props) => props.row.original.sales.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'vehicle_number',
|
|
||||||
header: 'No. Polisi',
|
|
||||||
cell: (props) => (
|
|
||||||
<span className='text-nowrap'>
|
|
||||||
{formatVechicleNumber(props.row.original.vehicle_number)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'marketing_type',
|
|
||||||
header: 'Marketing Type',
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'product',
|
|
||||||
header: 'Produk',
|
|
||||||
cell: ({ row }) => row.original.product.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'qty',
|
|
||||||
header: 'Kuantitas',
|
|
||||||
cell: (props) => formatNumber(props.row.original.qty),
|
|
||||||
footer: () => {
|
|
||||||
const totalQty = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.total_qty
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalQty ? formatNumber(totalQty) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'average_weight',
|
|
||||||
header: 'Bobot Rata-Rata (Kg)',
|
|
||||||
cell: (props) => formatNumber(props.row.original.average_weight_kg),
|
|
||||||
footer: () => {
|
|
||||||
const totalAverageWeightKg = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.average_weight_kg
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalAverageWeightKg ? formatNumber(totalAverageWeightKg) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'total_weight',
|
|
||||||
header: 'Bobot Total (Kg)',
|
|
||||||
cell: (props) => formatNumber(props.row.original.total_weight_kg),
|
|
||||||
footer: () => {
|
|
||||||
const totalWeightKg = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.total_weight_kg
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalWeightKg ? formatNumber(totalWeightKg) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'sales_price',
|
|
||||||
header: 'Harga Jual (Rp)',
|
|
||||||
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
|
|
||||||
footer: () => {
|
|
||||||
const totalSalesPrice = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.average_sales_price
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalSalesPrice ? formatNumber(totalSalesPrice) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'hpp_price',
|
|
||||||
header: 'HPP (Rp)',
|
|
||||||
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
|
|
||||||
footer: () => {
|
|
||||||
const totalHppPricePerKg = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.total_hpp_price_per_kg
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalHppPricePerKg ? formatCurrency(totalHppPricePerKg) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'sales_amount',
|
|
||||||
header: 'Total (Rp)',
|
|
||||||
cell: (props) => formatCurrency(props.row.original.sales_amount),
|
|
||||||
footer: () => {
|
|
||||||
const totalSalesAmount = isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.total?.total_sales_amount
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return totalSalesAmount ? formatCurrency(totalSalesAmount) : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sorting.length === 1) {
|
|
||||||
onFilterByChange(sorting[0].id);
|
|
||||||
onSortByChange(sorting[0].desc ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
onFilterByChange('');
|
|
||||||
onSortByChange('');
|
|
||||||
}
|
|
||||||
}, [sorting]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setOpen(
|
|
||||||
isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings.data.length > 0
|
|
||||||
: false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [dailyMarketings, isResponseSuccess]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
body: 'p-4 shadow',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Collapse
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
title={
|
|
||||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
|
||||||
<div className='card-title'>Penjualan Harian</div>
|
|
||||||
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:keyboard-arrow-down'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className={cn('text-primary transition-transform', {
|
|
||||||
'-rotate-180': open,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className='w-full!'
|
|
||||||
titleClassName='w-full p-0!'
|
|
||||||
>
|
|
||||||
<div className='w-full p-0'>
|
|
||||||
<div className='flex flex-col gap-2 mb-4'>
|
|
||||||
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
|
||||||
<DebouncedTextInput
|
|
||||||
name='search'
|
|
||||||
placeholder='Cari Penjualan Harian'
|
|
||||||
value={searchValue}
|
|
||||||
onChange={onSearchChange}
|
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Table<DailyMarketingRow>
|
|
||||||
data={
|
|
||||||
isResponseSuccess(dailyMarketings) ? dailyMarketings?.data : []
|
|
||||||
}
|
|
||||||
columns={dailyMarketingColumns}
|
|
||||||
pageSize={pageSize}
|
|
||||||
onPageSizeChange={onSetPageSize}
|
|
||||||
rowOptions={[10, 20, 50, 100]}
|
|
||||||
page={
|
|
||||||
isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.meta?.page
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
totalItems={
|
|
||||||
isResponseSuccess(dailyMarketings)
|
|
||||||
? dailyMarketings?.meta?.total_results
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onPageChange={onSetPage}
|
|
||||||
isLoading={isLoadingDailyMarketings}
|
|
||||||
sorting={sorting}
|
|
||||||
setSorting={setSorting}
|
|
||||||
renderFooter={true}
|
|
||||||
className={{
|
|
||||||
containerClassName: cn({
|
|
||||||
'w-full mb-20':
|
|
||||||
isResponseSuccess(dailyMarketings) &&
|
|
||||||
dailyMarketings?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Collapse>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DailyMarketingsTable;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { JSX, useState } from 'react';
|
|
||||||
|
|
||||||
import Tabs from '@/components/Tabs';
|
|
||||||
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingReportContent';
|
|
||||||
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab';
|
|
||||||
|
|
||||||
type MarketingReportTabType =
|
|
||||||
| 'daily'
|
|
||||||
| 'transaction'
|
|
||||||
| 'hpp-comparison'
|
|
||||||
| 'daily-hpp';
|
|
||||||
|
|
||||||
const marketingReportTabs: {
|
|
||||||
id: MarketingReportTabType;
|
|
||||||
label: string;
|
|
||||||
content: JSX.Element;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
id: 'daily',
|
|
||||||
label: 'Penjualan Harian',
|
|
||||||
content: <DailyMarketingReportContent />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'daily-hpp',
|
|
||||||
label: 'HPP Harian Kandang',
|
|
||||||
content: <HppPerKandangTab />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const MarketingReportContent = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState<string>('daily');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className='w-full max-w-full pb-16'>
|
|
||||||
<Tabs
|
|
||||||
activeTabId={activeTab}
|
|
||||||
onTabChange={setActiveTab}
|
|
||||||
tabs={marketingReportTabs}
|
|
||||||
variant='lifted'
|
|
||||||
className={{
|
|
||||||
content: '-m-px pl-px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MarketingReportContent;
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Tabs from '@/components/Tabs';
|
||||||
|
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab';
|
||||||
|
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
|
||||||
|
const MarketingReportContent = () => {
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Penjualan Harian',
|
||||||
|
content: <DailyMarketingReportContent tabId={'1'} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
label: 'HPP Harian Kandang',
|
||||||
|
content: <HppPerKandangTab tabId={'2'} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className='w-full'>
|
||||||
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
variant='boxed'
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onTabChange={setActiveTabId}
|
||||||
|
className={{
|
||||||
|
tabHeaderWrapper:
|
||||||
|
'justify-between items-center p-3 border-b border-base-content/10',
|
||||||
|
tab: 'w-fit',
|
||||||
|
content: 'p-0 m-0',
|
||||||
|
}}
|
||||||
|
sideContent={tabActions[activeTabId] || null}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarketingReportContent;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatNumber,
|
||||||
|
formatDate,
|
||||||
|
formatVechicleNumber,
|
||||||
|
} from '@/lib/helper';
|
||||||
|
import { DailyMarketingRow, SalesSummary } from '@/types/api/report/marketing';
|
||||||
|
|
||||||
|
interface DailyMarketingExportExcelParams {
|
||||||
|
data: DailyMarketingRow[];
|
||||||
|
summaryTotal?: SalesSummary;
|
||||||
|
period?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateDailyMarketingExcel = async (
|
||||||
|
params: DailyMarketingExportExcelParams
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!params.data || params.data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
|
||||||
|
// ===== DAILY MARKETING WORKSHEET =====
|
||||||
|
const columns = [
|
||||||
|
{ header: 'No', key: 'no', width: 5 },
|
||||||
|
{ header: 'Tanggal Jual', key: 'soDate', width: 15 },
|
||||||
|
{ header: 'Tanggal Realisasi', key: 'realizationDate', width: 18 },
|
||||||
|
{ header: 'Aging', key: 'aging', width: 10 },
|
||||||
|
{ header: 'Gudang', key: 'warehouse', width: 25 },
|
||||||
|
{ header: 'Pelanggan', key: 'customer', width: 25 },
|
||||||
|
{ header: 'No. DO', key: 'doNumber', width: 15 },
|
||||||
|
{ header: 'Sales/Marketing', key: 'sales', width: 20 },
|
||||||
|
{ header: 'No. Polisi', key: 'vehicleNumber', width: 15 },
|
||||||
|
{ header: 'Marketing Type', key: 'marketingType', width: 15 },
|
||||||
|
{ header: 'Produk', key: 'product', width: 20 },
|
||||||
|
{ header: 'Kuantitas', key: 'qty', width: 12 },
|
||||||
|
{ header: 'Bobot Rata-Rata (Kg)', key: 'averageWeight', width: 20 },
|
||||||
|
{ header: 'Bobot Total (Kg)', key: 'totalWeight', width: 18 },
|
||||||
|
{ header: 'Harga Jual (Rp)', key: 'salesPrice', width: 18 },
|
||||||
|
{ header: 'HPP (Rp)', key: 'hppPrice', width: 15 },
|
||||||
|
{ header: 'Total (Rp)', key: 'salesAmount', width: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const worksheet = workbook.addWorksheet('Laporan Marketing Harian');
|
||||||
|
worksheet.columns = columns;
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
params.data.forEach((item: DailyMarketingRow, index: number) => {
|
||||||
|
worksheet.addRow({
|
||||||
|
no: index + 1,
|
||||||
|
soDate: formatDate(item.so_date, 'DD-MMM-YYYY'),
|
||||||
|
realizationDate: formatDate(item.realization_date, 'DD-MMM-YYYY'),
|
||||||
|
aging: `${item.aging_days} hari`,
|
||||||
|
warehouse: item.warehouse?.name || '',
|
||||||
|
customer: item.customer?.name || '',
|
||||||
|
doNumber: item.do_number || '',
|
||||||
|
sales: item.sales?.name || '',
|
||||||
|
vehicleNumber: formatVechicleNumber(item.vehicle_number),
|
||||||
|
marketingType: item.marketing_type || '',
|
||||||
|
product: item.product?.name || '',
|
||||||
|
qty: formatNumber(item.qty || 0),
|
||||||
|
averageWeight: formatNumber(item.average_weight_kg || 0),
|
||||||
|
totalWeight: formatNumber(item.total_weight_kg || 0),
|
||||||
|
salesPrice: formatCurrency(item.sales_price_per_kg || 0),
|
||||||
|
hppPrice: formatCurrency(item.hpp_price_per_kg || 0),
|
||||||
|
salesAmount: formatCurrency(item.sales_amount || 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add TOTAL row if summary data is available
|
||||||
|
if (params.summaryTotal) {
|
||||||
|
worksheet.addRow({
|
||||||
|
no: 'TOTAL',
|
||||||
|
soDate: 'ALL',
|
||||||
|
realizationDate: '-',
|
||||||
|
aging: '-',
|
||||||
|
warehouse: '-',
|
||||||
|
customer: '-',
|
||||||
|
doNumber: '-',
|
||||||
|
sales: '-',
|
||||||
|
vehicleNumber: '-',
|
||||||
|
marketingType: '-',
|
||||||
|
product: '-',
|
||||||
|
qty: formatNumber(params.summaryTotal.total_qty || 0),
|
||||||
|
averageWeight: formatNumber(params.summaryTotal.average_weight_kg || 0),
|
||||||
|
totalWeight: formatNumber(params.summaryTotal.total_weight_kg || 0),
|
||||||
|
salesPrice: formatNumber(params.summaryTotal.average_sales_price || 0),
|
||||||
|
hppPrice: formatCurrency(params.summaryTotal.total_hpp_price_per_kg || 0),
|
||||||
|
salesAmount: formatCurrency(params.summaryTotal.total_sales_amount || 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
worksheet.columns.forEach((column) => {
|
||||||
|
if (column.width && column.width < 10) {
|
||||||
|
column.width = 10;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentDate = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = params.period
|
||||||
|
? `laporan-marketing-harian-${params.period}.xlsx`
|
||||||
|
: `laporan-marketing-harian-${currentDate}.xlsx`;
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type DailyMarketingReportFilterType = {
|
||||||
|
search: string | null;
|
||||||
|
area_id: string | null;
|
||||||
|
location_id: string | null;
|
||||||
|
warehouse_id: string | null;
|
||||||
|
customer_id: string | null;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
marketing_type: string | null;
|
||||||
|
filter_by: string | null;
|
||||||
|
sort_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DailyMarketingReportFilterSchema = yup.object({
|
||||||
|
search: yup.string().nullable(),
|
||||||
|
area_id: yup.string().nullable(),
|
||||||
|
location_id: yup.string().nullable(),
|
||||||
|
warehouse_id: yup.string().nullable(),
|
||||||
|
customer_id: yup.string().nullable(),
|
||||||
|
start_date: yup.string().nullable(),
|
||||||
|
end_date: yup
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.test(
|
||||||
|
'is-greater-than-start',
|
||||||
|
'Tanggal akhir tidak boleh masa lampau',
|
||||||
|
function (value) {
|
||||||
|
const { start_date } = this.parent;
|
||||||
|
if (!start_date || !value) return true;
|
||||||
|
return new Date(value) >= new Date(start_date);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
marketing_type: yup.string().nullable(),
|
||||||
|
filter_by: yup.string().nullable(),
|
||||||
|
sort_by: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DailyMarketingReportFilterValues = yup.InferType<
|
||||||
|
typeof DailyMarketingReportFilterSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type HppPerKandangFilterType = {
|
||||||
|
area_id: string | null;
|
||||||
|
location_id: string | null;
|
||||||
|
kandang_id: string | null;
|
||||||
|
weight_min: string | null;
|
||||||
|
weight_max: string | null;
|
||||||
|
period: string | null;
|
||||||
|
sort_by: string | null;
|
||||||
|
show_unrecorded: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HppPerKandangFilterSchema = yup.object({
|
||||||
|
area_id: yup.string().nullable(),
|
||||||
|
location_id: yup.string().nullable(),
|
||||||
|
kandang_id: yup.string().nullable(),
|
||||||
|
weight_min: yup.string().nullable(),
|
||||||
|
weight_max: yup
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.test(
|
||||||
|
'is-greater-than-min',
|
||||||
|
'Rentang bobot max tidak boleh lebih kecil dari min',
|
||||||
|
function (value) {
|
||||||
|
const { weight_min } = this.parent;
|
||||||
|
if (!weight_min || !value) return true;
|
||||||
|
const weightMinNum = parseFloat(weight_min) || 0;
|
||||||
|
const weightMaxNum = parseFloat(value) || 0;
|
||||||
|
return weightMaxNum >= weightMinNum;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
period: yup.string().required('Periode wajib diisi'),
|
||||||
|
sort_by: yup.string().nullable(),
|
||||||
|
show_unrecorded: yup.boolean().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HppPerKandangFilterValues = yup.InferType<
|
||||||
|
typeof HppPerKandangFilterSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { DailyMarketingRow } from '@/types/api/report/marketing.d';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const DailyMarketingReportSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<DailyMarketingRow>[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DailyMarketingReportSkeleton;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const HppPerKandangSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ColumnDef<HppPerKandangReport['rows'][0]>[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HppPerKandangSkeleton;
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
|
||||||
import { pdf } from '@react-pdf/renderer';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import Dropdown from '@/components/dropdown/Dropdown';
|
|
||||||
import DateInput from '@/components/input/DateInput';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
|
||||||
import DailyMarketingsTable from '@/components/pages/report/marketing/DailyMarketingsTable';
|
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
|
||||||
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingReportPDF';
|
|
||||||
|
|
||||||
import { Area } from '@/types/api/master-data/area';
|
|
||||||
import {
|
|
||||||
AreaApi,
|
|
||||||
CustomerApi,
|
|
||||||
LocationApi,
|
|
||||||
WarehouseApi,
|
|
||||||
} from '@/services/api/master-data';
|
|
||||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
|
||||||
import { Customer } from '@/types/api/master-data/customer';
|
|
||||||
import { MarketingReportApi } from '@/services/api/report/marketing-report';
|
|
||||||
import {
|
|
||||||
MARKETING_DATE_FILTER_TYPE_OPTIONS,
|
|
||||||
MARKETING_TYPE_OPTIONS,
|
|
||||||
} from '@/config/constant';
|
|
||||||
import { httpClient } from '@/services/http/client';
|
|
||||||
import { BaseApiResponse } from '@/types/api/api-general';
|
|
||||||
import {
|
|
||||||
DailyMarketingReport,
|
|
||||||
DailyMarketingReportResponse,
|
|
||||||
} from '@/types/api/report/marketing';
|
|
||||||
import { isResponseError } from '@/lib/api-helper';
|
|
||||||
|
|
||||||
const DailyMarketingReportContent = () => {
|
|
||||||
const {
|
|
||||||
state: tableFilterState,
|
|
||||||
updateFilter,
|
|
||||||
setPage,
|
|
||||||
setPageSize,
|
|
||||||
toQueryString: getTableFilterQueryString,
|
|
||||||
reset: resetFilter,
|
|
||||||
} = useTableFilter({
|
|
||||||
initial: {
|
|
||||||
search: '',
|
|
||||||
area_id: '',
|
|
||||||
location_id: '',
|
|
||||||
warehouse_id: '',
|
|
||||||
customer_id: '',
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
marketing_type: '',
|
|
||||||
filter_by: '',
|
|
||||||
sort_by: '',
|
|
||||||
},
|
|
||||||
paramMap: {
|
|
||||||
page: 'page',
|
|
||||||
pageSize: 'limit',
|
|
||||||
area_id: 'area_id',
|
|
||||||
location_id: 'location_id',
|
|
||||||
warehouse_id: 'warehouse_id',
|
|
||||||
customer_id: 'customer_id',
|
|
||||||
start_date: 'start_date',
|
|
||||||
end_date: 'end_date',
|
|
||||||
marketing_type: 'marketing_type',
|
|
||||||
filter_by: 'filter_by',
|
|
||||||
sort_by: 'sort_by',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const dailyMarketingsReportUrl = `${MarketingReportApi.basePath}${getTableFilterQueryString()}`;
|
|
||||||
|
|
||||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
|
||||||
useState(false);
|
|
||||||
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
|
|
||||||
|
|
||||||
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
|
|
||||||
const {
|
|
||||||
setInputValue: setAreaInputValue,
|
|
||||||
options: areaOptions,
|
|
||||||
isLoadingOptions: isLoadingAreaOptions,
|
|
||||||
loadMore: loadMoreAreas,
|
|
||||||
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedArea(val as OptionType);
|
|
||||||
updateFilter('area_id', val ? ((val as OptionType).value as string) : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
setInputValue: setLocationInputValue,
|
|
||||||
options: locationOptions,
|
|
||||||
isLoadingOptions: isLoadingLocationOptions,
|
|
||||||
loadMore: loadMoreLocations,
|
|
||||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedLocation(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'location_id',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedWarehouse, setSelectedWarehouse] = useState<OptionType | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
setInputValue: setWarehouseInputValue,
|
|
||||||
options: warehouseOptions,
|
|
||||||
isLoadingOptions: isLoadingWarehouseOptions,
|
|
||||||
loadMore: loadMoreWarehouses,
|
|
||||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedWarehouse(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'warehouse_id',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedCustomer, setSelectedCustomer] = useState<OptionType | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
setInputValue: setCustomerInputValue,
|
|
||||||
options: customerOptions,
|
|
||||||
isLoadingOptions: isLoadingCustomerOptions,
|
|
||||||
loadMore: loadMoreCustomers,
|
|
||||||
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedCustomer(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'customer_id',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
updateFilter('start_date', e.target.value ? e.target.value : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const endDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
updateFilter('end_date', e.target.value ? e.target.value : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedMarketingDateFilterType, setSelectedMarketingDateFilterType] =
|
|
||||||
useState<OptionType | null>(null);
|
|
||||||
const marketingDateFilterTypeChangeHandler = (
|
|
||||||
val: OptionType | OptionType[] | null
|
|
||||||
) => {
|
|
||||||
setSelectedMarketingDateFilterType(val as OptionType);
|
|
||||||
updateFilter('filter_by', val ? ((val as OptionType).value as string) : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedMarketingType, setSelectedMarketingType] =
|
|
||||||
useState<OptionType | null>(null);
|
|
||||||
const marketingTypeChangeHandler = (
|
|
||||||
val: OptionType | OptionType[] | null
|
|
||||||
) => {
|
|
||||||
setSelectedMarketingType(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'marketing_type',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
updateFilter('search', e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterByChangeHandler = (filterBy: string) => {
|
|
||||||
updateFilter('filter_by', filterBy);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortByChangeHandler = (sort: 'asc' | 'desc' | '') => {
|
|
||||||
updateFilter('sort_by', sort);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToExcelHandler = async () => {
|
|
||||||
setIsLoadingExportingToExcel(true);
|
|
||||||
|
|
||||||
await MarketingReportApi.exportDailyMarketingToExcel(
|
|
||||||
getTableFilterQueryString()
|
|
||||||
);
|
|
||||||
|
|
||||||
setIsLoadingExportingToExcel(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToPdfHandler = async () => {
|
|
||||||
setIsLoadingExportingToPdf(true);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(getTableFilterQueryString());
|
|
||||||
|
|
||||||
params.set('limit', '9999999');
|
|
||||||
|
|
||||||
const queryString = `?${params.toString()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dailyMarketingsReport =
|
|
||||||
await httpClient<DailyMarketingReportResponse>(
|
|
||||||
`${MarketingReportApi.basePath}${queryString}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isResponseError(dailyMarketingsReport)) {
|
|
||||||
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPdf = async () => {
|
|
||||||
const dailyMarketingReportPdfBlob = await pdf(
|
|
||||||
<DailyMarketingReportPDF
|
|
||||||
data={dailyMarketingsReport.data}
|
|
||||||
total={dailyMarketingsReport.total}
|
|
||||||
/>
|
|
||||||
).toBlob();
|
|
||||||
|
|
||||||
const dailyMarketingReportPdfUrl = URL.createObjectURL(
|
|
||||||
dailyMarketingReportPdfBlob
|
|
||||||
);
|
|
||||||
window.open(dailyMarketingReportPdfUrl, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadPdf = async () => {
|
|
||||||
const blob = await pdf(
|
|
||||||
<DailyMarketingReportPDF
|
|
||||||
data={dailyMarketingsReport.data}
|
|
||||||
total={dailyMarketingsReport.total}
|
|
||||||
/>
|
|
||||||
).toBlob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = 'laporan-penjualan-harian.pdf';
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
await openPdf();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingExportingToPdf(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setSelectedArea(null);
|
|
||||||
setSelectedLocation(null);
|
|
||||||
setSelectedWarehouse(null);
|
|
||||||
setSelectedCustomer(null);
|
|
||||||
setSelectedMarketingType(null);
|
|
||||||
resetFilter();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
tableFilterState.filter_by === 'realization_date' ||
|
|
||||||
tableFilterState.filter_by === 'so_date'
|
|
||||||
) {
|
|
||||||
setSelectedMarketingDateFilterType({
|
|
||||||
label:
|
|
||||||
tableFilterState.filter_by === 'realization_date'
|
|
||||||
? 'Tanggal Realisasi'
|
|
||||||
: 'Tanggal SO',
|
|
||||||
value: tableFilterState.filter_by,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSelectedMarketingDateFilterType(null);
|
|
||||||
}
|
|
||||||
}, [tableFilterState.filter_by]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full border border-gray-200 p-4'>
|
|
||||||
<div>
|
|
||||||
<h2 className='text-xl font-bold text-center'>Penjualan Harian</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className='flex flex-col gap-4 mb-6'>
|
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
label='Area'
|
|
||||||
placeholder='Pilih Area'
|
|
||||||
options={areaOptions}
|
|
||||||
isLoading={isLoadingAreaOptions}
|
|
||||||
value={selectedArea}
|
|
||||||
onChange={areaChangeHandler}
|
|
||||||
onInputChange={setAreaInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreAreas}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Lokasi'
|
|
||||||
placeholder='Pilih Lokasi'
|
|
||||||
options={locationOptions}
|
|
||||||
isLoading={isLoadingLocationOptions}
|
|
||||||
value={selectedLocation}
|
|
||||||
onChange={locationChangeHandler}
|
|
||||||
onInputChange={setLocationInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreLocations}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Gudang'
|
|
||||||
placeholder='Pilih Gudang'
|
|
||||||
options={warehouseOptions}
|
|
||||||
isLoading={isLoadingWarehouseOptions}
|
|
||||||
value={selectedWarehouse}
|
|
||||||
onChange={warehouseChangeHandler}
|
|
||||||
onInputChange={setWarehouseInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreWarehouses}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Customer'
|
|
||||||
placeholder='Pilih Customer'
|
|
||||||
options={customerOptions}
|
|
||||||
isLoading={isLoadingCustomerOptions}
|
|
||||||
value={selectedCustomer}
|
|
||||||
onChange={customerChangeHandler}
|
|
||||||
onInputChange={setCustomerInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreCustomers}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DateInput
|
|
||||||
name='startDate'
|
|
||||||
label='Tanggal Awal'
|
|
||||||
placeholder='Tanggal Realisasi'
|
|
||||||
value={tableFilterState.start_date}
|
|
||||||
onChange={startDateChangeHandler}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DateInput
|
|
||||||
name='endDate'
|
|
||||||
label='Tanggal Akhir'
|
|
||||||
placeholder='Tanggal Realisasi'
|
|
||||||
value={tableFilterState.end_date}
|
|
||||||
onChange={endDateChangeHandler}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
label='Filter Tanggal'
|
|
||||||
placeholder='Pilih Filter Tanggal'
|
|
||||||
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
|
|
||||||
value={selectedMarketingDateFilterType}
|
|
||||||
onChange={marketingDateFilterTypeChangeHandler}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Tipe Marketing'
|
|
||||||
placeholder='Pilih Tipe Marketing'
|
|
||||||
options={MARKETING_TYPE_OPTIONS}
|
|
||||||
value={selectedMarketingType}
|
|
||||||
onChange={marketingTypeChangeHandler}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='col-span-12 sm:col-span-6 lg:col-span-8 flex flex-wrap sm:justify-end items-end gap-2'>
|
|
||||||
<Button
|
|
||||||
color='primary'
|
|
||||||
// onClick={handleSearch}
|
|
||||||
className='flex-1 sm:flex-none'
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
|
|
||||||
Cari
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color='warning'
|
|
||||||
onClick={handleReset}
|
|
||||||
className='flex-1 sm:flex-none'
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons-outline:refresh' width={20} height={20} />
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dropdown
|
|
||||||
align='end'
|
|
||||||
direction='bottom'
|
|
||||||
trigger={
|
|
||||||
<Button>
|
|
||||||
Export{' '}
|
|
||||||
<Icon
|
|
||||||
icon='heroicons-outline:download'
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Menu>
|
|
||||||
<MenuItem
|
|
||||||
title='Export to Excel'
|
|
||||||
icon='icon-park-outline:excel'
|
|
||||||
isLoading={isLoadingExportingToExcel}
|
|
||||||
onClick={exportToExcelHandler}
|
|
||||||
className='text-nowrap'
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
title='Export to PDF'
|
|
||||||
icon='icon-park-outline:file-pdf-one'
|
|
||||||
onClick={exportToPdfHandler}
|
|
||||||
className='text-nowrap'
|
|
||||||
/>
|
|
||||||
</Menu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DailyMarketingsTable
|
|
||||||
dailyMarketingsReportUrl={dailyMarketingsReportUrl}
|
|
||||||
onSetPage={setPage}
|
|
||||||
pageSize={tableFilterState.pageSize}
|
|
||||||
onSetPageSize={setPageSize}
|
|
||||||
searchValue={tableFilterState.search}
|
|
||||||
onSearchChange={searchChangeHandler}
|
|
||||||
onFilterByChange={filterByChangeHandler}
|
|
||||||
onSortByChange={sortByChangeHandler}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DailyMarketingReportContent;
|
|
||||||
@@ -0,0 +1,939 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import { AreaApi } from '@/services/api/master-data';
|
||||||
|
import { LocationApi } from '@/services/api/master-data';
|
||||||
|
import { WarehouseApi } from '@/services/api/master-data';
|
||||||
|
import { CustomerApi } from '@/services/api/master-data';
|
||||||
|
import { MarketingReportApi } from '@/services/api/report/marketing-report';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatNumber,
|
||||||
|
formatDate,
|
||||||
|
formatVechicleNumber,
|
||||||
|
formatTitleCase,
|
||||||
|
} from '@/lib/helper';
|
||||||
|
import {
|
||||||
|
DailyMarketingRow,
|
||||||
|
DailyMarketingReportResponse,
|
||||||
|
} from '@/types/api/report/marketing';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Dropdown from '@/components/Dropdown';
|
||||||
|
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF';
|
||||||
|
import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import {
|
||||||
|
DailyMarketingReportFilterSchema,
|
||||||
|
DailyMarketingReportFilterType,
|
||||||
|
} from '@/components/pages/report/marketing/filter/DailyMarketingFilter';
|
||||||
|
import SelectInput from '@/components/input/SelectInput';
|
||||||
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
|
||||||
|
import { useEffect as useEffectHook } from 'react';
|
||||||
|
import { httpClient } from '@/services/http/client';
|
||||||
|
import { isResponseError } from '@/lib/api-helper';
|
||||||
|
import {
|
||||||
|
MARKETING_DATE_FILTER_TYPE_OPTIONS,
|
||||||
|
MARKETING_TYPE_OPTIONS,
|
||||||
|
} from '@/config/constant';
|
||||||
|
import Badge from '@/components/Badge';
|
||||||
|
|
||||||
|
interface DailyMarketingTabProps {
|
||||||
|
tabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterParams {
|
||||||
|
area_id?: string;
|
||||||
|
location_id?: string;
|
||||||
|
warehouse_id?: string;
|
||||||
|
customer_id?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
filter_by?: string;
|
||||||
|
marketing_type?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||||
|
// ===== STATE MANAGEMENT =====
|
||||||
|
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||||
|
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||||
|
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||||
|
|
||||||
|
// ===== SUBMISSION STATE =====
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
|
||||||
|
// ===== SEARCH STATE =====
|
||||||
|
const [searchValue, setSearchValue] = useState<string>('');
|
||||||
|
|
||||||
|
// ===== FILTER STATE =====
|
||||||
|
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||||
|
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== OPTIONS =====
|
||||||
|
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
|
||||||
|
AreaApi.basePath,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { options: locationOptions, isLoadingOptions: isLoadingLocations } =
|
||||||
|
useSelect(LocationApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const { options: warehouseOptions, isLoadingOptions: isLoadingWarehouses } =
|
||||||
|
useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } =
|
||||||
|
useSelect(CustomerApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<DailyMarketingReportFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
search: null,
|
||||||
|
area_id: null,
|
||||||
|
location_id: null,
|
||||||
|
warehouse_id: null,
|
||||||
|
customer_id: null,
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
filter_by: null,
|
||||||
|
marketing_type: null,
|
||||||
|
sort_by: null,
|
||||||
|
},
|
||||||
|
validationSchema: DailyMarketingReportFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
setFilterParams({
|
||||||
|
area_id: values.area_id || undefined,
|
||||||
|
location_id: values.location_id || undefined,
|
||||||
|
warehouse_id: values.warehouse_id || undefined,
|
||||||
|
customer_id: values.customer_id || undefined,
|
||||||
|
start_date: values.start_date || undefined,
|
||||||
|
end_date: values.end_date || undefined,
|
||||||
|
filter_by: values.filter_by || undefined,
|
||||||
|
marketing_type: values.marketing_type || undefined,
|
||||||
|
sort_by: values.sort_by || undefined,
|
||||||
|
});
|
||||||
|
filterModal.closeModal();
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
setFilterParams({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== SEARCH CHANGE HANDLER =====
|
||||||
|
const searchChangeHandler = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== DERIVED VALUES =====
|
||||||
|
const areaValue = useMemo(() => {
|
||||||
|
if (!formik.values.area_id) return null;
|
||||||
|
return (
|
||||||
|
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.area_id, areaOptions]);
|
||||||
|
|
||||||
|
const locationValue = useMemo(() => {
|
||||||
|
if (!formik.values.location_id) return null;
|
||||||
|
return (
|
||||||
|
locationOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.location_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.location_id, locationOptions]);
|
||||||
|
|
||||||
|
const warehouseValue = useMemo(() => {
|
||||||
|
if (!formik.values.warehouse_id) return null;
|
||||||
|
return (
|
||||||
|
warehouseOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.warehouse_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.warehouse_id, warehouseOptions]);
|
||||||
|
|
||||||
|
const customerValue = useMemo(() => {
|
||||||
|
if (!formik.values.customer_id) return null;
|
||||||
|
return (
|
||||||
|
customerOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.customer_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.customer_id, customerOptions]);
|
||||||
|
|
||||||
|
const filterByValue = useMemo(() => {
|
||||||
|
if (!formik.values.filter_by) return null;
|
||||||
|
return (
|
||||||
|
MARKETING_DATE_FILTER_TYPE_OPTIONS.find(
|
||||||
|
(opt) => opt.value === formik.values.filter_by
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.filter_by]);
|
||||||
|
|
||||||
|
const marketingTypeValue = useMemo(() => {
|
||||||
|
if (!formik.values.marketing_type) return null;
|
||||||
|
return (
|
||||||
|
MARKETING_TYPE_OPTIONS.find(
|
||||||
|
(opt) => opt.value === formik.values.marketing_type
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.marketing_type]);
|
||||||
|
|
||||||
|
// ===== ACTIVE FILTERS COUNT =====
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (filterParams.area_id) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.location_id) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.warehouse_id) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.customer_id) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.start_date || filterParams.end_date) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.filter_by) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.marketing_type) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterParams.sort_by) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
const hasFilters = activeFiltersCount > 0;
|
||||||
|
|
||||||
|
// ===== DATA FETCHING =====
|
||||||
|
const { data: dailyMarketings, isLoading } = useSWR(
|
||||||
|
isSubmitted
|
||||||
|
? () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (searchValue) params.set('search', searchValue);
|
||||||
|
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||||
|
if (filterParams.location_id)
|
||||||
|
params.set('location_id', filterParams.location_id);
|
||||||
|
if (filterParams.warehouse_id)
|
||||||
|
params.set('warehouse_id', filterParams.warehouse_id);
|
||||||
|
if (filterParams.customer_id)
|
||||||
|
params.set('customer_id', filterParams.customer_id);
|
||||||
|
if (filterParams.start_date)
|
||||||
|
params.set('start_date', filterParams.start_date);
|
||||||
|
if (filterParams.end_date)
|
||||||
|
params.set('end_date', filterParams.end_date);
|
||||||
|
if (filterParams.filter_by)
|
||||||
|
params.set('filter_by', filterParams.filter_by);
|
||||||
|
if (filterParams.marketing_type)
|
||||||
|
params.set('marketing_type', filterParams.marketing_type);
|
||||||
|
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||||
|
|
||||||
|
return ['daily-marketing-report', params.toString()];
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
([, params]) =>
|
||||||
|
MarketingReportApi.getAllDailyMarketingFetcher(
|
||||||
|
`${MarketingReportApi.basePath}?${params}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: DailyMarketingRow[] = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(dailyMarketings)
|
||||||
|
? (dailyMarketings?.data as DailyMarketingRow[]) || []
|
||||||
|
: [],
|
||||||
|
[dailyMarketings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(dailyMarketings) && dailyMarketings?.total
|
||||||
|
? dailyMarketings.total
|
||||||
|
: undefined,
|
||||||
|
[dailyMarketings]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== EXPORT DATA FETCHER =====
|
||||||
|
const dailyMarketingsExport = useCallback(async (): Promise<
|
||||||
|
DailyMarketingRow[] | null
|
||||||
|
> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (searchValue) params.set('search', searchValue);
|
||||||
|
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||||
|
if (filterParams.location_id)
|
||||||
|
params.set('location_id', filterParams.location_id);
|
||||||
|
if (filterParams.warehouse_id)
|
||||||
|
params.set('warehouse_id', filterParams.warehouse_id);
|
||||||
|
if (filterParams.customer_id)
|
||||||
|
params.set('customer_id', filterParams.customer_id);
|
||||||
|
if (filterParams.start_date)
|
||||||
|
params.set('start_date', filterParams.start_date);
|
||||||
|
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||||
|
if (filterParams.filter_by) params.set('filter_by', filterParams.filter_by);
|
||||||
|
if (filterParams.marketing_type)
|
||||||
|
params.set('marketing_type', filterParams.marketing_type);
|
||||||
|
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||||
|
params.set('limit', '9999999');
|
||||||
|
|
||||||
|
const queryString = `?${params.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient<DailyMarketingReportResponse>(
|
||||||
|
`${MarketingReportApi.basePath}${queryString}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseError(response)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data || [];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [filterParams, searchValue]);
|
||||||
|
|
||||||
|
// ===== EXPORT HANDLERS =====
|
||||||
|
const handleExportExcel = useCallback(async () => {
|
||||||
|
setIsExcelExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allDataForExport = await dailyMarketingsExport();
|
||||||
|
|
||||||
|
if (!allDataForExport || allDataForExport.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const period =
|
||||||
|
filterParams.start_date && filterParams.end_date
|
||||||
|
? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await generateDailyMarketingExcel({
|
||||||
|
data: allDataForExport,
|
||||||
|
summaryTotal: summaryTotal,
|
||||||
|
period: period,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsExcelExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterParams, dailyMarketingsExport, summaryTotal]);
|
||||||
|
|
||||||
|
const handleExportPDF = useCallback(async () => {
|
||||||
|
setIsPdfExportLoading(true);
|
||||||
|
try {
|
||||||
|
const allDataForExport = await dailyMarketingsExport();
|
||||||
|
|
||||||
|
if (!allDataForExport || allDataForExport.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diekspor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyMarketingReportPdfBlob = await pdf(
|
||||||
|
<DailyMarketingReportPDF data={allDataForExport} total={summaryTotal} />
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
|
const dailyMarketingReportPdfUrl = URL.createObjectURL(
|
||||||
|
dailyMarketingReportPdfBlob
|
||||||
|
);
|
||||||
|
window.open(dailyMarketingReportPdfUrl, '_blank');
|
||||||
|
|
||||||
|
toast.success('PDF berhasil dibuat.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsPdfExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [dailyMarketingsExport, summaryTotal]);
|
||||||
|
|
||||||
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
|
useEffectHook(() => {
|
||||||
|
setTabActions(
|
||||||
|
tabId,
|
||||||
|
<div className='flex flex-row gap-3 items-center'>
|
||||||
|
<DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Search'
|
||||||
|
value={searchValue}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
startAdornment={
|
||||||
|
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
|
||||||
|
}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full min-w-48 max-w-3xs',
|
||||||
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
|
input: 'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||||
|
{
|
||||||
|
'border-primary-gradient text-primary': hasFilters,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
Filter
|
||||||
|
{hasFilters && (
|
||||||
|
<Badge
|
||||||
|
className={{
|
||||||
|
badge:
|
||||||
|
'p-1.5 bg-[#FF3535] text-xs text-base-100 border border-base-300 rounded-lg',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeFiltersCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
isLoading={isAnyExportLoading}
|
||||||
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
|
>
|
||||||
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:cloud-arrow-down'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
isLoading={isExcelExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
|
Export to Excel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
tabId,
|
||||||
|
searchValue,
|
||||||
|
hasFilters,
|
||||||
|
activeFiltersCount,
|
||||||
|
isAnyExportLoading,
|
||||||
|
filterModal.open,
|
||||||
|
setTabActions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffectHook(() => {
|
||||||
|
return () => {
|
||||||
|
clearTabActions(tabId);
|
||||||
|
};
|
||||||
|
}, [tabId, clearTabActions]);
|
||||||
|
|
||||||
|
const getTableColumns = (): ColumnDef<DailyMarketingRow>[] => {
|
||||||
|
const tableColumns: ColumnDef<DailyMarketingRow>[] = [
|
||||||
|
{
|
||||||
|
id: 'no',
|
||||||
|
header: 'No',
|
||||||
|
cell: (props) => props.row.index + 1,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>TOTAL</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'so_date',
|
||||||
|
header: 'Tanggal Jual',
|
||||||
|
accessorKey: 'so_date',
|
||||||
|
cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'),
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>ALL</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realization_date',
|
||||||
|
header: 'Tanggal Realisasi',
|
||||||
|
accessorKey: 'realization_date',
|
||||||
|
cell: (props) =>
|
||||||
|
formatDate(props.row.original.realization_date, 'DD-MMM-YYYY'),
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aging_days',
|
||||||
|
header: 'Aging',
|
||||||
|
accessorKey: 'aging_days',
|
||||||
|
cell: (props) => `${props.row.original.aging_days} hari`,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'warehouse',
|
||||||
|
header: 'Gudang',
|
||||||
|
accessorKey: 'warehouse',
|
||||||
|
cell: ({ row }) => row.original.warehouse.name,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customer',
|
||||||
|
header: 'Pelanggan',
|
||||||
|
accessorKey: 'customer',
|
||||||
|
cell: ({ row }) => row.original.customer.name,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'do_number',
|
||||||
|
header: 'No. DO',
|
||||||
|
accessorKey: 'do_number',
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales_person',
|
||||||
|
header: 'Sales/Marketing',
|
||||||
|
accessorKey: 'sales',
|
||||||
|
cell: (props) => props.row.original.sales.name,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vehicle_number',
|
||||||
|
header: 'No. Polisi',
|
||||||
|
accessorKey: 'vehicle_number',
|
||||||
|
cell: (props) => (
|
||||||
|
<span className='text-nowrap'>
|
||||||
|
{formatVechicleNumber(props.row.original.vehicle_number)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'marketing_type',
|
||||||
|
header: 'Marketing Type',
|
||||||
|
accessorKey: 'marketing_type',
|
||||||
|
cell: (props) => (
|
||||||
|
<span className='text-nowrap'>
|
||||||
|
{formatTitleCase(props.row.original.marketing_type || '-')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'product',
|
||||||
|
header: 'Produk',
|
||||||
|
accessorKey: 'product',
|
||||||
|
cell: ({ row }) => row.original.product.name,
|
||||||
|
footer: () => <div className='font-semibold text-gray-900'>-</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qty',
|
||||||
|
header: 'Kuantitas',
|
||||||
|
accessorKey: 'qty',
|
||||||
|
cell: (props) => formatNumber(props.row.original.qty),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.total_qty
|
||||||
|
? formatNumber(summaryTotal.total_qty)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'average_weight',
|
||||||
|
header: 'Bobot Rata-Rata (Kg)',
|
||||||
|
accessorKey: 'average_weight_kg',
|
||||||
|
cell: (props) => formatNumber(props.row.original.average_weight_kg),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.average_weight_kg
|
||||||
|
? formatNumber(summaryTotal.average_weight_kg)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'total_weight',
|
||||||
|
header: 'Bobot Total (Kg)',
|
||||||
|
accessorKey: 'total_weight_kg',
|
||||||
|
cell: (props) => formatNumber(props.row.original.total_weight_kg),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.total_weight_kg
|
||||||
|
? formatNumber(summaryTotal.total_weight_kg)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales_price',
|
||||||
|
header: 'Harga Jual (Rp)',
|
||||||
|
accessorKey: 'sales_price_per_kg',
|
||||||
|
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.average_sales_price
|
||||||
|
? formatNumber(summaryTotal.average_sales_price)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hpp_price',
|
||||||
|
header: 'HPP (Rp)',
|
||||||
|
accessorKey: 'hpp_price_per_kg',
|
||||||
|
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.total_hpp_price_per_kg
|
||||||
|
? formatCurrency(summaryTotal.total_hpp_price_per_kg)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales_amount',
|
||||||
|
header: 'Total (Rp)',
|
||||||
|
accessorKey: 'sales_amount',
|
||||||
|
cell: (props) => formatCurrency(props.row.original.sales_amount),
|
||||||
|
footer: () => (
|
||||||
|
<div className='text-right font-semibold text-gray-900'>
|
||||||
|
{summaryTotal?.total_sales_amount
|
||||||
|
? formatCurrency(summaryTotal.total_sales_amount)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return tableColumns;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||||
|
{!isSubmitted ? (
|
||||||
|
<DailyMarketingReportSkeleton
|
||||||
|
columns={getTableColumns()}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:funnel'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='No Filters Selected'
|
||||||
|
subtitle='Please choose filters to narrow down your results and make your search easier.'
|
||||||
|
/>
|
||||||
|
) : isLoading ? (
|
||||||
|
<DailyMarketingReportSkeleton
|
||||||
|
columns={getTableColumns()}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-report'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='Memuat Data Penjualan Harian'
|
||||||
|
subtitle='Silakan tunggu sebentar...'
|
||||||
|
/>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<DailyMarketingReportSkeleton
|
||||||
|
columns={getTableColumns()}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:chart-bar'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='Data Not Yet Available'
|
||||||
|
subtitle='Please change your filters to get the data.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
data={data}
|
||||||
|
columns={getTableColumns()}
|
||||||
|
renderFooter={data.length > 0}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'w-full mb-0!',
|
||||||
|
tableWrapperClassName: 'overflow-x-auto',
|
||||||
|
tableClassName: 'w-full table-auto text-sm',
|
||||||
|
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
|
||||||
|
headerColumnClassName:
|
||||||
|
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
|
||||||
|
bodyRowClassName:
|
||||||
|
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
|
tableFooterClassName:
|
||||||
|
'bg-gray-100 font-semibold border border-gray-200',
|
||||||
|
footerRowClassName: 'border-t-2 border-gray-300',
|
||||||
|
footerColumnClassName:
|
||||||
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-3'>
|
||||||
|
{/* Area Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Area'
|
||||||
|
placeholder='Pilih Area'
|
||||||
|
options={areaOptions}
|
||||||
|
isLoading={isLoadingAreas}
|
||||||
|
value={areaValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'area_id',
|
||||||
|
val && !Array.isArray(val) ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Location Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
isLoading={isLoadingLocations}
|
||||||
|
value={locationValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'location_id',
|
||||||
|
val && !Array.isArray(val) ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Warehouse Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Gudang'
|
||||||
|
placeholder='Pilih Gudang'
|
||||||
|
options={warehouseOptions}
|
||||||
|
isLoading={isLoadingWarehouses}
|
||||||
|
value={warehouseValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'warehouse_id',
|
||||||
|
val && !Array.isArray(val) ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Customer Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Customer'
|
||||||
|
placeholder='Pilih Customer'
|
||||||
|
options={customerOptions}
|
||||||
|
isLoading={isLoadingCustomers}
|
||||||
|
value={customerValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'customer_id',
|
||||||
|
val && !Array.isArray(val) ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Date Range Filter */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-semibold text-base-content py-2'>
|
||||||
|
Rentang Tanggal
|
||||||
|
</label>
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<DateInput
|
||||||
|
name='start_date'
|
||||||
|
label='Tanggal Awal'
|
||||||
|
placeholder='Pilih Tanggal Awal'
|
||||||
|
value={formik.values.start_date || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
formik.setFieldValue('start_date', e.target.value || null);
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
isError={
|
||||||
|
!!formik.errors.start_date && formik.touched.start_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{formik.errors.start_date && formik.touched.start_date && (
|
||||||
|
<div className='text-error text-xs mt-1'>
|
||||||
|
{formik.errors.start_date}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DateInput
|
||||||
|
name='end_date'
|
||||||
|
label='Tanggal Akhir'
|
||||||
|
placeholder='Pilih Tanggal Akhir'
|
||||||
|
value={formik.values.end_date || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
formik.setFieldValue('end_date', e.target.value || null);
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
isError={!!formik.errors.end_date && formik.touched.end_date}
|
||||||
|
/>
|
||||||
|
{formik.errors.end_date && formik.touched.end_date && (
|
||||||
|
<div className='text-error text-xs mt-1'>
|
||||||
|
{formik.errors.end_date}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter By Date Type */}
|
||||||
|
<SelectInput
|
||||||
|
label='Filter Tanggal'
|
||||||
|
placeholder='Pilih Filter Tanggal'
|
||||||
|
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
|
||||||
|
value={filterByValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'filter_by',
|
||||||
|
val && !Array.isArray(val) ? (val.value as string) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Marketing Type Filter */}
|
||||||
|
<SelectInput
|
||||||
|
label='Tipe Marketing'
|
||||||
|
placeholder='Pilih Tipe Marketing'
|
||||||
|
options={MARKETING_TYPE_OPTIONS}
|
||||||
|
value={marketingTypeValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'marketing_type',
|
||||||
|
val && !Array.isArray(val) ? (val.value as string) : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='reset'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DailyMarketingTab;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,527 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import Dropdown from '@/components/dropdown/Dropdown';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import Menu from '@/components/menu/Menu';
|
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/ProductionResultProjectFlockKandangTable';
|
|
||||||
|
|
||||||
import { BaseKandang } from '@/types/api/master-data/kandang';
|
|
||||||
import { AreaApi, LocationApi } from '@/services/api/master-data';
|
|
||||||
import {
|
|
||||||
ProjectFlockApi,
|
|
||||||
ProjectFlockKandangApi,
|
|
||||||
} from '@/services/api/production';
|
|
||||||
import {
|
|
||||||
BaseProjectFlockKandang,
|
|
||||||
ProjectFlockKandang,
|
|
||||||
} from '@/types/api/production/project-flock-kandang';
|
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import Pagination from '@/components/Pagination';
|
|
||||||
import { ProductionResultReportApi } from '@/services/api/report/production-result';
|
|
||||||
import { BaseApiResponse } from '@/types/api/api-general';
|
|
||||||
import { httpClient } from '@/services/http/client';
|
|
||||||
import { ProductionResult } from '@/types/api/report/production-result';
|
|
||||||
import ProductionResultReportPDF from './ProductionResultReportPDF';
|
|
||||||
import { pdf } from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
const ProductionResultContent = () => {
|
|
||||||
const [projectFlockKandangs, setProjectFlockKandangs] = useState<
|
|
||||||
ProjectFlockKandang[] | null
|
|
||||||
>(null);
|
|
||||||
const [projectFlockKandangMetadata, setProjectFlockKandangMetadata] =
|
|
||||||
useState<
|
|
||||||
| {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total_pages: number;
|
|
||||||
total_results: number;
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(10);
|
|
||||||
|
|
||||||
const [isLoadingSearch, setIsLoadingSearch] = useState(false);
|
|
||||||
|
|
||||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
|
|
||||||
|
|
||||||
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [selectedProjectFlock, setSelectedProjectFlock] =
|
|
||||||
useState<OptionType | null>(null);
|
|
||||||
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
|
|
||||||
useState<OptionType | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setAreaInputValue,
|
|
||||||
options: areaOptions,
|
|
||||||
isLoadingOptions: isLoadingAreaOptions,
|
|
||||||
loadMore: loadMoreAreas,
|
|
||||||
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedArea(val as OptionType);
|
|
||||||
|
|
||||||
setSelectedLocation(null);
|
|
||||||
|
|
||||||
setSelectedProjectFlock(null);
|
|
||||||
|
|
||||||
setSelectedProjectFlockKandang(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setLocationInputValue,
|
|
||||||
options: locationOptions,
|
|
||||||
isLoadingOptions: isLoadingLocationOptions,
|
|
||||||
loadMore: loadMoreLocations,
|
|
||||||
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
|
|
||||||
area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedLocation(val as OptionType);
|
|
||||||
|
|
||||||
setSelectedProjectFlock(null);
|
|
||||||
|
|
||||||
setSelectedProjectFlockKandang(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setProjectFlockInputValue,
|
|
||||||
options: projectFlockOptions,
|
|
||||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
|
||||||
loadMore: loadMoreProjectFlocks,
|
|
||||||
} = useSelect<BaseKandang>(
|
|
||||||
ProjectFlockApi.basePath,
|
|
||||||
'id',
|
|
||||||
'flock_name',
|
|
||||||
'search',
|
|
||||||
{
|
|
||||||
area_id: selectedArea
|
|
||||||
? ((selectedArea as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
location_id: selectedLocation
|
|
||||||
? ((selectedLocation as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
category: 'LAYING',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedProjectFlock(val as OptionType);
|
|
||||||
|
|
||||||
setSelectedProjectFlockKandang(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setProjectFlockKandangInputValue,
|
|
||||||
options: projectFlockKandangOptions,
|
|
||||||
isLoadingOptions: isLoadingProjectFlockKandangOptions,
|
|
||||||
loadMore: loadMoreProjectFlockKandangs,
|
|
||||||
} = useSelect<BaseKandang>(
|
|
||||||
ProjectFlockKandangApi.basePath,
|
|
||||||
'id',
|
|
||||||
'kandang.name',
|
|
||||||
'search',
|
|
||||||
{
|
|
||||||
area_id: selectedArea
|
|
||||||
? ((selectedArea as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
location_id: selectedLocation
|
|
||||||
? ((selectedLocation as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
project_flock_id: selectedProjectFlock
|
|
||||||
? ((selectedProjectFlock as OptionType).value as string)
|
|
||||||
: '',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectFlockKandangChangeHandler = (
|
|
||||||
val: OptionType | OptionType[] | null
|
|
||||||
) => {
|
|
||||||
setSelectedProjectFlockKandang(val as OptionType);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToExcelHandler = async () => {
|
|
||||||
setIsLoadingExportingToExcel(true);
|
|
||||||
|
|
||||||
await ProductionResultReportApi.exportProductionResultToExcel(
|
|
||||||
projectFlockKandangs
|
|
||||||
);
|
|
||||||
|
|
||||||
setIsLoadingExportingToExcel(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToPdfHandler = async () => {
|
|
||||||
setIsLoadingExportingToPdf(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let projectFlockKandangsData: BaseProjectFlockKandang[] = [];
|
|
||||||
|
|
||||||
if (selectedProjectFlockKandang) {
|
|
||||||
const projectFlockKandangResponse =
|
|
||||||
await ProjectFlockKandangApi.getSingle(
|
|
||||||
selectedProjectFlockKandang?.value as number
|
|
||||||
);
|
|
||||||
|
|
||||||
projectFlockKandangsData = isResponseSuccess(
|
|
||||||
projectFlockKandangResponse
|
|
||||||
)
|
|
||||||
? [projectFlockKandangResponse.data]
|
|
||||||
: [];
|
|
||||||
} else {
|
|
||||||
const projectFlockKandangsResponse =
|
|
||||||
await ProjectFlockKandangApi.getAll({
|
|
||||||
area_id: selectedArea?.value,
|
|
||||||
project_flock_id: selectedProjectFlock?.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
projectFlockKandangsData = isResponseSuccess(
|
|
||||||
projectFlockKandangsResponse
|
|
||||||
)
|
|
||||||
? projectFlockKandangsResponse.data
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const mappedProductionResults: {
|
|
||||||
projectFlockKandang: BaseProjectFlockKandang;
|
|
||||||
productionResult: ProductionResult[] | null;
|
|
||||||
}[] = await Promise.all(
|
|
||||||
projectFlockKandangsData.map(async (projectFlockKandang) => {
|
|
||||||
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`;
|
|
||||||
const getProductionResultRes = await httpClient<
|
|
||||||
BaseApiResponse<ProductionResult[]>
|
|
||||||
>(getProductionResultPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
projectFlockKandang,
|
|
||||||
productionResult: isResponseSuccess(getProductionResultRes)
|
|
||||||
? getProductionResultRes.data
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mappedProductionResults.length === 0) {
|
|
||||||
toast.error('Tidak ada data untuk diexport.');
|
|
||||||
setIsLoadingExportingToPdf(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPdf = async () => {
|
|
||||||
const productionResultPdfBlob = await pdf(
|
|
||||||
<ProductionResultReportPDF
|
|
||||||
mappedProductionResults={mappedProductionResults}
|
|
||||||
/>
|
|
||||||
).toBlob();
|
|
||||||
|
|
||||||
const productionResultReportPdfUrl = URL.createObjectURL(
|
|
||||||
productionResultPdfBlob
|
|
||||||
);
|
|
||||||
window.open(productionResultReportPdfUrl, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
await openPdf();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingExportingToPdf(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchHandler = async () => {
|
|
||||||
setProjectFlockKandangs(null);
|
|
||||||
setIsLoadingSearch(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (selectedProjectFlockKandang) {
|
|
||||||
const projectFlockKandangResponse =
|
|
||||||
await ProjectFlockKandangApi.getSingle(
|
|
||||||
selectedProjectFlockKandang?.value as number
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!projectFlockKandangResponse ||
|
|
||||||
isResponseError(projectFlockKandangResponse)
|
|
||||||
) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProjectFlockKandangs([projectFlockKandangResponse.data]);
|
|
||||||
setProjectFlockKandangMetadata({
|
|
||||||
page: 1,
|
|
||||||
limit: 10,
|
|
||||||
total_pages: 1,
|
|
||||||
total_results: 1,
|
|
||||||
});
|
|
||||||
setIsLoadingSearch(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectFlockKandangsResponse = await ProjectFlockKandangApi.getAll({
|
|
||||||
area_id: selectedArea?.value,
|
|
||||||
project_flock_id: selectedProjectFlock?.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
!projectFlockKandangsResponse ||
|
|
||||||
isResponseError(projectFlockKandangsResponse)
|
|
||||||
) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProjectFlockKandangs(projectFlockKandangsResponse.data);
|
|
||||||
setProjectFlockKandangMetadata(projectFlockKandangsResponse.meta);
|
|
||||||
setIsLoadingSearch(false);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Gagal mencari data! Coba lagi.');
|
|
||||||
setProjectFlockKandangs(null);
|
|
||||||
setProjectFlockKandangMetadata(undefined);
|
|
||||||
setIsLoadingSearch(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetHandler = () => {
|
|
||||||
setProjectFlockKandangs(null);
|
|
||||||
setSelectedArea(null);
|
|
||||||
setSelectedLocation(null);
|
|
||||||
setSelectedProjectFlock(null);
|
|
||||||
setSelectedProjectFlockKandang(null);
|
|
||||||
// resetFilter();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full p-4'>
|
|
||||||
<Card
|
|
||||||
variant='bordered'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h2 className='text-xl font-bold text-center'>
|
|
||||||
Laporan Hasil Produksi
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className='flex flex-col gap-4 mb-6 mt-4'>
|
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
label='Area'
|
|
||||||
placeholder='Pilih Area'
|
|
||||||
options={areaOptions}
|
|
||||||
isLoading={isLoadingAreaOptions}
|
|
||||||
value={selectedArea}
|
|
||||||
onChange={areaChangeHandler}
|
|
||||||
onInputChange={setAreaInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreAreas}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Lokasi'
|
|
||||||
placeholder={
|
|
||||||
selectedArea ? 'Pilih Lokasi' : 'Pilih Area terlebih dahulu'
|
|
||||||
}
|
|
||||||
options={locationOptions}
|
|
||||||
isLoading={isLoadingLocationOptions}
|
|
||||||
value={selectedLocation}
|
|
||||||
onChange={locationChangeHandler}
|
|
||||||
onInputChange={setLocationInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreLocations}
|
|
||||||
isClearable
|
|
||||||
isDisabled={!selectedArea}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Project Flock'
|
|
||||||
placeholder={
|
|
||||||
selectedArea && selectedLocation
|
|
||||||
? 'Pilih Project Flock'
|
|
||||||
: 'Pilih Area dan Lokasi terlebih dahulu'
|
|
||||||
}
|
|
||||||
options={projectFlockOptions}
|
|
||||||
isLoading={isLoadingProjectFlockOptions}
|
|
||||||
value={selectedProjectFlock}
|
|
||||||
onChange={projectFlockChangeHandler}
|
|
||||||
onInputChange={setProjectFlockInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
|
||||||
isClearable
|
|
||||||
isDisabled={!selectedArea || !selectedLocation}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Project Flock Kandang'
|
|
||||||
placeholder={
|
|
||||||
selectedProjectFlock
|
|
||||||
? 'Pilih Project Flock Kandang'
|
|
||||||
: 'Pilih Project Flock terlebih dahulu'
|
|
||||||
}
|
|
||||||
options={projectFlockKandangOptions}
|
|
||||||
isLoading={isLoadingProjectFlockKandangOptions}
|
|
||||||
value={selectedProjectFlockKandang}
|
|
||||||
onChange={projectFlockKandangChangeHandler}
|
|
||||||
onInputChange={setProjectFlockKandangInputValue}
|
|
||||||
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
|
|
||||||
isClearable
|
|
||||||
isDisabled={!selectedProjectFlock}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
|
||||||
<div className='col-span-12 flex flex-wrap sm:justify-end items-end gap-2'>
|
|
||||||
<Button
|
|
||||||
onClick={searchHandler}
|
|
||||||
isLoading={isLoadingSearch}
|
|
||||||
disabled={
|
|
||||||
!selectedArea || !selectedLocation || !selectedProjectFlock
|
|
||||||
}
|
|
||||||
className='flex-1 sm:flex-none'
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons-outline:search' width={20} height={20} />
|
|
||||||
Cari
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color='warning'
|
|
||||||
onClick={resetHandler}
|
|
||||||
className='flex-1 sm:flex-none'
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons-outline:refresh' width={20} height={20} />
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dropdown
|
|
||||||
align='end'
|
|
||||||
direction='bottom'
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
disabled={
|
|
||||||
!selectedArea ||
|
|
||||||
!selectedLocation ||
|
|
||||||
!selectedProjectFlock
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Export{' '}
|
|
||||||
<Icon
|
|
||||||
icon='heroicons-outline:download'
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Menu>
|
|
||||||
<MenuItem
|
|
||||||
title='Export to Excel'
|
|
||||||
icon='icon-park-outline:excel'
|
|
||||||
isLoading={isLoadingExportingToExcel}
|
|
||||||
onClick={exportToExcelHandler}
|
|
||||||
className='text-nowrap'
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
title='Export to PDF'
|
|
||||||
icon='icon-park-outline:file-pdf-one'
|
|
||||||
isLoading={isLoadingExportingToPdf}
|
|
||||||
onClick={exportToPdfHandler}
|
|
||||||
className='text-nowrap'
|
|
||||||
/>
|
|
||||||
</Menu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className='mt-4'>
|
|
||||||
{isLoadingSearch && (
|
|
||||||
<span className='loading loading-dots loading-xl block mx-auto text-gray-400' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingSearch && !projectFlockKandangs && (
|
|
||||||
<p className='text-center text-gray-500'>
|
|
||||||
Silakan pilih filter dan klik tombol Cari untuk menampilkan data.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingSearch && projectFlockKandangs?.length === 0 && (
|
|
||||||
<p className='text-center text-gray-500'>
|
|
||||||
Tidak ada data kandang project flock yang dapat ditampilkan.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingSearch && projectFlockKandangs && (
|
|
||||||
<Card
|
|
||||||
variant='bordered'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{projectFlockKandangs.map((projectFlockKandang) => (
|
|
||||||
<ProductionResultProjectFlockKandangTable
|
|
||||||
key={projectFlockKandang.id}
|
|
||||||
projectFlockKandangId={projectFlockKandang.id}
|
|
||||||
kandangName={projectFlockKandang.kandang.name}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className='max-w-sm ml-auto mt-5'>
|
|
||||||
<Pagination
|
|
||||||
totalItems={projectFlockKandangMetadata?.total_results || 0}
|
|
||||||
itemsPerPage={projectFlockKandangMetadata?.limit || 0}
|
|
||||||
currentPage={projectFlockKandangMetadata?.page || 0}
|
|
||||||
onPrevPage={() =>
|
|
||||||
setPage((currPage) =>
|
|
||||||
currPage > 1 ? currPage - 1 : currPage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onNextPage={() =>
|
|
||||||
setPage((currPage) =>
|
|
||||||
projectFlockKandangMetadata?.total_pages &&
|
|
||||||
currPage < projectFlockKandangMetadata.total_pages
|
|
||||||
? currPage + 1
|
|
||||||
: currPage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onPageChange={(pageNumber) => setPage(pageNumber)}
|
|
||||||
rowOptions={[10, 20, 50, 100]}
|
|
||||||
onRowChange={setPageSize}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductionResultContent;
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Tabs from '@/components/Tabs';
|
||||||
|
import ProductionResultTab from '@/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
|
||||||
|
const ProductionResultTabs = () => {
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||||
|
const tabActions = useReportTabStore((state) => state.tabActions);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Hasil Produksi',
|
||||||
|
content: <ProductionResultTab tabId={'1'} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className='w-full'>
|
||||||
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
variant='boxed'
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onTabChange={setActiveTabId}
|
||||||
|
className={{
|
||||||
|
tabHeaderWrapper:
|
||||||
|
'justify-between items-center p-3 border-b border-base-content/10',
|
||||||
|
tab: 'w-fit',
|
||||||
|
content: 'p-0 m-0',
|
||||||
|
}}
|
||||||
|
sideContent={tabActions[activeTabId] || null}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionResultTabs;
|
||||||
+7
-7
@@ -66,7 +66,7 @@ const getBwTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'woa',
|
key: 'woa',
|
||||||
@@ -114,7 +114,7 @@ const getDepTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'dep_kum',
|
key: 'dep_kum',
|
||||||
@@ -141,7 +141,7 @@ const getButiranTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'butiran_utuh',
|
key: 'butiran_utuh',
|
||||||
@@ -196,7 +196,7 @@ const getKgTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'kg_utuh',
|
key: 'kg_utuh',
|
||||||
@@ -251,7 +251,7 @@ const getPersenTableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'persen_utuh',
|
key: 'persen_utuh',
|
||||||
@@ -292,7 +292,7 @@ const getProduksi1TableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'hd',
|
key: 'hd',
|
||||||
@@ -361,7 +361,7 @@ const getProduksi2TableColumns = (): PdfColumn<ProductionResult>[] => [
|
|||||||
header: 'No',
|
header: 'No',
|
||||||
flex: 0.5,
|
flex: 0.5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
cell: ({ row, index }) => index + 1,
|
cell: ({ index }) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'fcr',
|
key: 'fcr',
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import { formatNumber } from '@/lib/helper';
|
||||||
|
import { ProductionResult } from '@/types/api/report/production-result';
|
||||||
|
|
||||||
|
interface ProductionResultExportExcelParams {
|
||||||
|
data: ProductionResult[];
|
||||||
|
period?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateProductionResultExcel = async (
|
||||||
|
params: ProductionResultExportExcelParams
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!params.data || params.data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
|
||||||
|
// ===== PRODUCTION RESULT WORKSHEET =====
|
||||||
|
const columns = [
|
||||||
|
{ header: 'No', key: 'no', width: 6 },
|
||||||
|
{ header: 'Project Flock', key: 'projectFlockName', width: 25 },
|
||||||
|
{
|
||||||
|
header: 'Category',
|
||||||
|
key: 'projectFlockCategory',
|
||||||
|
width: 18,
|
||||||
|
},
|
||||||
|
{ header: 'Kandang', key: 'kandangName', width: 18 },
|
||||||
|
{ header: 'Week of Age (WOA)', key: 'woa', width: 20 },
|
||||||
|
{ header: 'Body Weight (BW)', key: 'bw', width: 18 },
|
||||||
|
{ header: 'Body Weight (Std BW)', key: 'stdBw', width: 22 },
|
||||||
|
{ header: 'Uniformity (%)', key: 'uniformity', width: 16 },
|
||||||
|
{ header: 'Uniformity Std (%)', key: 'stdUniformity', width: 20 },
|
||||||
|
{ header: 'Depletion Cumulative', key: 'depKum', width: 22 },
|
||||||
|
{ header: 'Depletion Standard', key: 'depStd', width: 20 },
|
||||||
|
{ header: 'Telur Utuh', key: 'butiranUtuh', width: 14 },
|
||||||
|
{ header: 'Telur Putih', key: 'butiranPutih', width: 14 },
|
||||||
|
{ header: 'Telur Retak', key: 'butiranRetak', width: 14 },
|
||||||
|
{ header: 'Telur Pecah', key: 'butiranPecah', width: 14 },
|
||||||
|
{ header: 'Jumlah Telur', key: 'butiranJumlah', width: 16 },
|
||||||
|
{ header: 'Total Telur', key: 'totalButir', width: 14 },
|
||||||
|
{ header: 'Utuh (Kg)', key: 'kgUtuh', width: 12 },
|
||||||
|
{ header: 'Putih (Kg)', key: 'kgPutih', width: 12 },
|
||||||
|
{ header: 'Retak (Kg)', key: 'kgRetak', width: 12 },
|
||||||
|
{ header: 'Pecah (Kg)', key: 'kgPecah', width: 12 },
|
||||||
|
{ header: 'Jumlah (Kg)', key: 'kgJumlah', width: 14 },
|
||||||
|
{ header: 'Total Weight (Kg)', key: 'totalKg', width: 20 },
|
||||||
|
{ header: 'Utuh (%)', key: 'persenUtuh', width: 12 },
|
||||||
|
{ header: 'Putih (%)', key: 'persenPutih', width: 12 },
|
||||||
|
{ header: 'Retak (%)', key: 'persenRetak', width: 12 },
|
||||||
|
{ header: 'Pecah (%)', key: 'persenPecah', width: 12 },
|
||||||
|
{ header: 'Hen Day (HD)', key: 'hd', width: 15 },
|
||||||
|
{ header: 'Hen Day Std (HD Std)', key: 'hdStd', width: 22 },
|
||||||
|
{ header: 'Feed Intake (FI)', key: 'fi', width: 18 },
|
||||||
|
{ header: 'Feed Intake Std (FI Std)', key: 'fiStd', width: 25 },
|
||||||
|
{ header: 'Egg Mass (EM)', key: 'em', width: 16 },
|
||||||
|
{ header: 'Egg Mass Std (EM Std)', key: 'emStd', width: 23 },
|
||||||
|
{ header: 'Egg Weight (EW)', key: 'ew', width: 18 },
|
||||||
|
{ header: 'Egg Weight Std (EW Std)', key: 'ewStd', width: 25 },
|
||||||
|
{ header: 'Feed Conversion Ratio (FCR)', key: 'fcr', width: 30 },
|
||||||
|
{
|
||||||
|
header: 'Feed Conversion Ratio Std (FCR Std)',
|
||||||
|
key: 'fcrStd',
|
||||||
|
width: 35,
|
||||||
|
},
|
||||||
|
{ header: 'Hen House (HH)', key: 'hh', width: 18 },
|
||||||
|
{ header: 'Hen House Std (HH Std)', key: 'hhStd', width: 25 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const worksheet = workbook.addWorksheet('Production Result');
|
||||||
|
worksheet.columns = columns;
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
params.data.forEach((item: ProductionResult, index: number) => {
|
||||||
|
worksheet.addRow({
|
||||||
|
no: index + 1,
|
||||||
|
projectFlockName: item.project_flock?.name || '',
|
||||||
|
projectFlockCategory: item.project_flock?.category || '',
|
||||||
|
kandangName: item.project_flock?.kandang?.name || '',
|
||||||
|
woa: formatNumber(item.woa || 0),
|
||||||
|
bw: formatNumber(item.bw || 0),
|
||||||
|
stdBw: formatNumber(item.std_bw || 0),
|
||||||
|
uniformity: formatNumber(item.uniformity || 0),
|
||||||
|
stdUniformity: item.std_uniformity || '',
|
||||||
|
depKum: formatNumber(item.dep_kum || 0),
|
||||||
|
depStd: formatNumber(item.dep_std || 0),
|
||||||
|
butiranUtuh: formatNumber(item.butiran_utuh || 0),
|
||||||
|
butiranPutih: formatNumber(item.butiran_putih || 0),
|
||||||
|
butiranRetak: formatNumber(item.butiran_retak || 0),
|
||||||
|
butiranPecah: formatNumber(item.butiran_pecah || 0),
|
||||||
|
butiranJumlah: formatNumber(item.butiran_jumlah || 0),
|
||||||
|
totalButir: formatNumber(item.total_butir || 0),
|
||||||
|
kgUtuh: formatNumber(item.kg_utuh || 0),
|
||||||
|
kgPutih: formatNumber(item.kg_putih || 0),
|
||||||
|
kgRetak: formatNumber(item.kg_retak || 0),
|
||||||
|
kgPecah: formatNumber(item.kg_pecah || 0),
|
||||||
|
kgJumlah: formatNumber(item.kg_jumlah || 0),
|
||||||
|
totalKg: formatNumber(item.total_kg || 0),
|
||||||
|
persenUtuh: formatNumber(item.persen_utuh || 0),
|
||||||
|
persenPutih: formatNumber(item.persen_putih || 0),
|
||||||
|
persenRetak: formatNumber(item.persen_retak || 0),
|
||||||
|
persenPecah: formatNumber(item.persen_pecah || 0),
|
||||||
|
hd: formatNumber(item.hd || 0),
|
||||||
|
hdStd: formatNumber(item.hd_std || 0),
|
||||||
|
fi: formatNumber(item.fi || 0),
|
||||||
|
fiStd: formatNumber(item.fi_std || 0),
|
||||||
|
em: formatNumber(item.em || 0),
|
||||||
|
emStd: formatNumber(item.em_std || 0),
|
||||||
|
ew: formatNumber(item.ew || 0),
|
||||||
|
ewStd: formatNumber(item.ew_std || 0),
|
||||||
|
fcr: formatNumber(item.fcr || 0),
|
||||||
|
fcrStd: formatNumber(item.fcr_std || 0),
|
||||||
|
hh: formatNumber(item.hh || 0),
|
||||||
|
hhStd: formatNumber(item.hh_std || 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentDate = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = params.period
|
||||||
|
? `laporan-hasil-produksi-${params.period}.xlsx`
|
||||||
|
: `laporan-hasil-produksi-${currentDate}.xlsx`;
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type ProductionResultFilterProps = {
|
||||||
|
area_id: string | null;
|
||||||
|
location_id: string | null;
|
||||||
|
project_flock_id: string | null;
|
||||||
|
kandang_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductionResultFilterFormType = {
|
||||||
|
area_id: OptionType | null;
|
||||||
|
location_id: OptionType | null;
|
||||||
|
project_flock_id: OptionType | null;
|
||||||
|
kandang_id: OptionType | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProductionResultFilterSchema = yup.object({
|
||||||
|
area_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.required('Area wajib dipilih')
|
||||||
|
.test('is-not-empty', 'Area wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return !!value;
|
||||||
|
}),
|
||||||
|
location_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.required('Lokasi wajib dipilih')
|
||||||
|
.test('is-not-empty', 'Lokasi wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return !!value;
|
||||||
|
}),
|
||||||
|
project_flock_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.required('Project Flock wajib dipilih')
|
||||||
|
.test('is-not-empty', 'Project Flock wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return !!value;
|
||||||
|
}),
|
||||||
|
kandang_id: yup
|
||||||
|
.mixed<OptionType>()
|
||||||
|
.required('Kandang wajib dipilih')
|
||||||
|
.test('is-not-empty', 'Kandang wajib dipilih', (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return !!value;
|
||||||
|
}),
|
||||||
|
}) as yup.ObjectSchema<ProductionResultFilterFormType>;
|
||||||
|
|
||||||
|
export type ProductionResultFilterValues = yup.InferType<
|
||||||
|
typeof ProductionResultFilterSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { ProductionResult } from '@/types/api/report/production-result';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
type ProductionResultColumn =
|
||||||
|
| ColumnDef<ProductionResult>
|
||||||
|
| {
|
||||||
|
header: string;
|
||||||
|
columns: Array<{
|
||||||
|
header: string;
|
||||||
|
accessorKey?: string;
|
||||||
|
cell?: (props: {
|
||||||
|
row: { original: ProductionResult };
|
||||||
|
}) => React.ReactNode;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductionResultSkeleton = ({
|
||||||
|
columns,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
columns: ProductionResultColumn[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='relative size-full'>
|
||||||
|
<Table
|
||||||
|
data={[]}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={true}
|
||||||
|
className={{
|
||||||
|
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
|
||||||
|
headerColumnClassName: 'whitespace-nowrap',
|
||||||
|
containerClassName: 'mb-0 overflow-hidden',
|
||||||
|
tableWrapperClassName: 'overflow-hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionResultSkeleton;
|
||||||
+842
@@ -0,0 +1,842 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { generateProductionResultExcel } from '../export/ProductionResultExportXLSX';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Dropdown from '@/components/dropdown/Dropdown';
|
||||||
|
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTable';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import {
|
||||||
|
ProductionResultFilterSchema,
|
||||||
|
type ProductionResultFilterValues,
|
||||||
|
} from '@/components/pages/report/production-result/filter/ProductionResultFilter';
|
||||||
|
|
||||||
|
import { BaseKandang } from '@/types/api/master-data/kandang';
|
||||||
|
import { AreaApi, LocationApi } from '@/services/api/master-data';
|
||||||
|
import {
|
||||||
|
ProjectFlockApi,
|
||||||
|
ProjectFlockKandangApi,
|
||||||
|
} from '@/services/api/production';
|
||||||
|
import {
|
||||||
|
BaseProjectFlockKandang,
|
||||||
|
ProjectFlockKandang,
|
||||||
|
} from '@/types/api/production/project-flock-kandang';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { ProductionResultReportApi } from '@/services/api/report/production-result';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
import { httpClient } from '@/services/http/client';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { ProductionResult } from '@/types/api/report/production-result';
|
||||||
|
import ProductionResultReportPDF from '../export/ProductionResultExportPDF';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
|
import { useReportTabStore } from '@/stores/report/report-tab.store';
|
||||||
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
|
import { cn, formatNumber } from '@/lib/helper';
|
||||||
|
import Pagination from '@/components/Pagination';
|
||||||
|
import ProductionResultSkeleton from '@/components/pages/report/production-result/skeleton/ProductionResultSkeleton';
|
||||||
|
|
||||||
|
interface ProductionResultTabProps {
|
||||||
|
tabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterParams {
|
||||||
|
area_id?: string;
|
||||||
|
location_id?: string;
|
||||||
|
project_flock_id?: string;
|
||||||
|
project_flock_kandang_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
|
||||||
|
// ===== STATE MANAGEMENT =====
|
||||||
|
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||||
|
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||||
|
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||||
|
|
||||||
|
// ===== SUBMISSION STATE =====
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||||
|
|
||||||
|
// ===== PAGINATION STATE =====
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== TABLE COLUMNS =====
|
||||||
|
const productionResultColumns: ColumnDef<ProductionResult>[] = [
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
cell: (props) => props.row.index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'woa',
|
||||||
|
header: 'WOA',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'bw',
|
||||||
|
header: 'BW',
|
||||||
|
cell: (props) => formatNumber(props.row.original.bw),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'std_bw',
|
||||||
|
header: 'STD BW',
|
||||||
|
cell: (props) => formatNumber(props.row.original.std_bw),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'uniformity',
|
||||||
|
header: 'Uniformity',
|
||||||
|
cell: (props) => formatNumber(props.row.original.uniformity),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'std_uniformity',
|
||||||
|
header: 'STD Uniformity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'dep_kum',
|
||||||
|
header: 'Dep Kum',
|
||||||
|
cell: (props) => formatNumber(props.row.original.dep_kum),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'dep_std',
|
||||||
|
header: 'Dep STD',
|
||||||
|
cell: (props) => formatNumber(props.row.original.dep_std),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Butiran',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_utuh',
|
||||||
|
header: 'Utuh',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_utuh),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_putih',
|
||||||
|
header: 'Putih',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_putih),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_retak',
|
||||||
|
header: 'Retak',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_retak),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_pecah',
|
||||||
|
header: 'Pecah',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_pecah),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'butiran_jumlah',
|
||||||
|
header: 'Jumlah (Butir)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.butiran_jumlah),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'total_butir',
|
||||||
|
header: 'Total Butir',
|
||||||
|
cell: (props) => formatNumber(props.row.original.total_butir),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Kg',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_utuh',
|
||||||
|
header: 'Utuh (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_utuh),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_putih',
|
||||||
|
header: 'Putih (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_putih),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_retak',
|
||||||
|
header: 'Retak (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_retak),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_pecah',
|
||||||
|
header: 'Pecah (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_pecah),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'kg_jumlah',
|
||||||
|
header: 'Jumlah (Kg)',
|
||||||
|
cell: (props) => formatNumber(props.row.original.kg_jumlah),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'total_kg',
|
||||||
|
header: 'Total Kg',
|
||||||
|
cell: (props) => formatNumber(props.row.original.total_kg),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '%',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
accessorKey: 'persen_utuh',
|
||||||
|
header: 'Utuh',
|
||||||
|
cell: (props) => formatNumber(props.row.original.persen_utuh),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'persen_putih',
|
||||||
|
header: 'Putih',
|
||||||
|
cell: (props) => formatNumber(props.row.original.persen_putih),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'persen_retak',
|
||||||
|
header: 'Retak',
|
||||||
|
cell: (props) => formatNumber(props.row.original.persen_retak),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'persen_pecah',
|
||||||
|
header: 'Pecah',
|
||||||
|
cell: (props) => formatNumber(props.row.original.persen_pecah),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<ProductionResultFilterValues>({
|
||||||
|
initialValues: {
|
||||||
|
area_id: null,
|
||||||
|
location_id: null,
|
||||||
|
project_flock_id: null,
|
||||||
|
kandang_id: null,
|
||||||
|
},
|
||||||
|
validationSchema: ProductionResultFilterSchema,
|
||||||
|
validateOnBlur: true,
|
||||||
|
validateOnChange: true,
|
||||||
|
onSubmit: (values) => {
|
||||||
|
setFilterParams({
|
||||||
|
area_id: values.area_id?.value
|
||||||
|
? String(values.area_id.value)
|
||||||
|
: undefined,
|
||||||
|
location_id: values.location_id?.value
|
||||||
|
? String(values.location_id.value)
|
||||||
|
: undefined,
|
||||||
|
project_flock_id: values.project_flock_id?.value
|
||||||
|
? String(values.project_flock_id.value)
|
||||||
|
: undefined,
|
||||||
|
project_flock_kandang_id: values.kandang_id?.value
|
||||||
|
? String(values.kandang_id.value)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
filterModal.closeModal();
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
setFilterParams({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setAreaInputValue,
|
||||||
|
options: areaOptions,
|
||||||
|
isLoadingOptions: isLoadingAreas,
|
||||||
|
loadMore: loadMoreAreas,
|
||||||
|
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocations,
|
||||||
|
loadMore: loadMoreLocations,
|
||||||
|
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
|
||||||
|
area_id: formik.values.area_id?.value
|
||||||
|
? String(formik.values.area_id.value)
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setProjectFlockInputValue,
|
||||||
|
options: projectFlockOptions,
|
||||||
|
isLoadingOptions: isLoadingProjectFlocks,
|
||||||
|
loadMore: loadMoreProjectFlocks,
|
||||||
|
} = useSelect<BaseKandang>(
|
||||||
|
ProjectFlockApi.basePath,
|
||||||
|
'id',
|
||||||
|
'flock_name',
|
||||||
|
'search',
|
||||||
|
{
|
||||||
|
area_id: formik.values.area_id?.value
|
||||||
|
? String(formik.values.area_id.value)
|
||||||
|
: '',
|
||||||
|
location_id: formik.values.location_id?.value
|
||||||
|
? String(formik.values.location_id.value)
|
||||||
|
: '',
|
||||||
|
category: 'LAYING',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setProjectFlockKandangInputValue,
|
||||||
|
options: projectFlockKandangOptions,
|
||||||
|
isLoadingOptions: isLoadingProjectFlockKandangs,
|
||||||
|
loadMore: loadMoreProjectFlockKandangs,
|
||||||
|
} = useSelect<BaseKandang>(
|
||||||
|
ProjectFlockKandangApi.basePath,
|
||||||
|
'id',
|
||||||
|
'kandang.name',
|
||||||
|
'search',
|
||||||
|
{
|
||||||
|
area_id: formik.values.area_id?.value
|
||||||
|
? String(formik.values.area_id.value)
|
||||||
|
: '',
|
||||||
|
location_id: formik.values.location_id?.value
|
||||||
|
? String(formik.values.location_id.value)
|
||||||
|
: '',
|
||||||
|
project_flock_id: formik.values.project_flock_id?.value
|
||||||
|
? String(formik.values.project_flock_id.value)
|
||||||
|
: '',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const areaValue = useMemo(
|
||||||
|
() => formik.values.area_id,
|
||||||
|
[formik.values.area_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationValue = useMemo(
|
||||||
|
() => formik.values.location_id,
|
||||||
|
[formik.values.location_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFlockValue = useMemo(
|
||||||
|
() => formik.values.project_flock_id,
|
||||||
|
[formik.values.project_flock_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFlockKandangValue = useMemo(
|
||||||
|
() => formik.values.kandang_id,
|
||||||
|
[formik.values.kandang_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== ACTIVE FILTERS COUNT =====
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (filterParams.area_id) count += 1;
|
||||||
|
if (filterParams.location_id) count += 1;
|
||||||
|
if (filterParams.project_flock_id) count += 1;
|
||||||
|
if (filterParams.project_flock_kandang_id) count += 1;
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
const hasFilters = activeFiltersCount > 0;
|
||||||
|
|
||||||
|
// ===== DATA FETCHING =====
|
||||||
|
const { data: projectFlockKandangsData, isLoading } = useSWR<
|
||||||
|
BaseApiResponse<ProjectFlockKandang[]>
|
||||||
|
>(
|
||||||
|
isSubmitted
|
||||||
|
? () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterParams.area_id)
|
||||||
|
params.append('area_id', filterParams.area_id);
|
||||||
|
if (filterParams.project_flock_id)
|
||||||
|
params.append('project_flock_id', filterParams.project_flock_id);
|
||||||
|
params.append('page', String(page));
|
||||||
|
params.append('limit', String(pageSize));
|
||||||
|
|
||||||
|
return [`/production/project-flock-kandangs?${params.toString()}`];
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
([url]: string[]) => httpClient<BaseApiResponse<ProjectFlockKandang[]>>(url)
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFlockKandangs = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(projectFlockKandangsData)
|
||||||
|
? projectFlockKandangsData.data
|
||||||
|
: null,
|
||||||
|
[projectFlockKandangsData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFlockKandangMetadata = useMemo(
|
||||||
|
() =>
|
||||||
|
isResponseSuccess(projectFlockKandangsData)
|
||||||
|
? projectFlockKandangsData.meta
|
||||||
|
: undefined,
|
||||||
|
[projectFlockKandangsData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== EXPORT HANDLERS =====
|
||||||
|
const exportToExcelHandler = useCallback(async () => {
|
||||||
|
setIsExcelExportLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let projectFlockKandangsFetch: BaseProjectFlockKandang[] = [];
|
||||||
|
|
||||||
|
if (filterParams.project_flock_kandang_id) {
|
||||||
|
const projectFlockKandangResponse =
|
||||||
|
await ProjectFlockKandangApi.getSingle(
|
||||||
|
Number(filterParams.project_flock_kandang_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
projectFlockKandangsFetch = isResponseSuccess(
|
||||||
|
projectFlockKandangResponse
|
||||||
|
)
|
||||||
|
? [projectFlockKandangResponse.data]
|
||||||
|
: [];
|
||||||
|
} else {
|
||||||
|
const projectFlockKandangsResponse =
|
||||||
|
await ProjectFlockKandangApi.getAll({
|
||||||
|
area_id: filterParams.area_id,
|
||||||
|
project_flock_id: filterParams.project_flock_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
projectFlockKandangsFetch = isResponseSuccess(
|
||||||
|
projectFlockKandangsResponse
|
||||||
|
)
|
||||||
|
? projectFlockKandangsResponse.data
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const productionResultData: ProductionResult[] = [];
|
||||||
|
|
||||||
|
for (const kandang of projectFlockKandangsFetch) {
|
||||||
|
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${kandang.id}?page=1&limit=100`;
|
||||||
|
const getProductionResultRes = await httpClient<
|
||||||
|
BaseApiResponse<ProductionResult[]>
|
||||||
|
>(getProductionResultPath);
|
||||||
|
|
||||||
|
if (isResponseSuccess(getProductionResultRes)) {
|
||||||
|
productionResultData.push(
|
||||||
|
...(getProductionResultRes.data?.map((result) => ({
|
||||||
|
...result,
|
||||||
|
project_flock: {
|
||||||
|
...result.project_flock,
|
||||||
|
name:
|
||||||
|
projectFlockValue?.label ||
|
||||||
|
kandang.project_flock?.name ||
|
||||||
|
`Project Flock #${kandang.project_flock_id}`,
|
||||||
|
category: kandang.project_flock?.category || '',
|
||||||
|
kandang: {
|
||||||
|
...result.project_flock?.kandang,
|
||||||
|
name:
|
||||||
|
kandang.kandang?.name || `Kandang #${kandang.kandang_id}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})) || [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productionResultData.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diexport.');
|
||||||
|
setIsExcelExportLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateProductionResultExcel({
|
||||||
|
data: productionResultData,
|
||||||
|
period: '',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
|
||||||
|
} finally {
|
||||||
|
setIsExcelExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterParams, projectFlockValue]);
|
||||||
|
|
||||||
|
const exportToPdfHandler = useCallback(async () => {
|
||||||
|
setIsPdfExportLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let projectFlockKandangsFetch: BaseProjectFlockKandang[] = [];
|
||||||
|
|
||||||
|
if (filterParams.project_flock_kandang_id) {
|
||||||
|
const projectFlockKandangResponse =
|
||||||
|
await ProjectFlockKandangApi.getSingle(
|
||||||
|
Number(filterParams.project_flock_kandang_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
projectFlockKandangsFetch = isResponseSuccess(
|
||||||
|
projectFlockKandangResponse
|
||||||
|
)
|
||||||
|
? [projectFlockKandangResponse.data]
|
||||||
|
: [];
|
||||||
|
} else {
|
||||||
|
const projectFlockKandangsResponse =
|
||||||
|
await ProjectFlockKandangApi.getAll({
|
||||||
|
area_id: filterParams.area_id,
|
||||||
|
project_flock_id: filterParams.project_flock_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
projectFlockKandangsFetch = isResponseSuccess(
|
||||||
|
projectFlockKandangsResponse
|
||||||
|
)
|
||||||
|
? projectFlockKandangsResponse.data
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedProductionResults: {
|
||||||
|
projectFlockKandang: BaseProjectFlockKandang;
|
||||||
|
productionResult: ProductionResult[] | null;
|
||||||
|
}[] = await Promise.all(
|
||||||
|
projectFlockKandangsFetch.map(async (projectFlockKandang) => {
|
||||||
|
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`;
|
||||||
|
const getProductionResultRes = await httpClient<
|
||||||
|
BaseApiResponse<ProductionResult[]>
|
||||||
|
>(getProductionResultPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectFlockKandang,
|
||||||
|
productionResult: isResponseSuccess(getProductionResultRes)
|
||||||
|
? getProductionResultRes.data
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mappedProductionResults.length === 0) {
|
||||||
|
toast.error('Tidak ada data untuk diexport.');
|
||||||
|
setIsPdfExportLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPdf = async () => {
|
||||||
|
const productionResultPdfBlob = await pdf(
|
||||||
|
<ProductionResultReportPDF
|
||||||
|
mappedProductionResults={mappedProductionResults}
|
||||||
|
/>
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
|
const productionResultReportPdfUrl = URL.createObjectURL(
|
||||||
|
productionResultPdfBlob
|
||||||
|
);
|
||||||
|
window.open(productionResultReportPdfUrl, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
await openPdf();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPdfExportLoading(false);
|
||||||
|
}, [filterParams]);
|
||||||
|
|
||||||
|
// ===== REGISTER TAB ACTIONS TO STORE =====
|
||||||
|
const setTabActions = useReportTabStore((state) => state.setTabActions);
|
||||||
|
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTabActions(
|
||||||
|
tabId,
|
||||||
|
<div className='flex flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
onClick={() => filterModal.openModal()}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||||
|
{
|
||||||
|
'border-primary-gradient text-primary': hasFilters,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
Filter
|
||||||
|
{hasFilters && (
|
||||||
|
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||||
|
{activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
align='end'
|
||||||
|
direction='bottom'
|
||||||
|
className={{
|
||||||
|
content:
|
||||||
|
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='none'
|
||||||
|
isLoading={isAnyExportLoading}
|
||||||
|
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
|
>
|
||||||
|
<div className='flex flex-row items-center gap-1.5'>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:cloud-arrow-down'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>Export</span>
|
||||||
|
|
||||||
|
<div className='w-px self-stretch bg-base-content/10' />
|
||||||
|
|
||||||
|
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={exportToExcelHandler}
|
||||||
|
isLoading={isExcelExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||||
|
Export to Excel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={exportToPdfHandler}
|
||||||
|
isLoading={isPdfExportLoading}
|
||||||
|
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:document' width={20} height={20} />
|
||||||
|
Export to PDF
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
tabId,
|
||||||
|
hasFilters,
|
||||||
|
activeFiltersCount,
|
||||||
|
isAnyExportLoading,
|
||||||
|
exportToExcelHandler,
|
||||||
|
exportToPdfHandler,
|
||||||
|
setTabActions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTabActions(tabId);
|
||||||
|
};
|
||||||
|
}, [tabId, clearTabActions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||||
|
{!isSubmitted ? (
|
||||||
|
<ProductionResultSkeleton
|
||||||
|
columns={productionResultColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:funnel'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='No Filters Selected'
|
||||||
|
subtitle='Please choose filters to narrow down your results and make your search easier.'
|
||||||
|
/>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !projectFlockKandangs || projectFlockKandangs.length === 0 ? (
|
||||||
|
<ProductionResultSkeleton
|
||||||
|
columns={productionResultColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:chart-bar'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title='Data Not Yet Available'
|
||||||
|
subtitle='Please change your filters to get the data.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{projectFlockKandangs.map(
|
||||||
|
(projectFlockKandang: ProjectFlockKandang) => (
|
||||||
|
<ProductionResultProjectFlockKandangTable
|
||||||
|
key={projectFlockKandang.id}
|
||||||
|
projectFlockKandangId={projectFlockKandang.id}
|
||||||
|
kandangName={projectFlockKandang.kandang.name}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='max-w-sm ml-auto'>
|
||||||
|
<Pagination
|
||||||
|
totalItems={projectFlockKandangMetadata?.total_results || 0}
|
||||||
|
itemsPerPage={projectFlockKandangMetadata?.limit || 0}
|
||||||
|
currentPage={projectFlockKandangMetadata?.page || 0}
|
||||||
|
onPrevPage={() =>
|
||||||
|
setPage((currPage) =>
|
||||||
|
currPage > 1 ? currPage - 1 : currPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onNextPage={() =>
|
||||||
|
setPage((currPage) =>
|
||||||
|
projectFlockKandangMetadata?.total_pages &&
|
||||||
|
currPage < projectFlockKandangMetadata.total_pages
|
||||||
|
? currPage + 1
|
||||||
|
: currPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPageChange={(pageNumber) => setPage(pageNumber)}
|
||||||
|
rowOptions={[10, 20, 50, 100]}
|
||||||
|
onRowChange={setPageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Area'
|
||||||
|
placeholder='Pilih Area'
|
||||||
|
options={areaOptions}
|
||||||
|
isLoading={isLoadingAreas}
|
||||||
|
value={areaValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('area_id', val);
|
||||||
|
formik.setFieldValue('location_id', null);
|
||||||
|
formik.setFieldValue('project_flock_id', null);
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}}
|
||||||
|
onInputChange={setAreaInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreAreas}
|
||||||
|
isClearable
|
||||||
|
isError={formik.touched.area_id && Boolean(formik.errors.area_id)}
|
||||||
|
errorMessage={formik.errors.area_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
isLoading={isLoadingLocations}
|
||||||
|
value={locationValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('location_id', val);
|
||||||
|
formik.setFieldValue('project_flock_id', null);
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreLocations}
|
||||||
|
isClearable
|
||||||
|
isDisabled={!formik.values.area_id}
|
||||||
|
isError={
|
||||||
|
formik.touched.location_id && Boolean(formik.errors.location_id)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.location_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Project Flock'
|
||||||
|
placeholder='Pilih Project Flock'
|
||||||
|
options={projectFlockOptions}
|
||||||
|
isLoading={isLoadingProjectFlocks}
|
||||||
|
value={projectFlockValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('project_flock_id', val);
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}}
|
||||||
|
onInputChange={setProjectFlockInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||||
|
isClearable
|
||||||
|
isDisabled={!formik.values.location_id}
|
||||||
|
isError={
|
||||||
|
formik.touched.project_flock_id &&
|
||||||
|
Boolean(formik.errors.project_flock_id)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.project_flock_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
required
|
||||||
|
label='Kandang'
|
||||||
|
placeholder='Pilih Kandang'
|
||||||
|
options={projectFlockKandangOptions}
|
||||||
|
isLoading={isLoadingProjectFlockKandangs}
|
||||||
|
value={projectFlockKandangValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
formik.setFieldValue('kandang_id', val);
|
||||||
|
}}
|
||||||
|
onInputChange={setProjectFlockKandangInputValue}
|
||||||
|
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
|
||||||
|
isClearable
|
||||||
|
isDisabled={!formik.values.project_flock_id}
|
||||||
|
isError={
|
||||||
|
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
|
||||||
|
}
|
||||||
|
errorMessage={formik.errors.kandang_id}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='reset'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionResultContent;
|
||||||
+50
-87
@@ -4,12 +4,10 @@ import { useEffect, useState } from 'react';
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Collapse from '@/components/Collapse';
|
|
||||||
|
|
||||||
import { cn, formatNumber } from '@/lib/helper';
|
import { formatNumber } from '@/lib/helper';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { ProductionResult } from '@/types/api/report/production-result';
|
import { ProductionResult } from '@/types/api/report/production-result';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
@@ -52,8 +50,6 @@ const ProductionResultProjectFlockKandangTable = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const productionResultColumns: ColumnDef<ProductionResult>[] = [
|
const productionResultColumns: ColumnDef<ProductionResult>[] = [
|
||||||
@@ -270,93 +266,60 @@ const ProductionResultProjectFlockKandangTable = ({
|
|||||||
}
|
}
|
||||||
}, [sorting]);
|
}, [sorting]);
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
if (!open) {
|
<Card
|
||||||
setOpen(
|
title={kandangName}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg border-none',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
|
collapsible={true}
|
||||||
|
defaultCollapsed={
|
||||||
isResponseSuccess(productionResults)
|
isResponseSuccess(productionResults)
|
||||||
? productionResults.data.length > 0
|
? productionResults.data.length > 0
|
||||||
: false
|
: false
|
||||||
);
|
}
|
||||||
}
|
|
||||||
}, [productionResults, isResponseSuccess]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full',
|
|
||||||
body: 'p-4 shadow',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Collapse
|
<Table<ProductionResult>
|
||||||
open={open}
|
data={
|
||||||
onOpenChange={setOpen}
|
isResponseSuccess(productionResults) ? productionResults?.data : []
|
||||||
title={
|
|
||||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
|
||||||
<div className='card-title'>{kandangName}</div>
|
|
||||||
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:keyboard-arrow-down'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className={cn('text-primary transition-transform', {
|
|
||||||
'-rotate-180': open,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
className='w-full!'
|
columns={productionResultColumns}
|
||||||
titleClassName='w-full p-0!'
|
pageSize={tableFilterState.pageSize}
|
||||||
>
|
onPageSizeChange={setPageSize}
|
||||||
<div className='w-full p-0'>
|
rowOptions={[10, 20, 50, 100]}
|
||||||
{/* <div className='flex flex-col gap-2 mb-4'>
|
page={
|
||||||
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
isResponseSuccess(productionResults)
|
||||||
<DebouncedTextInput
|
? productionResults?.meta?.page
|
||||||
name='search'
|
: 0
|
||||||
placeholder='Cari Record'
|
}
|
||||||
value={tableFilterState.search}
|
totalItems={
|
||||||
onChange={searchChangeHandler}
|
isResponseSuccess(productionResults)
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
? productionResults?.meta?.total_results
|
||||||
/>
|
: 0
|
||||||
</div>
|
}
|
||||||
</div> */}
|
onPageChange={setPage}
|
||||||
|
isLoading={isLoadingProductionResults}
|
||||||
<Table<ProductionResult>
|
sorting={sorting}
|
||||||
data={
|
setSorting={setSorting}
|
||||||
isResponseSuccess(productionResults)
|
renderFooter={false}
|
||||||
? productionResults?.data
|
className={{
|
||||||
: []
|
containerClassName: 'w-full mb-0!',
|
||||||
}
|
tableWrapperClassName:
|
||||||
columns={productionResultColumns}
|
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||||
pageSize={tableFilterState.pageSize}
|
tableClassName: 'w-full table-auto text-sm',
|
||||||
onPageSizeChange={setPageSize}
|
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
|
||||||
rowOptions={[10, 20, 50, 100]}
|
headerColumnClassName:
|
||||||
page={
|
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
|
||||||
isResponseSuccess(productionResults)
|
bodyRowClassName:
|
||||||
? productionResults?.meta?.page
|
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
|
||||||
: 0
|
bodyColumnClassName:
|
||||||
}
|
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||||
totalItems={
|
}}
|
||||||
isResponseSuccess(productionResults)
|
/>
|
||||||
? productionResults?.meta?.total_results
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
|
||||||
isLoading={isLoadingProductionResults}
|
|
||||||
sorting={sorting}
|
|
||||||
setSorting={setSorting}
|
|
||||||
renderFooter={false}
|
|
||||||
className={{
|
|
||||||
containerClassName: cn({
|
|
||||||
'w-full mb-20':
|
|
||||||
isResponseSuccess(productionResults) &&
|
|
||||||
productionResults?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
headerColumnClassName:
|
|
||||||
'px-4 py-3 border-x border-base-content/10 text-base-content/50',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Collapse>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
import { create } from 'zustand';
|
|
||||||
import { devtools } from 'zustand/middleware';
|
|
||||||
|
|
||||||
export type FinanceTabActionsSlice = {
|
|
||||||
// State - actions per tab ID
|
|
||||||
tabActions: Record<string, ReactNode>;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
setTabActions: (tabId: string, actions: ReactNode) => void;
|
|
||||||
clearTabActions: (tabId: string) => void;
|
|
||||||
clearAllTabActions: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useFinanceTabStore = create<FinanceTabActionsSlice>()(
|
|
||||||
devtools(
|
|
||||||
(set) => ({
|
|
||||||
tabActions: {},
|
|
||||||
|
|
||||||
setTabActions: (tabId, actions) =>
|
|
||||||
set(
|
|
||||||
(state) => ({
|
|
||||||
tabActions: {
|
|
||||||
...state.tabActions,
|
|
||||||
[tabId]: actions,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
'setTabActions'
|
|
||||||
),
|
|
||||||
|
|
||||||
clearTabActions: (tabId) =>
|
|
||||||
set(
|
|
||||||
(state) => {
|
|
||||||
const { [tabId]: _, ...rest } = state.tabActions;
|
|
||||||
return { tabActions: rest };
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
'clearTabActions'
|
|
||||||
),
|
|
||||||
|
|
||||||
clearAllTabActions: () =>
|
|
||||||
set({ tabActions: {} }, false, 'clearAllTabActions'),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'FinanceTabStore',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import { createProjectFlockSlice } from '@/stores/project-flock/slices/project-flock.slice';
|
import { createProjectFlockSlice } from '@/stores/production/project-flock/slices/project-flock.slice';
|
||||||
import { ProjectFlockSlice } from '@/types/stores';
|
import { ProjectFlockSlice } from '@/types/stores';
|
||||||
|
|
||||||
export type ProjectFlockStore = ProjectFlockSlice;
|
export type ProjectFlockStore = ProjectFlockSlice;
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import { createUniformitySlice } from '@/stores/uniformity/slices/uniformity.slice';
|
import { createUniformitySlice } from '@/stores/production/uniformity/slices/uniformity.slice';
|
||||||
import { UniformitySlice } from '@/types/stores';
|
import { UniformitySlice } from '@/types/stores';
|
||||||
|
|
||||||
export type UniformityStore = UniformitySlice;
|
export type UniformityStore = UniformitySlice;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import {
|
||||||
|
createReportTabSlice,
|
||||||
|
ReportTabSlice,
|
||||||
|
} from '@/stores/report/slices/report-tab.slice';
|
||||||
|
|
||||||
|
export type ReportTabStore = ReportTabSlice;
|
||||||
|
|
||||||
|
export const useReportTabStore = create<ReportTabStore>()(
|
||||||
|
devtools(
|
||||||
|
(...args) => ({
|
||||||
|
...createReportTabSlice(...args),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'ReportTabStore',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { StateCreator } from 'zustand';
|
||||||
|
|
||||||
|
export type ReportTabSlice = {
|
||||||
|
// State - actions per tab ID
|
||||||
|
tabActions: Record<string, ReactNode>;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setTabActions: (tabId: string, actions: ReactNode) => void;
|
||||||
|
clearTabActions: (tabId: string) => void;
|
||||||
|
clearAllTabActions: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createReportTabSlice: StateCreator<
|
||||||
|
ReportTabSlice,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ReportTabSlice
|
||||||
|
> = (set) => ({
|
||||||
|
tabActions: {},
|
||||||
|
|
||||||
|
setTabActions: (tabId, actions) =>
|
||||||
|
set((state) => ({
|
||||||
|
tabActions: {
|
||||||
|
...state.tabActions,
|
||||||
|
[tabId]: actions,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearTabActions: (tabId) =>
|
||||||
|
set((state) => {
|
||||||
|
const { [tabId]: _, ...rest } = state.tabActions;
|
||||||
|
return { tabActions: rest };
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearAllTabActions: () => set({ tabActions: {} }),
|
||||||
|
});
|
||||||
Vendored
+1
@@ -38,6 +38,7 @@ export type StockLog = {
|
|||||||
id: number;
|
id: number;
|
||||||
increase: number;
|
increase: number;
|
||||||
decrease: number;
|
decrease: number;
|
||||||
|
stock: number;
|
||||||
loggable_type: string;
|
loggable_type: string;
|
||||||
loggable_id: number;
|
loggable_id: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user