Commit 4a474f6c authored by Rais Aryaguna's avatar Rais Aryaguna

feat: bulanan

parent 9ffe9ff5
...@@ -140,8 +140,7 @@ export const dashboardRoutes: RouteObject[] = [ ...@@ -140,8 +140,7 @@ export const dashboardRoutes: RouteObject[] = [
{ index: true, element: <OverviewBupotBulananPage /> }, { index: true, element: <OverviewBupotBulananPage /> },
{ path: 'bulanan', element: <OverviewBupotBulananPage /> }, { path: 'bulanan', element: <OverviewBupotBulananPage /> },
{ path: 'bulanan/rekam', element: <OverviewBupotBulananRekamPage /> }, { path: 'bulanan/rekam', element: <OverviewBupotBulananRekamPage /> },
{ path: 'bulanan/:id/ubah', element: <OverviewBupotBulananRekamPage /> }, { path: 'bulanan/:id/:type', element: <OverviewBupotBulananRekamPage /> },
{ path: 'bulanan/:id/pengganti', element: <OverviewBupotBulananRekamPage /> },
{ path: 'bupot-final', element: <OverviewBupotFinalTdkFinalPage /> }, { path: 'bupot-final', element: <OverviewBupotFinalTdkFinalPage /> },
{ path: 'tahunan', element: <OverviewBupotA1Page /> }, { path: 'tahunan', element: <OverviewBupotA1Page /> },
{ path: 'bupot-26', element: <OverviewBupotPasal26Page /> }, { path: 'bupot-26', element: <OverviewBupotPasal26Page /> },
......
import { Close } from '@mui/icons-material';
import { Dialog, DialogContent, DialogTitle, IconButton, Typography } from '@mui/material';
// import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import { useAppSelector } from 'src/store';
interface DialogPenandatanganProps {
isOpen: boolean;
onClose: () => void;
title?: string;
// onSubmit: (data: any) => void;
// isLoadingButtonSubmit: boolean;
// agreementText?: string;
// isPembatalan: boolean;
// isPending: boolean;
// feature: string;
// isWarning: boolean;
// isCountDown: boolean;
}
export default function DialogPenandatangan({
isOpen,
onClose,
title = 'Penandatangan',
}: DialogPenandatanganProps) {
const penandatanganOptions = useAppSelector((state: any) => state.user.data.signer_npwp);
const form = useForm({
mode: 'all',
});
// const [isCheckedAgreement, setIsCheckedAgreement] = useState(false);
const handleClose = () => {
form.reset();
onClose();
};
// const declareOptions = [
// { label: feature === 'spt faktur' ? 'PKP' : 'Wajib Pajak', value: 0 },
// { label: 'Wakil/Kuasa', value: 1 },
// ];
// const handleSubmitLocal = (data: any) => {
// if (isCountDown)
// setCountdown(30); // start countdown saat submit
// else setCountdown(null);
// onSubmit(data); // tetap panggil props onSubmit
// };
return (
<Dialog
fullWidth
maxWidth="md"
open={isOpen}
onClose={handleClose}
aria-labelledby="dialog-rekap"
>
<DialogTitle id="dialog-rekap">
<Typography textTransform="capitalize" fontWeight="bold" variant="inherit" color="initial">
{title}
</Typography>
</DialogTitle>
<IconButton
aria-label="close"
onClick={handleClose}
sx={(theme: any) => ({
position: 'absolute',
right: 8,
top: 8,
color: theme.palette.grey[500],
})}
>
<Close />
</IconButton>
<DialogContent>
{/* <form onSubmit={form.handleSubmit(handleSubmitLocal)}> */}
<Field.Autocomplete
name="nikNpwpTtd"
label="NPWP/NIK Penandatangan"
options={[{ value: penandatanganOptions, label: `NAMA${penandatanganOptions}` }]}
sx={{ background: 'white' }}
/>
{/*
<Agreement
isCheckedAgreement={isCheckedAgreement}
setIsCheckedAgreement={setIsCheckedAgreement}
text={agreementText}
/>
<LoadingButton
loading={isLoadingButtonSubmit}
disabled={!isCheckedAgreement}
variant="contained"
type="submit"
>
Save
</LoadingButton> */}
{/* </form> */}
</DialogContent>
</Dialog>
);
}
import React from 'react';
import type { GridPreferencePanelsValue} from '@mui/x-data-grid-premium';
import { useGridApiContext } from '@mui/x-data-grid-premium';
import { IconButton, Tooltip } from '@mui/material';
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
// ✅ React.memo: cegah render ulang tanpa alasan
const CustomColumnsButton: React.FC = React.memo(() => {
const apiRef = useGridApiContext();
// ✅ useCallback biar referensi handleClick stabil di setiap render
const handleClick = React.useCallback(() => {
if (!apiRef.current) return;
apiRef.current.showPreferences('columns' as GridPreferencePanelsValue);
}, [apiRef]);
return (
<Tooltip title="Kolom">
<IconButton
size="small"
onClick={handleClick}
sx={{
color: '#123375',
'&:hover': { backgroundColor: 'rgba(18, 51, 117, 0.08)' },
}}
>
<ViewColumnIcon fontSize="small" />
</IconButton>
</Tooltip>
);
});
export default CustomColumnsButton;
import * as React from 'react';
import type { GridToolbarProps } from '@mui/x-data-grid-premium';
import { GridToolbarContainer } from '@mui/x-data-grid-premium';
import { Stack, Divider, IconButton, Tooltip } from '@mui/material';
import type { ActionItem } from '../types/types';
import { CustomFilterButton } from './CustomFilterButton';
import CustomColumnsButton from './CustomColumnsButton';
interface CustomToolbarProps extends GridToolbarProps {
actions?: ActionItem[][];
columns: any[]; // GridColDef[]
filterModel: any;
setFilterModel: (m: any) => void;
statusOptions?: { value: string; label: string }[];
}
// ✅ React.memo mencegah render ulang kalau props sama
export const CustomToolbar = React.memo(function CustomToolbar({
actions = [],
columns,
filterModel,
setFilterModel,
statusOptions = [],
...gridToolbarProps
}: CustomToolbarProps) {
return (
<GridToolbarContainer
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 1.5,
}}
{...gridToolbarProps}
>
<Stack direction="row" alignItems="center" gap={1}>
{actions.map((group, groupIdx) => (
<Stack key={groupIdx} direction="row" gap={0.5} alignItems="center">
{group.map((action, idx) => (
<Tooltip key={idx} title={action.title}>
<span>
<IconButton
sx={{ color: action.disabled ? 'action.disabled' : '#123375' }}
size="small"
onClick={action.func}
disabled={action.disabled}
>
{action.icon}
</IconButton>
</span>
</Tooltip>
))}
{groupIdx < actions.length - 1 && <Divider orientation="vertical" flexItem />}
</Stack>
))}
</Stack>
<Stack direction="row" alignItems="center" gap={0.5}>
<CustomColumnsButton />
<CustomFilterButton
columns={columns}
filterModel={filterModel}
setFilterModel={setFilterModel}
statusOptions={statusOptions}
/>
</Stack>
</GridToolbarContainer>
);
});
import { LoadingButton } from '@mui/lab';
import { Grid, MenuItem, Stack } from '@mui/material';
import type { GridRowSelectionModel } from '@mui/x-data-grid-premium';
import { useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import Agreement from 'src/shared/components/agreement/Agreement';
import DialogProgressBar from 'src/shared/components/dialog/DialogProgressBar';
import DialogUmum from 'src/shared/components/dialog/DialogUmum';
import useDialogProgressBar from 'src/shared/hooks/useDialogProgressBar';
import { useAppSelector } from 'src/store';
import queryKey from '../constant/queryKey';
import useUploadBulanan from '../hooks/useUploadeBulanan';
interface DialogPenandatanganProps {
dataSelected?: GridRowSelectionModel;
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
isOpenDialogUpload: boolean;
setIsOpenDialogUpload: (v: boolean) => void;
successMessage?: string;
onConfirmUpload?: () => Promise<void> | void;
}
const normalizeSelection = (sel?: any): (string | number)[] => {
if (!sel) return [];
if (Array.isArray(sel)) return sel as (string | number)[];
if (sel instanceof Set) return Array.from(sel) as (string | number)[];
if (typeof sel === 'object') {
// common shape from newer MUI: { ids: Set(...), type: 'include' }
if (sel.ids instanceof Set) return Array.from(sel.ids) as (string | number)[];
// maybe it's a map-like object { '1': true, '2': true }
const maybeIds = Object.keys(sel).filter((k) => k !== 'type' && k !== 'size');
if (maybeIds.length > 0) {
// try to convert numeric-like keys to number where applicable
return maybeIds.map((k) => {
const n = Number(k);
return Number.isNaN(n) ? k : n;
});
}
}
return [];
};
const DialogPenandatangan: React.FC<DialogPenandatanganProps> = ({
dataSelected,
setSelectionModel,
tableApiRef,
isOpenDialogUpload,
setIsOpenDialogUpload,
successMessage = 'Data berhasil diupload',
onConfirmUpload,
}) => {
const [isOpenDialogProgressBar, setIsOpenDialogProgressBar] = useState(false);
const [isCheckedAgreement, setIsCheckedAgreement] = useState<boolean>(false);
const signer = useAppSelector((state: any) => state.user.data.signer);
const queryClient = useQueryClient();
const methods = useForm({
defaultValues: {
signer: signer || '',
},
});
const {
numberOfData,
setNumberOfData,
numberOfDataFail,
numberOfDataProcessed,
numberOfDataSuccess,
processSuccess,
processFail,
resetToDefault,
status,
} = useDialogProgressBar();
const { mutateAsync, isPending } = useUploadBulanan({
onSuccess: () => processSuccess(),
onError: () => processFail(),
});
const handleMultipleDelete = async () => {
const ids = normalizeSelection(dataSelected);
return Promise.allSettled(ids.map(async (id) => mutateAsync({ id: String(id) })));
};
const clearSelection = () => {
// clear grid selection via apiRef if available
tableApiRef?.current?.setRowSelectionModel?.([]);
// also clear local state if setter provided
// set to undefined to follow the native hook type (or to empty array cast)
setSelectionModel?.(undefined);
};
const handleCloseModal = () => {
setIsOpenDialogUpload(false);
resetToDefault();
};
const onSubmit = async () => {
try {
setIsOpenDialogProgressBar(true);
await handleMultipleDelete();
enqueueSnackbar(successMessage, { variant: 'success' });
handleCloseModal();
clearSelection();
} catch (error: any) {
enqueueSnackbar(error?.message || 'Gagal upload data', { variant: 'error' });
} finally {
// sesuaikan queryKey jika perlu; tetap panggil invalidasi
queryClient.invalidateQueries({ queryKey: queryKey.bulanan.all('') });
}
};
useEffect(() => {
setNumberOfData(normalizeSelection(dataSelected).length);
}, [isOpenDialogUpload, dataSelected, setNumberOfData]);
return (
<>
<FormProvider {...methods}>
<DialogUmum
isOpen={isOpenDialogUpload}
onClose={handleCloseModal}
title="Upload Bukti Potong"
>
<Stack spacing={2} sx={{ mt: 2 }}>
<Grid size={{ md: 12 }}>
<Field.Select name="signer" label="NPWP/NIK Penandatangan">
<MenuItem value={signer}>{signer}</MenuItem>
</Field.Select>
</Grid>
<Grid size={12}>
<Agreement
isCheckedAgreement={isCheckedAgreement}
setIsCheckedAgreement={setIsCheckedAgreement}
text="Dengan ini saya menyatakan bahwa Bukti Pemotongan/Pemungutan Unifikasi telah saya isi dengan benar secara elektronik sesuai dengan"
/>
</Grid>
<Stack direction="row" justifyContent="flex-end" spacing={1} mt={1}>
<LoadingButton
type="button"
disabled={!isCheckedAgreement}
// onClick={onSubmit}
onClick={async () => {
if (onConfirmUpload) {
await onConfirmUpload();
setIsOpenDialogUpload(false);
return;
}
await onSubmit();
}}
loading={isPending}
variant="contained"
sx={{ background: '#143B88' }}
>
Save
</LoadingButton>
</Stack>
</Stack>
</DialogUmum>
</FormProvider>
<DialogProgressBar
isOpen={isOpenDialogProgressBar}
handleClose={() => {
handleCloseModal();
setIsOpenDialogProgressBar(false);
}}
numberOfData={numberOfData}
numberOfDataProcessed={numberOfDataProcessed}
numberOfDataFail={numberOfDataFail}
numberOfDataSuccess={numberOfDataSuccess}
status={status}
/>
</>
);
};
export default DialogPenandatangan;
...@@ -206,20 +206,6 @@ export const FG_BUPOT = { ...@@ -206,20 +206,6 @@ export const FG_BUPOT = {
BULANAN: '1', BULANAN: '1',
}; };
// export const FG_STATUS = {
// NORMAL: '0',
// NORMAL_PENGGANTI: '1',
// DIGANTI: '2',
// BATAL: '3',
// HAPUS: '4',
// SUBMITTED: '5',
// DRAFT: '6',
// FAILED: '7',
// PENDING: '8',
// EXTERNAL: '9',
// ON_SCHEDULE: '10',
// };
export const FG_STATUS: Record<string, string> = { export const FG_STATUS: Record<string, string> = {
'0': 'NORMAL', '0': 'NORMAL',
'1': 'NORMAL_PENGGANTI', '1': 'NORMAL_PENGGANTI',
...@@ -234,6 +220,13 @@ export const FG_STATUS: Record<string, string> = { ...@@ -234,6 +220,13 @@ export const FG_STATUS: Record<string, string> = {
'10': 'ON_SCHEDULE', '10': 'ON_SCHEDULE',
}; };
export const FG_STATUS_BUPOT = {
DRAFT: 'DRAFT',
NORMAL_DONE: 'NORMAL-Done',
AMENDED: 'AMENDED',
CANCELLED: 'CANCELLED',
};
export const FG_PDF_STATUS = { export const FG_PDF_STATUS = {
TERBENTUK: '0', TERBENTUK: '0',
BELUM_TERBENTUK: '1', BELUM_TERBENTUK: '1',
......
const appRootKey = 'bupot'; export const appRootKey = 'bupot-21-26';
export const bulanan = 'bulanan';
const queryKey = { const queryKey = {
getKodeObjekPajak: (params: any) => [appRootKey, 'kode-objek-pajak', params], getKodeObjekPajak: (params: any) => [appRootKey, 'kode-objek-pajak', params],
......
type FilterItem = {
field: string;
operator: string;
value?: string | number | Array<string | number> | null;
join?: 'AND' | 'OR';
};
type BaseParams = Record<string, any>;
export function useAdvancedFilter() {
const numericFields = new Set(['masaPajak', 'tahunPajak', 'dpp', 'pphDipotong']);
const dateFields = new Set(['created_at', 'updated_at']);
const fieldMap: Record<string, string> = {
noBupot: 'nomorBupot',
};
const dbField = (field: string) => fieldMap[field] ?? field;
const escape = (v: string) => String(v).replace(/'/g, "''");
const toDbDate = (value: string | Date) => {
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}${m}${d}`;
}
const digits = String(value).replace(/[^0-9]/g, '');
if (digits.length >= 8) return digits.slice(0, 8);
return digits;
};
const normalizeOp = (op: string) => op?.toString().trim();
function buildAdvancedFilter(filters?: FilterItem[] | null) {
if (!filters || filters.length === 0) return '';
const exprs: string[] = [];
const joins: ('AND' | 'OR')[] = [];
for (let i = 0; i < filters.length; i++) {
const f = filters[i];
if (!f || !f.field) continue;
const op = normalizeOp(f.operator ?? '');
const fieldName = dbField(f.field);
let expr: string | null = null;
// --- DATE FIELDS ---
if (dateFields.has(fieldName)) {
const rawVal = f.value;
if (!rawVal && !/is empty|is not empty/i.test(op)) continue;
const ymd = toDbDate(rawVal as string | Date);
if (!ymd) continue;
if (/^is$/i.test(op)) {
expr = `"${fieldName}" >= '${ymd} 00:00:00' AND "${fieldName}" <= '${ymd} 23:59:59'`;
} else if (/is on or after/i.test(op)) {
expr = `"${fieldName}" >= '${ymd}'`;
} else if (/is on or before/i.test(op)) {
expr = `"${fieldName}" <= '${ymd}'`;
}
}
// --- EMPTY ---
if (/is empty/i.test(op)) {
expr = `LOWER("${fieldName}") IS NULL`;
} else if (/is not empty/i.test(op)) {
expr = `LOWER("${fieldName}") IS NOT NULL`;
}
// --- IS ANY OF ---
if (!expr && /is any of/i.test(op)) {
let values: Array<string | number> = [];
if (Array.isArray(f.value)) values = f.value as any;
else if (typeof f.value === 'string')
values = f.value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
else if (f.value != null) values = [f.value as any];
if (values.length > 0) {
if (fieldName === 'fgStatus' || fieldName === 'fg_status') {
const ors = values.map((v) => {
const s = escape(String(v).toLowerCase());
return `LOWER("${fieldName}") LIKE LOWER('%${s}%')`;
});
expr = `(${ors.join(' OR ')})`;
} else {
const ors = values.map((v) => {
const s = escape(String(v).toLowerCase());
return `LOWER("${fieldName}") = '${s}'`;
});
expr = `(${ors.join(' OR ')})`;
}
}
}
// --- FGSTATUS special single-value is / is not ---
if (!expr && (fieldName === 'fgStatus' || fieldName === 'fg_status')) {
const valRaw = f.value == null ? '' : String(f.value);
if (valRaw !== '' || /is any of|is empty|is not empty/i.test(op)) {
const valEscaped = escape(valRaw.toLowerCase());
if (/^is$/i.test(op)) {
expr = `LOWER("${fieldName}") LIKE LOWER('%${valEscaped}%')`;
} else if (/is not/i.test(op)) {
expr = `LOWER("${fieldName}") NOT LIKE LOWER('%${valEscaped}%')`;
}
}
}
// --- GENERIC ---
if (!expr) {
const valRaw = f.value == null ? '' : String(f.value);
if (valRaw !== '') {
const valEscaped = escape(valRaw.toLowerCase());
if (numericFields.has(fieldName) && /^(=|>=|<=)$/.test(op)) {
expr = `"${fieldName}" ${op} '${valEscaped}'`;
} else if (/^contains$/i.test(op)) {
expr = `LOWER("${fieldName}") LIKE LOWER('%${valEscaped}%')`;
} else if (/^equals$/i.test(op)) {
const values = Array.isArray(f.value)
? (f.value as any[]).map((v) => escape(String(v).toLowerCase()))
: [escape(String(f.value).toLowerCase())];
expr = `LOWER("${fieldName}") IN (${values.map((v) => `'${v}'`).join(',')})`;
} else if (/^(>=|<=|=)$/.test(op) && !numericFields.has(fieldName)) {
expr = `LOWER("${fieldName}") ${op} '${valEscaped}'`;
} else if (/^(is)$/i.test(op)) {
expr = `LOWER("${fieldName}") = '${valEscaped}'`;
} else {
expr = `LOWER("${fieldName}") = '${valEscaped}'`;
}
}
}
if (expr) {
exprs.push(expr);
const joinBefore = f.join ?? (exprs.length > 1 ? 'AND' : 'AND');
joins.push(joinBefore);
}
}
if (exprs.length === 0) return '';
let out = exprs[0];
for (let i = 1; i < exprs.length; i++) {
const j = joins[i] ?? 'AND';
out = `(${out}) ${j} (${exprs[i]})`;
}
return out;
}
/**
* ✅ FIXED: Clean undefined values dan handle sorting dengan benar
*/
function buildRequestParams(base: BaseParams = {}, advanced: string) {
const out: BaseParams = {};
// ✅ Copy semua base params kecuali yang undefined
Object.keys(base).forEach((key) => {
if (base[key] !== undefined) {
out[key] = base[key];
}
});
// ✅ Field mapping
if ('noBupot' in out) {
out.nomorBupot = out.noBupot;
delete out.noBupot;
}
// ✅ Hanya tambahkan advanced jika ada isinya
if (advanced && advanced.trim() !== '') {
out.advanced = advanced.trim();
}
// ✅ Clean up undefined sorting (jangan kirim ke backend)
if (out.sortingMode === undefined) {
delete out.sortingMode;
}
if (out.sortingMethod === undefined) {
delete out.sortingMethod;
}
return out;
}
return { buildAdvancedFilter, buildRequestParams } as const;
}
...@@ -75,13 +75,11 @@ const normalisePropsGetBulanan = (params: TGetListDataTableDn) => ({ ...@@ -75,13 +75,11 @@ const normalisePropsGetBulanan = (params: TGetListDataTableDn) => ({
idDipotong: params.userId, idDipotong: params.userId,
}); });
const normalisPropsParmasGetDn = (params: any) => { const normalisPropsParmas = (params: any) => {
const sorting = !isEmpty(params.sort) ? transformSortModelToSortApiPayload(params.sort) : {}; const sorting = !isEmpty(params.sort) ? transformSortModelToSortApiPayload(params.sort) : {};
return { return {
...params, ...params,
page: params.Page,
limit: params.Limit,
masaPajak: params.msPajak || null, masaPajak: params.msPajak || null,
tahunPajak: params.thnPajak || null, tahunPajak: params.thnPajak || null,
npwp: params.idDipotong || null, npwp: params.idDipotong || null,
...@@ -93,7 +91,7 @@ const useGetBulanan = ({ params, ...props }: any) => { ...@@ -93,7 +91,7 @@ const useGetBulanan = ({ params, ...props }: any) => {
const query = useQuery<TBaseResponseAPI<TGetListDataTableDnResult>>({ const query = useQuery<TBaseResponseAPI<TGetListDataTableDnResult>>({
queryKey: queryKey.bulanan.all(params), queryKey: queryKey.bulanan.all(params),
queryFn: async () => { queryFn: async () => {
const response = await bulananApi.getList({ params: normalisPropsParmasGetDn(params) }); const response = await bulananApi.getList({ params: normalisPropsParmas(params) });
return { return {
...response, ...response,
......
...@@ -4,12 +4,11 @@ import Divider from '@mui/material/Divider'; ...@@ -4,12 +4,11 @@ import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Suspense, useMemo, useState } from 'react'; import { Suspense, useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { Field } from 'src/components/hook-form'; import { Field } from 'src/components/hook-form';
import { DashboardContent } from 'src/layouts/dashboard'; import { DashboardContent } from 'src/layouts/dashboard';
import { usePathname } from 'src/routes/hooks';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';
import Agreement from 'src/shared/components/agreement/Agreement'; import Agreement from 'src/shared/components/agreement/Agreement';
import HeadingRekam from 'src/shared/components/HeadingRekam'; import HeadingRekam from 'src/shared/components/HeadingRekam';
...@@ -20,15 +19,17 @@ import JumlahPerhitunganForm from '../components/rekam/JumlahPerhitunganForm'; ...@@ -20,15 +19,17 @@ import JumlahPerhitunganForm from '../components/rekam/JumlahPerhitunganForm';
import PanduanDnRekam from '../components/rekam/PanduanDnRekam'; import PanduanDnRekam from '../components/rekam/PanduanDnRekam';
import PerhitunganPPhPasal21 from '../components/rekam/PerhitunganPPhPasal21'; import PerhitunganPPhPasal21 from '../components/rekam/PerhitunganPPhPasal21';
import { import {
ActionRekam,
FG_FASILITAS_PPH_21, FG_FASILITAS_PPH_21,
FG_FASILITAS_PPH_21_TEXT, FG_FASILITAS_PPH_21_TEXT,
KODE_OBJEK_PAJAK, KODE_OBJEK_PAJAK,
KODE_OBJEK_PAJAK_TEXT, KODE_OBJEK_PAJAK_TEXT,
} from '../constant'; } from '../constant';
import { checkCurrentPage } from '../utils/utils';
import useSaveBulanan from '../hooks/useSaveBulanan'; import useSaveBulanan from '../hooks/useSaveBulanan';
import DialogPenandatangan from '../../DialogPenandatangan'; import DialogPenandatangan from '../components/DialogPenandatangan';
import useUploadBulanan from '../hooks/useUploadeBulanan';
import { useNavigate, useParams } from 'react-router';
import { enqueueSnackbar } from 'notistack';
import useGetBulanan from '../hooks/useGetBulanan';
const bulananSchema = z const bulananSchema = z
.object({ .object({
...@@ -168,16 +169,21 @@ const bulananSchema = z ...@@ -168,16 +169,21 @@ const bulananSchema = z
); );
export const BulananRekamView = () => { export const BulananRekamView = () => {
// const { id } = useParams(); const { id, type } = useParams<{ id?: string; type?: 'ubah' | 'pengganti' | 'new' }>();
const pathname = usePathname(); const navigate = useNavigate();
const { mutate: saveBulanan, isPending: isSaving } = useSaveBulanan(); const { mutate: saveBulanan, isPending: isSaving } = useSaveBulanan({
onSuccess: () => enqueueSnackbar('Data berhasil disimpan', { variant: 'success' }),
});
const { mutate: uploadBulanan, isPending: isUpload } = useUploadBulanan();
const [isOpenPanduan, setIsOpenPanduan] = useState<boolean>(false); const [isOpenPanduan, setIsOpenPanduan] = useState<boolean>(false);
const [isCheckedAgreement, setIsCheckedAgreement] = useState<boolean>(false); const [isCheckedAgreement, setIsCheckedAgreement] = useState<boolean>(false);
const [isOpenDialogPenandatangan, setIsOpenDialogPenandatangan] = useState(false); const [isOpenDialogPenandatangan, setIsOpenDialogPenandatangan] = useState(false);
const actionRekam = checkCurrentPage(pathname); const isEdit = type === 'ubah';
const isPengganti = type === 'pengganti';
const dataListKOP = useMemo( const dataListKOP = useMemo(
() => () =>
[KODE_OBJEK_PAJAK.BULANAN_01, KODE_OBJEK_PAJAK.BULANAN_02, KODE_OBJEK_PAJAK.BULANAN_03].map( [KODE_OBJEK_PAJAK.BULANAN_01, KODE_OBJEK_PAJAK.BULANAN_02, KODE_OBJEK_PAJAK.BULANAN_03].map(
...@@ -189,6 +195,15 @@ export const BulananRekamView = () => { ...@@ -189,6 +195,15 @@ export const BulananRekamView = () => {
[] []
); );
const { data: existingBulanan, isLoading: isLoadingBulanan } = useGetBulanan({
params: {
page: 1,
limit: 1,
id,
},
enabled: !!id,
});
type BpuFormData = z.infer<typeof bulananSchema>; type BpuFormData = z.infer<typeof bulananSchema>;
const handleOpenPanduan = () => setIsOpenPanduan(!isOpenPanduan); const handleOpenPanduan = () => setIsOpenPanduan(!isOpenPanduan);
...@@ -236,13 +251,22 @@ export const BulananRekamView = () => { ...@@ -236,13 +251,22 @@ export const BulananRekamView = () => {
defaultValues, defaultValues,
}); });
console.log('🚀 ~ BulananRekamView ~ methods:', methods.formState.errors); useEffect(() => {
if ((isEdit || isPengganti) && existingBulanan && !isLoadingBulanan) {
const normalized = {
...existingBulanan,
};
methods.reset(normalized as any);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, isPengganti, existingBulanan, isLoadingBulanan]);
const handleDraft = async (data: BpuFormData) => { const handleDraft = async (data: BpuFormData) => {
// Transform data sesuai dengan struktur yang dibutuhkan // Transform data sesuai dengan struktur yang dibutuhkan
const transformedData = { const transformedData = {
...data, ...data,
id: actionRekam === ActionRekam.UBAH ? data.id : undefined, id: isEdit || isPengganti ? data.id : undefined,
msPajak: data.masaPajak, msPajak: data.masaPajak,
thnPajak: data.tahunPajak, thnPajak: data.tahunPajak,
passportNo: data.passport || '', passportNo: data.passport || '',
...@@ -263,13 +287,42 @@ export const BulananRekamView = () => { ...@@ -263,13 +287,42 @@ export const BulananRekamView = () => {
await saveBulanan(transformedData); await saveBulanan(transformedData);
}; };
const handleUploud = async (data: BpuFormData) => {
try {
const response = await handleDraft(data);
uploadBulanan({
id: response?.id ?? '',
});
enqueueSnackbar('Berhasil Menyimpan Data', { variant: 'success' });
} catch (error: any) {
enqueueSnackbar(error.message, { variant: 'error' });
} finally {
navigate('/pph21/bulanan');
}
};
const handleClickUpload = async () => { const handleClickUpload = async () => {
setIsOpenDialogPenandatangan(true); setIsOpenDialogPenandatangan(true);
}; };
const SubmitRekam = async (data: BpuFormData) => { const SubmitRekam = async (data: BpuFormData) => {
const respon = await handleDraft(data); try {
console.log({ respon }); const respon = await handleDraft(data);
console.log({ respon });
enqueueSnackbar(
isEdit
? 'Data berhasil diperbarui'
: isPengganti
? 'Data pengganti berhasil disimpan'
: 'Data berhasil disimpan',
{ variant: 'success' }
);
navigate('/pph21/bulanan');
} catch (error: any) {
enqueueSnackbar(error.message || 'Gagal menyimpan data', { variant: 'error' });
console.error('❌ SaveDn error:', error);
}
}; };
const MockNitku = [ const MockNitku = [
...@@ -345,7 +398,7 @@ export const BulananRekamView = () => { ...@@ -345,7 +398,7 @@ export const BulananRekamView = () => {
type="button" type="button"
disabled={!isCheckedAgreement} disabled={!isCheckedAgreement}
onClick={methods.handleSubmit(handleClickUpload)} onClick={methods.handleSubmit(handleClickUpload)}
loading={isSaving} loading={isSaving || isUpload}
variant="contained" variant="contained"
sx={{ background: '#143B88' }} sx={{ background: '#143B88' }}
> >
...@@ -361,12 +414,13 @@ export const BulananRekamView = () => { ...@@ -361,12 +414,13 @@ export const BulananRekamView = () => {
</Grid> </Grid>
</Grid> </Grid>
</DashboardContent> </DashboardContent>
<DialogPenandatangan {isOpenDialogPenandatangan && (
isOpen={isOpenDialogPenandatangan} <DialogPenandatangan
onClose={() => { isOpenDialogUpload={isOpenDialogPenandatangan}
setIsOpenDialogPenandatangan(false); setIsOpenDialogUpload={setIsOpenDialogPenandatangan}
}} onConfirmUpload={() => methods.handleSubmit(handleUploud)()}
/> />
)}
</> </>
); );
}; };
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { produce } from 'immer';
type TableKey = string;
interface TablePagination {
page: number; // 0-based untuk MUI DataGrid
pageSize: number;
}
interface PaginationState {
tables: Record<TableKey, TablePagination>;
}
interface PaginationActions {
setPagination: (table: TableKey, next: Partial<TablePagination>) => void;
resetPagination: (table: TableKey) => void;
resetAllPaginations: () => void;
getPagination: (table: TableKey) => TablePagination;
removePagination: (table: TableKey) => void;
}
type PaginationStore = PaginationState & PaginationActions;
// ✅ Default untuk MUI DataGrid (0-based)
const DEFAULT_PAGINATION: Readonly<TablePagination> = Object.freeze({
page: 0, // 0-based untuk MUI
pageSize: 10,
});
const STORAGE_KEY = 'pagination-storage';
export const usePaginationStore = create<PaginationStore>()(
devtools(
persist(
(set, get) => ({
tables: {},
setPagination: (table, next) => {
set(
produce<PaginationStore>((draft) => {
const current = draft.tables[table] ?? { ...DEFAULT_PAGINATION };
draft.tables[table] = {
page: next.page ?? current.page,
pageSize: next.pageSize ?? current.pageSize,
};
}),
false,
{ type: 'SET_PAGINATION', table, next }
);
},
resetPagination: (table) => {
set(
produce<PaginationStore>((draft) => {
const currentPageSize = draft.tables[table]?.pageSize ?? DEFAULT_PAGINATION.pageSize;
draft.tables[table] = {
page: DEFAULT_PAGINATION.page,
pageSize: currentPageSize,
};
}),
false,
{ type: 'RESET_PAGINATION', table }
);
},
resetAllPaginations: () => {
set({ tables: {} }, false, { type: 'RESET_ALL_PAGINATIONS' });
},
getPagination: (table) => {
const state = get();
return state.tables[table] ?? { ...DEFAULT_PAGINATION };
},
removePagination: (table) => {
set(
produce<PaginationStore>((draft) => {
delete draft.tables[table];
}),
false,
{ type: 'REMOVE_PAGINATION', table }
);
},
}),
{
name: STORAGE_KEY,
partialize: (state) => ({ tables: state.tables }),
}
),
{ name: 'PaginationStore', enabled: process.env.NODE_ENV === 'development' }
)
);
// ============================================================================
// CUSTOM HOOKS WITH 1-BASED CONVERSION
// ============================================================================
/**
* ✅ Hook dengan konversi otomatis ke 1-based untuk backend
* MUI DataGrid: 0-based (page 0, 1, 2, ...)
* Backend API: 1-based (page 1, 2, 3, ...)
*/
export const useTablePagination = (tableKey: TableKey) => {
const pagination = usePaginationStore((s) => s.tables[tableKey] ?? DEFAULT_PAGINATION);
const setPagination = usePaginationStore((s) => s.setPagination);
const resetPagination = usePaginationStore((s) => s.resetPagination);
return [
pagination, // untuk MUI DataGrid (0-based)
(next: Partial<TablePagination>) => setPagination(tableKey, next),
() => resetPagination(tableKey),
] as const;
};
/**
* ✅ Hook khusus yang return page dalam format 1-based untuk API
*/
export const useTablePaginationForAPI = (tableKey: TableKey) => {
const pagination = usePaginationStore((s) => s.tables[tableKey] ?? DEFAULT_PAGINATION);
return {
page: pagination.page + 1, // Convert to 1-based
pageSize: pagination.pageSize,
limit: pagination.pageSize, // alias
};
};
export const useTablePage = (tableKey: TableKey) =>
usePaginationStore((s) => s.tables[tableKey]?.page ?? DEFAULT_PAGINATION.page);
export const useTablePageSize = (tableKey: TableKey) =>
usePaginationStore((s) => s.tables[tableKey]?.pageSize ?? DEFAULT_PAGINATION.pageSize);
export const createTableKey = (...parts: string[]): TableKey => parts.filter(Boolean).join('-');
export type { TableKey, TablePagination, PaginationStore };
export { DEFAULT_PAGINATION };
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment