Commit 9549ebd4 authored by Rais Aryaguna's avatar Rais Aryaguna

fix tahunan A1

add Bupot Pasal 26
parent 5dae0e7c
import { CONFIG } from 'src/global-config';
import { Bupot26ListView } from 'src/sections/bupot-21-26/bupot-26/view'
const metadata = { title: `E-Bupot 21/26- ${CONFIG.appName}` };
......@@ -7,7 +8,7 @@ export default function Page() {
<>
<title>{metadata.title}</title>
<p>Bupot Pasal 26</p>
<Bupot26ListView/>
</>
);
}
import { CONFIG } from 'src/global-config';
import { Bupot26RekamView } from 'src/sections/bupot-21-26/bupot-26/view'
const metadata = { title: `E-Bupot 21/26- ${CONFIG.appName}` };
export default function Page() {
return (
<>
<title>{metadata.title}</title>
<Bupot26RekamView/>
</>
);
}
......@@ -122,7 +122,9 @@ export const paths = {
tahunanA2: `${ROOTS.PPH21}/tahunan-a2`,
detailstahunanA2: (id: string) => `${ROOTS.PPH21}/tahunan-a2/${id}`,
bupot26: `${ROOTS.PPH21}/bupot-26`,
detailsbupot26: (id: string) => `${ROOTS.PPH21}/bupot-26/${id}`,
bupot26Rekam: `${ROOTS.PPH21}/bupot-26/rekam`,
bupot26Edit: (id: string, path:string) => `${ROOTS.PPH21}/bupot-26/${id}/${path}`,
},
// DASHBOARD
dashboard: {
......
......@@ -65,6 +65,7 @@ const OverviewBupotFinalTdkFinalRekamPage = lazy(
const OverviewBupotA1Page = lazy(() => import('src/pages/pph21/bupotTahunanA1'));
const OverviewBupotA1RekamPage = lazy(() => import('src/pages/pph21/bupotTahunanA1Rekam'));
const OverviewBupotPasal26Page = lazy(() => import('src/pages/pph21/bupotPasal26'));
const OverviewBupotPasal26RekamPage = lazy(() => import('src/pages/pph21/bupotPasal26Rekam'));
// ----------------------------------------------------------------------
......@@ -155,6 +156,8 @@ export const dashboardRoutes: RouteObject[] = [
{ path:'tahunan/rekam', element:<OverviewBupotA1RekamPage/>},
{ path:'tahunan/:id/:type', element:<OverviewBupotA1RekamPage/>},
{ path: 'bupot-26', element: <OverviewBupotPasal26Page /> },
{ path: 'bupot-26/rekam', element: <OverviewBupotPasal26RekamPage /> },
{ path: 'bupot-26/:id/:type', element: <OverviewBupotPasal26RekamPage /> },
],
},
];
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 React from 'react';
import Chip from '@mui/material/Chip';
import Box from '@mui/material/Box';
type Props = { value?: string; revNo?: number };
const StatusChip: React.FC<Props> = ({ value, revNo }) => {
if (!value) return <Chip label="" size="small" />;
if (value === 'NORMAL-Done' && revNo !== 0) {
return (
<Box
sx={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
}}
>
<Chip
label="Normal Pengganti"
size="small"
variant="outlined"
sx={{
borderColor: '#1976d2',
color: '#1976d2',
borderRadius: '8px',
fontWeight: 500,
paddingRight: '5px',
}}
/>
<Chip
label={revNo}
size="small"
variant="filled"
sx={{
position: 'absolute',
top: -6,
right: -6,
backgroundColor: '#1976d2',
color: '#fff',
borderRadius: '50%',
fontWeight: 500,
width: 18,
height: 18,
minWidth: 0,
border: '2px solid #fff',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.25)',
'& .MuiChip-label': {
padding: 0,
fontSize: '0.65rem',
lineHeight: 1,
},
}}
/>
</Box>
);
}
if (value === 'NORMAL-Done' && revNo === 0) {
return (
<Chip
label="Normal"
size="small"
variant="outlined"
sx={{
borderColor: '#1976d2',
color: '#1976d2',
borderRadius: '8px',
fontWeight: '500',
}}
/>
);
}
if (value === 'AMENDED') {
return (
<Chip
label="Diganti"
size="small"
variant="outlined"
sx={{
color: '#fff',
backgroundColor: '#f38c28',
borderRadius: '8px',
fontWeight: 500,
border: 'none',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.15)',
}}
/>
);
}
if (value === 'CANCELLED') {
return (
<Chip
label="Dibatalkan"
size="small"
variant="outlined"
sx={{
borderColor: '#d32f2f',
color: '#d32f2f',
borderRadius: '8px',
fontWeight: '500',
}}
/>
);
}
if (value === 'DRAFT') {
return (
<Chip
label="Draft"
size="small"
variant="outlined"
sx={{
borderColor: '#9e9e9e',
color: '#616161',
borderRadius: '8px',
}}
/>
);
}
return <Chip label={value} size="small" />;
};
export default React.memo(StatusChip);
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
} from '@mui/material';
interface CancelConfirmationDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
selectedCount: number;
}
const CancelConfirmationDialog: React.FC<CancelConfirmationDialogProps> = ({
open,
onClose,
onConfirm,
selectedCount,
}) => (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Konfirmasi Pembatalan</DialogTitle>
<DialogContent>
<Typography>
Apakah Anda yakin ingin membatalkan {selectedCount} data yang dipilih?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Batal</Button>
<Button onClick={onConfirm} color="error" variant="contained">
Ya, Batalkan
</Button>
</DialogActions>
</Dialog>
);
export default CancelConfirmationDialog;
import React, { useEffect, useMemo, useState } from 'react';
import { Stack, Button, Typography } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import minMax from 'dayjs/plugin/minMax';
import DialogUmum from 'src/shared/components/dialog/DialogUmum';
import DialogProgressBar from 'src/shared/components/dialog/DialogProgressBar';
import useDialogProgressBar from 'src/shared/hooks/useDialogProgressBar';
import useCancelDn from '../../hooks/useCancel';
import type { GridRowSelectionModel } from '@mui/x-data-grid-premium';
dayjs.extend(minMax);
// Helper format tanggal ke format API (DDMMYYYY)
const formatDateDDMMYYYY = (d: Date) => {
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = d.getFullYear();
return `${dd}${mm}${yyyy}`;
};
interface ModalCancelNrProps {
dataSelected?: any[];
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
isOpenDialogCancel: boolean;
setIsOpenDialogCancel: (v: boolean) => void;
successMessage?: string;
onConfirmCancel?: () => Promise<void> | void;
}
const ModalCancelNr: React.FC<ModalCancelNrProps> = ({
dataSelected = [],
setSelectionModel,
tableApiRef,
isOpenDialogCancel,
setIsOpenDialogCancel,
successMessage = 'Data berhasil dibatalkan',
onConfirmCancel,
}) => {
const queryClient = useQueryClient();
const [tglPembatalan, setTglPembatalan] = useState<Dayjs | null>(null);
const [isOpenDialogProgressBar, setIsOpenDialogProgressBar] = useState(false);
const {
numberOfData,
setNumberOfData,
numberOfDataFail,
numberOfDataProcessed,
numberOfDataSuccess,
processSuccess,
processFail,
resetToDefault,
status,
} = useDialogProgressBar();
const { mutateAsync } = useCancelDn({
onSuccess: () => processSuccess(),
onError: () => processFail(),
});
// ✅ update jumlah data di progress bar
useEffect(() => {
setNumberOfData(dataSelected?.length ?? 0);
}, [dataSelected, setNumberOfData]);
// ✅ Ambil tanggal pemotongan paling awal (untuk minDate)
const minPembatalanDate = useMemo(() => {
if (!dataSelected.length) return null;
const dates = dataSelected
.map((d) => {
const tgl = d.tglPemotongan || d.tglpemotongan;
return tgl ? dayjs(tgl, ['YYYY-MM-DD', 'DD/MM/YYYY']) : null;
})
.filter((d): d is Dayjs => !!d && d.isValid());
return dates.length > 0 ? dayjs.min(dates) : null;
}, [dataSelected]);
const handleCloseModal = () => {
setIsOpenDialogCancel(false);
resetToDefault();
};
const handleSubmit = async () => {
if (!tglPembatalan) {
enqueueSnackbar('Tanggal pembatalan harus diisi', { variant: 'warning' });
return;
}
const formattedDate = formatDateDDMMYYYY(tglPembatalan.toDate());
const ids = dataSelected.map((item) => String(item.id ?? item.internal_id));
try {
setIsOpenDialogProgressBar(true);
const results = await Promise.allSettled(
ids.map((id) => mutateAsync({ id, tglPembatalan: formattedDate, feature:'26' }))
);
const rejected = results.filter((r) => r.status === 'rejected');
const success = results.filter((r) => r.status === 'fulfilled');
// ✅ tampilkan pesan error detail
if (rejected.length > 0) {
const errorMessages = rejected
.map((r) => (r.status === 'rejected' ? r.reason?.message : ''))
.filter(Boolean)
.join('\n');
enqueueSnackbar(
<span style={{ whiteSpace: 'pre-line' }}>
{errorMessages || `${rejected.length} dari ${ids.length} data gagal dibatalkan.`}
</span>,
{ variant: 'error' }
);
processFail();
}
if (success.length > 0) {
enqueueSnackbar(successMessage, { variant: 'success' });
processSuccess();
await onConfirmCancel?.();
}
// ✅ update cache data lokal agar status langsung berubah
queryClient.setQueryData(['unifikasi', 'dn'], (old: any) => {
if (!old?.data) return old;
return {
...old,
data: old.data.map((row: any) =>
ids.includes(String(row.id)) ? { ...row, fgStatus: 'CANCELLED' } : row
),
};
});
// ✅ refetch data agar sinkron
await queryClient.invalidateQueries({ queryKey: ['unifikasi', 'dn'] });
// ⚠️ Tidak perlu clearSelection di sini — DnListView akan sync otomatis lewat rowsSet
handleCloseModal();
} catch (error: any) {
enqueueSnackbar(error?.message || 'Gagal membatalkan data', { variant: 'error' });
processFail();
} finally {
setIsOpenDialogProgressBar(false);
}
};
return (
<>
{/* ✅ Dialog reusable */}
<DialogUmum
isOpen={isOpenDialogCancel}
onClose={handleCloseModal}
title="Batal Bukti Pemotongan/Pemungutan PPh Unifikasi"
>
<Stack spacing={2}>
<Typography>
Silakan isi tanggal pembatalan. Tanggal tidak boleh sebelum tanggal pemotongan.
</Typography>
<DatePicker
label="Tanggal Pembatalan"
format="DD/MM/YYYY"
value={tglPembatalan}
maxDate={dayjs()}
minDate={minPembatalanDate || undefined}
onChange={(newValue) => setTglPembatalan(newValue)}
slotProps={{
textField: {
size: 'medium',
fullWidth: true,
helperText:
minPembatalanDate && `Tanggal minimal: ${minPembatalanDate.format('DD/MM/YYYY')}`,
InputLabelProps: { shrink: true },
sx: {
'& .MuiOutlinedInput-root': {
borderRadius: 1.5,
backgroundColor: '#fff',
'&:hover fieldset': {
borderColor: '#123375 !important',
},
'&.Mui-focused fieldset': {
borderColor: '#123375 !important',
borderWidth: '1px',
},
},
},
},
}}
/>
<Stack direction="row" justifyContent="flex-end" spacing={1} mt={1}>
<Button variant="outlined" onClick={handleCloseModal}>
Batal
</Button>
<Button
variant="contained"
color="error"
onClick={handleSubmit}
disabled={!tglPembatalan}
>
Batalkan
</Button>
</Stack>
</Stack>
</DialogUmum>
{/* ✅ Dialog progress bar */}
<DialogProgressBar
isOpen={isOpenDialogProgressBar}
handleClose={() => {
handleCloseModal();
setIsOpenDialogProgressBar(false);
}}
numberOfData={numberOfData}
numberOfDataProcessed={numberOfDataProcessed}
numberOfDataFail={numberOfDataFail}
numberOfDataSuccess={numberOfDataSuccess}
status={status}
/>
</>
);
};
export default ModalCancelNr;
import React, { useEffect, useState } from 'react';
import { enqueueSnackbar } from 'notistack';
import DialogUmum from 'src/shared/components/dialog/DialogUmum';
import DialogContent from '@mui/material/DialogContent';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
import useCetakPdfDn from '../../hooks/useCetakPdf';
import normalizePayloadCetakPdf from '../../utils/normalizePayloadCetakPdf';
interface ModalCetakPdfDnProps {
payload?: Record<string, any>;
isOpen: boolean;
onClose: () => void;
}
const ModalCetakPdfNr: React.FC<ModalCetakPdfDnProps> = ({ payload, isOpen, onClose }) => {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const { mutateAsync } = useCetakPdfDn({
onError: (error: any) => {
enqueueSnackbar(error?.message || 'Gagal memuat PDF', { variant: 'error' });
setLoading(false);
},
onSuccess: (res: any) => {
const fileUrl = res?.url || res?.data?.url;
if (!fileUrl) {
enqueueSnackbar('URL PDF tidak ditemukan di respons API', { variant: 'warning' });
setLoading(false);
return;
}
setPdfUrl(fileUrl);
setLoading(false);
enqueueSnackbar(res?.MsgStatus || 'PDF berhasil dibentuk', { variant: 'success' });
},
});
useEffect(() => {
const runCetak = async () => {
if (!isOpen || !payload) return;
setLoading(true);
setPdfUrl(null);
try {
// Payload sudah lengkap dari parent (sudah ada namaObjekPajak, pasalPPh, statusPPh)
const normalized = normalizePayloadCetakPdf(payload);
console.log('📤 Payload final cetak PDF:', normalized);
await mutateAsync(normalized);
} catch (err) {
console.error('❌ Error cetak PDF:', err);
enqueueSnackbar('Gagal generate PDF', { variant: 'error' });
setLoading(false);
}
};
runCetak();
}, [isOpen, payload, mutateAsync]);
return (
<DialogUmum
maxWidth="lg"
isOpen={isOpen}
onClose={onClose}
title="Detail Bupot Unifikasi (PDF)"
>
<DialogContent classes={{ root: 'p-16 sm:p-24' }}>
{loading && (
<Box display="flex" justifyContent="center" alignItems="center" height="60vh">
<CircularProgress />
</Box>
)}
{!loading && pdfUrl && (
<iframe
src={pdfUrl}
style={{
width: '100%',
height: '80vh',
border: 'none',
borderRadius: 8,
}}
title="Preview PDF Bupot"
/>
)}
{!loading && !pdfUrl && (
<Box textAlign="center" color="text.secondary" py={4}>
PDF tidak tersedia untuk ditampilkan.
</Box>
)}
</DialogContent>
</DialogUmum>
);
};
export default ModalCetakPdfNr;
import React, { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import DialogProgressBar from 'src/shared/components/dialog/DialogProgressBar';
import useDialogProgressBar from 'src/shared/hooks/useDialogProgressBar';
import DialogConfirm from 'src/shared/components/dialog/DialogConfirm';
import type { GridRowSelectionModel } from '@mui/x-data-grid-premium';
import useDeleteBupot26 from '../../hooks/useDelete';
import queryKey from 'src/sections/bupot-21-26/constant/queryKey';
interface ModalDeleteDnProps {
dataSelected?: GridRowSelectionModel;
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
isOpenDialogDelete: boolean;
setIsOpenDialogDelete: (v: boolean) => void;
successMessage?: string;
onConfirmDelete?: () => Promise<void> | void;
}
/**
* Normalize various possible shapes of grid selection model into array of ids.
* Handles:
* - array of ids: [1, 2, 'a']
* - Set-of-ids: new Set([1,2])
* - object like { ids: Set(...), type: 'include' }
* - fallback: tries to extract keys if it's an object map
*/
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 ModalDelete: React.FC<ModalDeleteDnProps> = ({
dataSelected,
setSelectionModel,
tableApiRef,
isOpenDialogDelete,
setIsOpenDialogDelete,
successMessage = 'Data berhasil dihapus',
onConfirmDelete,
}) => {
const queryClient = useQueryClient();
// custom hooks for progress state
const {
numberOfData,
setNumberOfData,
numberOfDataFail,
numberOfDataProcessed,
numberOfDataSuccess,
processSuccess,
processFail,
resetToDefault,
status,
} = useDialogProgressBar();
const [isOpenDialogProgressBar, setIsOpenDialogProgressBar] = useState(false);
// React Query mutation for delete
const { mutateAsync } = useDeleteBupot26({
onSuccess: () => processSuccess(),
onError: () => processFail(),
});
// fungsi multiple delete -- gunakan normalized array of ids
const handleMultipleDelete = async () => {
const ids = normalizeSelection(dataSelected);
return Promise.allSettled(ids.map(async (id) => mutateAsync({ id: String(id) , feature: '26'})));
};
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 = () => {
setIsOpenDialogDelete(false);
resetToDefault();
};
const onSubmit = async () => {
try {
setIsOpenDialogProgressBar(true);
await handleMultipleDelete();
enqueueSnackbar(successMessage, { variant: 'success' });
await onConfirmDelete?.();
handleCloseModal();
clearSelection();
} catch (error: any) {
enqueueSnackbar(error?.message || 'Gagal menghapus data', { variant: 'error' });
} finally {
// sesuaikan queryKey jika perlu; tetap panggil invalidasi
queryClient.invalidateQueries({ queryKey: queryKey.bupot26.all('') });
}
};
useEffect(() => {
setNumberOfData(normalizeSelection(dataSelected).length);
}, [isOpenDialogDelete, dataSelected, setNumberOfData]);
return (
<>
<DialogConfirm
fullWidth
maxWidth="xs"
title="Apakah Anda yakin akan menghapus data ini?"
description="Data yang sudah dihapus tidak dapat dikembalikan."
actionTitle="Hapus"
isOpen={isOpenDialogDelete}
isLoadingBtnSubmit={false}
handleClose={handleCloseModal}
handleSubmit={onSubmit}
/>
<DialogProgressBar
isOpen={isOpenDialogProgressBar}
handleClose={() => {
handleCloseModal();
setIsOpenDialogProgressBar(false);
}}
numberOfData={numberOfData}
numberOfDataProcessed={numberOfDataProcessed}
numberOfDataFail={numberOfDataFail}
numberOfDataSuccess={numberOfDataSuccess}
status={status}
/>
</>
);
};
export default ModalDelete;
import React, { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import DialogProgressBar from 'src/shared/components/dialog/DialogProgressBar';
import useDialogProgressBar from 'src/shared/hooks/useDialogProgressBar';
import type { GridRowSelectionModel } from '@mui/x-data-grid-premium';
import useUpload from '../../hooks/useUpload';
import DialogUmum from 'src/shared/components/dialog/DialogUmum';
import Stack from '@mui/material/Stack';
import Grid from '@mui/material/Grid';
import { Field } from 'src/components/hook-form';
import MenuItem from '@mui/material/MenuItem';
import { useSelector } from 'react-redux';
import type { RootState } from 'src/store';
import Agreement from 'src/shared/components/agreement/Agreement';
import { FormProvider, useForm } from 'react-hook-form';
import Button from '@mui/material/Button';
interface ModalUploadNrProps {
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;
}
/**
* Normalize various possible shapes of grid selection model into array of ids.
* Handles:
* - array of ids: [1, 2, 'a']
* - Set-of-ids: new Set([1,2])
* - object like { ids: Set(...), type: 'include' }
* - fallback: tries to extract keys if it's an object map
*/
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 ModalUploadNr: React.FC<ModalUploadNrProps> = ({
dataSelected,
setSelectionModel,
tableApiRef,
isOpenDialogUpload,
setIsOpenDialogUpload,
successMessage = 'Data berhasil diupload',
onConfirmUpload,
}) => {
const queryClient = useQueryClient();
const uploadNr = useUpload();
// custom hooks for progress state
const {
numberOfData,
setNumberOfData,
numberOfDataFail,
numberOfDataProcessed,
numberOfDataSuccess,
processSuccess,
processFail,
resetToDefault,
status,
} = useDialogProgressBar();
const [isOpenDialogProgressBar, setIsOpenDialogProgressBar] = useState(false);
const [isCheckedAgreement, setIsCheckedAgreement] = useState<boolean>(false);
const signer = useSelector((state: RootState) => state.user.data.signer);
const { mutateAsync } = useUpload({
onSuccess: () => processSuccess(),
onError: () => processFail(),
});
const methods = useForm({
defaultValues: {
signer: signer || '',
},
});
// fungsi multiple delete -- gunakan normalized array of ids
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' });
// ✅ refetch langsung setelah sukses
await onConfirmUpload?.();
handleCloseModal();
clearSelection();
} catch (error: any) {
enqueueSnackbar(error?.message || 'Gagal upload data', { variant: 'error' });
} finally {
// sesuaikan queryKey jika perlu; tetap panggil invalidasi
queryClient.invalidateQueries({ queryKey: ['unifikasi', 'nr'] });
}
};
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}>
<Button
type="button"
disabled={!isCheckedAgreement}
onClick={onSubmit}
loading={uploadNr.isPending}
variant="contained"
sx={{ background: '#143B88' }}
>
Save
</Button>
</Stack>
</Stack>
</DialogUmum>
</FormProvider>
<DialogProgressBar
isOpen={isOpenDialogProgressBar}
handleClose={() => {
handleCloseModal();
setIsOpenDialogProgressBar(false);
}}
numberOfData={numberOfData}
numberOfDataProcessed={numberOfDataProcessed}
numberOfDataFail={numberOfDataFail}
numberOfDataSuccess={numberOfDataSuccess}
status={status}
/>
</>
);
};
export default ModalUploadNr;
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import MenuItem from '@mui/material/MenuItem';
import dayjs from 'dayjs';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import { useSelector } from 'react-redux';
import { Field } from 'src/components/hook-form';
import type { RootState } from 'src/store';
export const METODE_PEMBAYARAN = [
{
value: 'DIRECT',
label: 'Pembayaran Langsung',
},
{
value: 'IMPREST',
label: 'Uang Persediaan',
},
];
const DokumenReferensi = ({
namaDokOptions,
}: {
namaDokOptions: {
value: string;
label: string;
}[];
}) => {
const { watch, setValue } = useFormContext<Record<string, any>>();
const nitku = useSelector((state: RootState) => state.user.data.nitku_trial);
const nitkuValue = watch('idTku');
const isNmrSP2D = watch('metodePembayaranBendahara') === 'IMPREST';
const npwpLog = localStorage.getItem('npwp_log') || '';
const isSp2d = npwpLog.slice(0, 4) === '0001';
useEffect(() => {
if (!nitkuValue && nitku) {
setValue('idTku', nitku);
}
}, [nitku, nitkuValue, setValue]);
return (
<Grid sx={{ mb: 3 }} container rowSpacing={2} columnSpacing={2}>
<Grid sx={{ mt: 3 }} size={{ md: 12 }}>
<Divider sx={{ fontWeight: 'bold' }} textAlign="left">
Daftar Dokumen
</Divider>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Autocomplete name="namaDok" label="Nama Dokumen" options={namaDokOptions} />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="nomorDok" label="Nomor Dokumen" />
</Grid>
{isSp2d && (
<>
<Grid size={{ md: 6 }}>
<Field.Autocomplete
name="metodePembayaranBendahara"
label="Metode Pembayaran SP2D"
options={METODE_PEMBAYARAN}
disableClearable={false}
onChange={(_, val) => {
setValue('metodePembayaranBendahara', val);
setValue('nomorSP2D', '');
}}
/>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="nomorSP2D" label="Nomor SP2D" disabled={isNmrSP2D} />
</Grid>
</>
)}
<Grid size={{ md: 6 }}>
<Field.DatePicker
name="tglDok"
label="Tanggal Dokumen"
format="DD/MM/YYYY"
maxDate={dayjs()}
minDate={dayjs('2025-01-01')}
/>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Select name="idTku" label="NITKU Pemotong">
<MenuItem value={nitku}>{nitku}</MenuItem>
</Field.Select>
</Grid>
</Grid>
);
};
export default DokumenReferensi;
import Grid from '@mui/material/Grid';
import dayjs from 'dayjs';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import type { TCountryResult } from '../../types/types';
type IdentitasProps = {
isPengganti: boolean;
existingNr?: any; // Data penuh dari API (opsional, untuk edit/pengganti)
country: TCountryResult;
};
const Identitas = ({ isPengganti, existingNr, country }: IdentitasProps) => {
const { setValue, watch } = useFormContext();
const tanggalPemotongan = watch('tglPemotongan');
// 🧩 Auto isi Tahun & Masa Pajak berdasarkan tanggalPemotongan
useEffect(() => {
if (tanggalPemotongan) {
const date = dayjs(tanggalPemotongan);
setValue('tglDok', tanggalPemotongan);
setValue('thnPajak', date.format('YYYY'));
setValue('masaPajak', date.format('MM'));
} else {
setValue('tglDok', '');
setValue('thnPajak', '');
setValue('masaPajak', '');
}
}, [tanggalPemotongan, setValue]);
return (
<>
{/* 📋 Identitas Dasar */}
<Grid container rowSpacing={2} alignItems="center" columnSpacing={2} sx={{ mb: 4 }}>
{/* 📅 Tanggal & Masa Pajak */}
<Grid size={{ md: 6 }}>
<Field.DatePicker
name="tglPemotongan"
label="Tanggal Pemotongan"
format="DD/MM/YYYY"
maxDate={dayjs()}
disabled={isPengganti}
/>
</Grid>
<Grid size={{ md: 3 }}>
<Field.DatePicker
name="thnPajak"
label="Tahun Pajak"
view="year"
format="YYYY"
disabled
/>
</Grid>
<Grid size={{ md: 3 }}>
<Field.DatePicker
name="masaPajak"
label="Masa Pajak"
views={['month']} // ✅ valid prop
openTo="month"
format="MM"
disabled
/>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="idDipotong" label="Tax ID Number (TIN)" disabled={isPengganti} />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="namaDipotong" label="Nama" disabled={isPengganti} />
</Grid>
<Grid size={{ md: 12 }}>
<Field.Text
name="alamatDipotong"
label="Alamat"
multiline
minRows={2}
disabled={isPengganti}
sx={{
'& .MuiInputBase-inputMultiline': {
lineHeight: 1.6,
},
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#bdbdbd',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#1976d2',
borderWidth: '2px',
},
}}
/>
</Grid>
<Grid size={{ md: 12 }}>
<Field.Autocomplete
name="negaraDipotong"
label="Negara"
options={country}
/>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="tmptLahirDipotong" label="Tempat Lahir" disabled={isPengganti} />
</Grid>
<Grid size={{ md: 6 }}>
<Field.DatePicker
name="tglLahirDipotong"
label="Tanggal Lahir"
format="DD/MM/YYYY"
maxDate={dayjs()}
disabled={isPengganti}
/>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="nomorPaspor" label="No. Paspor" disabled={isPengganti} />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="nomorKitasKitap" label="No.KITAS/KITAP" disabled={isPengganti} />
</Grid>
</Grid>
</>
);
};
export default Identitas;
import type { FC } from 'react';
import { Fragment, memo } from 'react';
import { Box, Button, Card, CardContent, CardHeader, IconButton, Typography } from '@mui/material';
import { ChevronRightRounded, CloseRounded } from '@mui/icons-material';
import { m } from 'framer-motion';
import { PANDUAN_REKAM_NR } from '../../constant';
interface PanduanDnRekamProps {
handleOpen: () => void;
isOpen: boolean;
}
const PanduanDnRekam: FC<PanduanDnRekamProps> = ({ handleOpen, isOpen }) => (
<Box position="sticky">
{/* Tombol toggle */}
{!isOpen && (
<Box height="100%" display="flex" justifyContent="center" alignItems="center">
<Button
variant="contained"
sx={{
height: 'fit-content',
right: 0,
borderRadius: 0,
minWidth: 35,
pt: 3,
pb: 3,
fontWeight: 'bold',
fontSize: 16,
backgroundColor: '#143B88',
}}
size="small"
onClick={handleOpen}
>
<span
style={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)',
display: 'flex',
alignItems: 'center',
}}
>
Panduan Penggunaan
<ChevronRightRounded sx={{ fontSize: 30 }} />
</span>
</Button>
</Box>
)}
{/* Konten panduan */}
{isOpen && (
<m.div
initial={{ x: 20, opacity: 0 }}
animate={{ x: 0, opacity: 1, transition: { delay: 0.2 } }}
>
<Card>
<CardHeader
avatar={
<img src="/assets/icon_panduan_penggunaan_1.svg" alt="Panduan" loading="lazy" />
}
sx={{
backgroundColor: '#123375',
color: '#FFFFFF',
p: 2,
'& .MuiCardHeader-title': { fontSize: 18 },
}}
action={
<IconButton aria-label="close" onClick={handleOpen} sx={{ color: 'white' }}>
<CloseRounded />
</IconButton>
}
title="Panduan Penggunaan"
/>
<CardContent
sx={{
maxHeight: 300,
overflow: 'auto',
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-track': {
backgroundColor: '#f0f0f0',
borderRadius: 8,
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: '#123375',
borderRadius: 8,
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: '#0d2858',
},
scrollbarWidth: 'thin',
scrollbarColor: '#123375 #f0f0f0',
}}
>
{/* Deskripsi Form */}
<Typography variant="body2" sx={{ mb: 2, whiteSpace: 'pre-line' }}>
<strong>Deskripsi Form:</strong>
<br />
{PANDUAN_REKAM_NR.description.intro}
</Typography>
<Typography variant="body2">{PANDUAN_REKAM_NR.description.textList}</Typography>
<Box component="ol" sx={{ pl: 3, mb: 2 }}>
{PANDUAN_REKAM_NR.description.list.map((item, idx) => (
<Typography key={`desc-${idx}`} variant="body2" component="li">
{item}
</Typography>
))}
</Box>
<Typography variant="body2" sx={{ mb: 2 }}>
{PANDUAN_REKAM_NR.description.closing}
</Typography>
{/* Bagian-bagian */}
{PANDUAN_REKAM_NR.sections.map((section, i) => (
<Box key={`section-${i}`} sx={{ mb: 2 }}>
<Typography
variant="body2"
sx={{ fontWeight: 'bold', fontSize: '0.95rem', mb: 0.5 }}
>
{section.title}
</Typography>
<Box component="ul" sx={{ pl: 2, listStyle: 'disc' }}>
{section.items.map((item, idx) => (
<Fragment key={`item-${i}-${idx}`}>
<Box component="li" sx={{ mb: 0.5 }}>
<Typography variant="body2" component="span">
{item.text}
</Typography>
{item.subItems?.length > 0 && (
<Box component="ol" sx={{ pl: 3, listStyle: 'decimal' }}>
{item.subItems.map((sub, subIdx) => (
<Typography
key={`sub-${i}-${idx}-${subIdx}`}
variant="body2"
component="li"
>
{sub}
</Typography>
))}
</Box>
)}
</Box>
</Fragment>
))}
</Box>
</Box>
))}
</CardContent>
</Card>
</m.div>
)}
</Box>
);
export default memo(PanduanDnRekam);
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import lodash from 'lodash';
import { useEffect } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import { RHFNumeric } from 'src/components/hook-form/rhf-numeric';
import { FG_FASILITAS_DN, FG_FASILITAS_OPTIONS, TARIF_0 } from '../../constant';
type PPHDipotongProps = {
kodeObjectPajak: any[];
fgFasilitasOptions: {
value: string;
label: string;
}[];
};
function roundCustom(num: number) {
const decimalPart = num - Math.floor(num);
if (decimalPart <= 0.5) return Math.trunc(num); // Bulatkan ke bawah
return Math.round(num); // Bulatkan ke bilangan bulat terdeka
}
const PphDipotong = ({ kodeObjectPajak, fgFasilitasOptions }: PPHDipotongProps) => {
const { setValue, control } = useFormContext<Record<string, any>>();
const perhitunganChanges = useWatch({
control,
name: ['penghasilanBruto', 'tarif', 'fgFasilitas', 'normaPenghasilanNeto'],
});
const fgFasilitas = perhitunganChanges[2].value;
useEffect(() => {
if (perhitunganChanges.filter((item) => lodash.isEmpty(item)).length === 0) {
const dpp = Number(perhitunganChanges[0] || 0);
const tarif = Number(perhitunganChanges[1] || 0);
const fasilitas = perhitunganChanges[2].value;
const normaPenghasilanNeto = Number(perhitunganChanges[3] || 0);
const valPphDipotong = dpp * (normaPenghasilanNeto / 100) * (tarif / 100);
const perhitungan = TARIF_0.includes(fasilitas) ? 0 : valPphDipotong;
setValue('pphDipotong', `${roundCustom(perhitungan)}`, {
shouldValidate: true,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [perhitunganChanges]);
return (
<Grid container rowSpacing={2} columnSpacing={2}>
<Grid sx={{ mt: 3 }} size={{ md: 6 }}>
<Field.Autocomplete
name="kodeObjekPajak"
label="Kode Objek Pajak"
options={kodeObjectPajak}
/>
</Grid>
<Grid size={{ md: 12 }}>
<Divider sx={{ fontWeight: 'bold' }} textAlign="left">
Fasilitas Pajak Penghasilan
</Divider>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Autocomplete
name="fgFasilitas"
label="Fasilitas"
options={fgFasilitasOptions}
onChange={(_, val) => {
setValue('fgFasilitas', val);
if (val.value !== FG_FASILITAS_OPTIONS.FASILITAS_LAINNYA)
setValue('normaPenghasilanNeto', '100');
if (val.value === FG_FASILITAS_OPTIONS.TANPA_FASILITAS) setValue('noDokLainnya', '');
}}
/>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text
name="noDokLainnya"
label="Nomor Dokumen Lainnya"
disabled={['9', ''].includes(fgFasilitas)}
sx={{ '& .MuiInputBase-root.Mui-disabled': { backgroundColor: '#f6f6f6' } }}
/>
</Grid>
<Grid size={{ md: 6 }}>
<RHFNumeric
name="penghasilanBruto"
label="Jumlah Penghasilan Bruto (Rp)"
allowNegativeValue={false}
allowDecimalValue={false}
/>
</Grid>
<Grid size={{ md: 6 }}>
<RHFNumeric
name="normaPenghasilanNeto"
label="Perkiraan Penghasilan Netto (%)"
allowNegativeValue={false}
allowDecimalValue={false}
maxValue={100}
readOnly={fgFasilitas !== FG_FASILITAS_OPTIONS.FASILITAS_LAINNYA}
/>
</Grid>
<Grid size={{ md: 6 }}>
<RHFNumeric
name="tarif"
label="Tarif (%)"
allowDecimalValue
maxValue={100}
readOnly={
![FG_FASILITAS_DN.FASILITAS_LAINNYA, FG_FASILITAS_DN.SKD_WPLN].includes(fgFasilitas)
}
disabled={
![FG_FASILITAS_DN.FASILITAS_LAINNYA, FG_FASILITAS_DN.SKD_WPLN].includes(fgFasilitas)
}
/>
</Grid>
<Grid size={{ md: 6 }}>
<RHFNumeric
name="pphDipotong"
label="PPh Yang Dipotong/Dipungut"
allowNegativeValue={false}
allowDecimalValue={false}
readOnly
/>
</Grid>
</Grid>
);
};
export default PphDipotong;
import React from 'react';
import { IconButton, Tooltip } from '@mui/material';
import HighlightOffTwoToneIcon from '@mui/icons-material/HighlightOffTwoTone';
interface ToolbarCancelProps {
selectedRows: any[];
selectedRowsData: any[];
onCancel: (ids: number[]) => void;
}
const ToolbarCancel: React.FC<ToolbarCancelProps> = ({
selectedRows,
selectedRowsData,
onCancel,
}) => {
// Logic sederhana
const isEnabled =
selectedRows.length > 0 &&
selectedRowsData.every((row: any) => row.fgStatus === 'normal' || row.fgStatus === 'amendment');
const handleClick = () => {
if (!isEnabled) return;
const ids = selectedRowsData.map((row: any) => row.id).filter((id: any) => id !== undefined);
onCancel(ids);
};
return (
<Tooltip title={isEnabled ? `Batalkan ${selectedRows.length} data` : 'Pilih data yang valid'}>
<IconButton
onClick={handleClick}
disabled={!isEnabled}
color={isEnabled ? 'error' : 'default'}
>
<HighlightOffTwoToneIcon />
</IconButton>
</Tooltip>
);
};
export default ToolbarCancel;
This diff is collapsed.
type FilterItem = {
field: string;
operator: string;
value?: string | number | Array<string | number> | null;
join?: 'AND' | 'OR'; // optional: join connector BEFORE this item (first item usually undefined)
};
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);
}
}
// combine expressions
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;
}
function buildRequestParams(base: BaseParams = {}, advanced: string) {
const out: BaseParams = { ...(base ?? {}) };
if ('noBupot' in out) {
out.nomorBupot = out.noBupot;
delete out.noBupot;
}
out.advanced = advanced || '';
return out;
}
return { buildAdvancedFilter, buildRequestParams } as const;
}
export default useAdvancedFilter;
import { useMutation } from '@tanstack/react-query';
import type { TCancelNrRequest, TCancelNrResponse } from '../types/types';
import nrApi from '../utils/api';
import queryKey from '../../constant/queryKey';
const useCancelNr = (props?: any) =>
useMutation<TCancelNrResponse, Error, TCancelNrRequest>({
mutationKey:queryKey.bupot26.cancel,
mutationFn: (payload) => nrApi.cancel(payload),
...props,
});
export default useCancelNr;
import { useMutation } from '@tanstack/react-query';
import bupot26 from '../utils/api';
import queryKey from '../../constant/queryKey';
const useCetakPdf = (options?: any) =>
useMutation({
mutationKey: queryKey.bupot26.cetakPdf(options),
mutationFn: async (params: any) => bupot26.cetakPdfDetail(params),
...options,
});
export default useCetakPdf;
import { useMutation } from '@tanstack/react-query';
import type { TBaseResponseAPI, TDeleteNrRequest } from '../types/types';
import bupot26 from '../utils/api';
import queryKey from '../../constant/queryKey';
const useDelete = (props?: any) =>
useMutation<TBaseResponseAPI<null>, Error, TDeleteNrRequest>({
mutationKey: queryKey.bupot26.delete,
mutationFn: (payload) => bupot26.delete(payload),
...props,
});
export default useDelete;
import { isEmpty } from 'lodash';
import { useQuery } from '@tanstack/react-query';
import type {
// TGetListDataTableDnResult,
TGetListDataTableNr,
TGetListDataTableNrResult,
} from '../types/types';
import { FG_PDF_STATUS, FG_SIGN_STATUS } from '../constant';
import nrApi from '../utils/api';
import dayjs from 'dayjs';
import queryKey from '../../constant/queryKey';
export type TGetDnApiWrapped = {
data: TGetListDataTableNrResult[];
total: number;
pageSize: number;
page: number; // 1-based
};
// ---------- helpers (unchanged, kept for completeness) ----------
export const transformFgStatusToFgSignStatus = (fgStatus: any) => {
const splittedFgStatus = fgStatus?.split('-') || [];
if (splittedFgStatus.includes('SIGN') > 0) return FG_SIGN_STATUS.FAILED;
if (splittedFgStatus.includes('SIGNING IN PROGRESS')) return FG_SIGN_STATUS.IN_PROGRESS;
if (fgStatus === 'DUPLICATE') return FG_SIGN_STATUS.DUPLICATE;
if (fgStatus === 'NOT_MATCH_STATUS') return FG_SIGN_STATUS.NOT_MATCH_STATUS;
if (fgStatus === 'NOT_MATCH_NILAI') return FG_SIGN_STATUS.NOT_MATCH_NILAI;
if (fgStatus === 'NOT_MATCH_IDBUPOT') return FG_SIGN_STATUS.NOT_MATCH_IDBUPOT;
switch (splittedFgStatus[1]) {
case 'document signed successfully':
case 'Done':
return FG_SIGN_STATUS.SIGNED;
case 'SIGNING_IN_PROGRESS':
return FG_SIGN_STATUS.IN_PROGRESS;
case 'DUPLICATE':
return FG_SIGN_STATUS.DUPLICATE;
case 'NOT_MATCH_STATUS':
return FG_SIGN_STATUS.NOT_MATCH_STATUS;
case 'NOT_MATCH_IDBUPOT':
return FG_SIGN_STATUS.NOT_MATCH_IDBUPOT;
default:
return null;
}
};
export const getFgStatusPdf = (link: any, fgSignStatus: any) => {
if (!link || [FG_SIGN_STATUS.IN_PROGRESS].includes(fgSignStatus))
return FG_PDF_STATUS.TIDAK_TERSEDIA;
if (!link.includes('https://coretaxdjp.pajak.go.id/')) return FG_PDF_STATUS.BELUM_TERBENTUK;
return FG_PDF_STATUS.TERBENTUK;
};
export const transformSortModelToSortApiPayload = (transformedModel: any) => ({
sortingMode: transformedModel.map((item: any) => item.field).join(','),
sortingMethod: transformedModel.length > 0 ? transformedModel[0].sort : 'desc',
});
export const formatDateToDDMMYYYY = (dateString: string | null | undefined) => {
if (!dateString) return '';
const date = new Date(dateString);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
const normalisePropsGetNr = (params: TGetListDataTableNr) => ({
...params,
nomorSP2D: params.dokumen_referensi?.[0]?.nomorSP2D || '',
metodePembayaranBendahara: params.dokumen_referensi?.[0]?.metodePembayaranBendahara || '',
dokReferensi: params.dokumen_referensi?.[0]?.dokReferensi || '',
nomorDokumen: params.dokumen_referensi?.[0]?.nomorDokumen || '',
id: params.id,
npwpPemotong: params.npwpPemotong,
idBupot: params.idBupot,
internal_id: params.internal_id,
fgStatus: params.fgStatus,
fgSignStatus: transformFgStatusToFgSignStatus(params.fgStatus),
fgPdf: getFgStatusPdf(params.link, transformFgStatusToFgSignStatus(params.fgStatus)),
// fgLapor: params.fgLapor,
revNo: params.revNo,
thnPajak: params.tahunPajak,
msPajak: params.masaPajak,
kdObjPjk: params.kodeObjekPajak,
noBupot: params.noBupot,
idDipotong: params.userId,
glAccount: params.glAccount,
namaDipotong: params.nama,
jmlBruto: params.dpp,
pphDipotong: params.pphDipotong,
created: params.created_by,
fgKirimEmail: params.fgkirimemail,
created_at: formatDateToDDMMYYYY(params.created_at),
updated: params.updated_by,
updated_at: formatDateToDDMMYYYY(params.updated_at),
});
export const normalizeExistingNr = (res: any) => ({
// 🧾 Data Pajak Utama
tglPemotongan: res.tglpemotongan ?? '',
thnPajak: res.tahunPajak ?? '',
msPajak: res.masaPajak ?? '',
// 👤 Identitas Dipotong
idDipotong: res.npwpPemotong ?? '',
namaDipotong: res.namaDipotong ?? '',
alamatDipotong: res.alamatDipotong ?? '',
negaraDipotong: res.negaraDipotong ?? '',
tmptLahirDipotong: res.tmptLahirDipotong ?? '',
tglLahirDipotong:
res.tglLahirDipotong && res.tglLahirDipotong.length === 8
? dayjs(res.tglLahirDipotong, 'DDMMYYYY').format('YYYY-MM-DD')
: '',
nomorPaspor: res.nomorPaspor ?? '',
nomorKitasKitap: res.nomorKitasKitap ?? '',
// 🧠 Informasi Tambahan
email: res.email ?? '',
keterangan1: res.keterangan1 ?? '',
keterangan2: res.keterangan2 ?? '',
keterangan3: res.keterangan3 ?? '',
keterangan4: res.keterangan4 ?? '',
keterangan5: res.keterangan5 ?? '',
// 💰 Pajak dan Penghasilan
kodeObjekPajak: res.kodeObjekPajak ?? '',
fgFasilitas: res.sertifikatInsentifDipotong ?? '',
noDokLainnya: res.nomorSertifikatInsentif ?? '',
penghasilanBruto: res.penghasilanBruto ?? '',
normaPenghasilanNeto: res.normaPenghasilanNeto ?? '',
tarif: String(res.tarif ?? ''),
pphDipotong: String(res.pphDipotong ?? ''),
// 📄 Dokumen Referensi
namaDok: res.dokumen_referensi?.[0]?.dokReferensi ?? '',
nomorDok: res.dokumen_referensi?.[0]?.nomorDokumen ?? '',
tglDok: res.dokumen_referensi?.[0]?.tanggal_Dokumen ?? '',
// 🏢 Cabang / Unit
idTku: res.idTku ?? '',
// 🆔 Metadata tambahan
idBupot: res.idBupot ?? '',
noBupot: res.noBupot ?? '',
revNo: res.revNo ?? 0,
});
const normalizeParams = (params: any) => {
const {
page = 0,
pageSize = params.limit ?? 10,
sort,
filter,
advanced,
sortingMode: sortingModeParam,
sortingMethod: sortingMethodParam,
...rest
} = params;
let sortPayload: any;
let sortingMode = sortingModeParam || '';
let sortingMethod = sortingMethodParam || '';
if (sort) {
try {
const parsed = JSON.parse(sort);
if (Array.isArray(parsed) && parsed.length > 0) {
sortPayload = parsed;
sortingMode = parsed[0]?.field ?? sortingMode;
sortingMethod = parsed[0]?.sort ?? sortingMethod;
}
} catch {
sortPayload = [];
}
}
return {
page: page + 1,
limit: pageSize,
advanced:
typeof advanced === 'string' && advanced.trim() !== ''
? advanced
: filter && !isEmpty(JSON.parse(filter))
? filter
: undefined,
...(sortPayload ? { sort: sortPayload } : {}),
sortingMode,
sortingMethod,
...rest,
feature: '26'
};
};
export const useGetBupot26 = ({ params }: { params: any }) => {
const normalized = normalizeParams(params);
return useQuery<TGetDnApiWrapped>({
queryKey: queryKey.bupot26.all(normalized),
queryFn: async () => {
const res: any = await nrApi.get({ params: normalized });
const rawData: any[] = Array.isArray(res?.data) ? res.data : res?.data ? [res.data] : [];
const total = Number(res?.total ?? res?.totalRow ?? 0);
let dataArray: TGetListDataTableNrResult[] = [];
const normalizeWithWorker = () =>
new Promise<TGetListDataTableNrResult[]>((resolve, reject) => {
try {
const worker = new Worker(
new URL('../workers/normalizeNr.worker.js', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e) => {
const { data, error } = e.data;
if (error) {
worker.terminate();
reject(new Error(error));
} else {
worker.terminate();
resolve(data as TGetListDataTableNrResult[]);
}
};
worker.onerror = (err) => {
worker.terminate();
reject(err);
};
worker.postMessage(rawData);
} catch (err) {
reject(err);
}
});
try {
if (typeof Worker !== 'undefined') {
dataArray = await normalizeWithWorker();
} else {
console.warn('⚠️ Worker not supported, using sync normalization');
dataArray = rawData.map(normalisePropsGetNr) as unknown as TGetListDataTableNrResult[];
}
} catch (err) {
console.error('❌ Worker failed, fallback to sync normalize:', err);
dataArray = rawData.map(normalisePropsGetNr) as unknown as TGetListDataTableNrResult[];
}
return {
data: dataArray,
total,
pageSize: normalized.limit,
page: normalized.page,
};
},
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
refetchOnMount: false,
staleTime: 0,
gcTime: 0,
retry: false,
});
};
export const useGetBupot26ById = (id: string, options = {}) =>
useQuery({
queryKey: queryKey.bupot26.detail(id),
queryFn: async () => {
const res = await nrApi.getById(id);
console.log(res);
if (!res) throw new Error('Data tidak ditemukan');
const normalized = normalizeExistingNr(res);
console.log('✅ Normalized data:', normalized);
return normalized;
},
enabled: !!id,
refetchOnWindowFocus: false,
...options,
});
export default useGetBupot26;
/* eslint-disable @typescript-eslint/no-shadow */
import { useEffect } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
const usePphDipotong = (kodeObjekPajakSelected?: any) => {
const { watch, setValue, control } = useFormContext();
// ambil value dari form
const fgFasilitas = watch('fgFasilitas');
const fgIdDipotong = watch('fgIdDipotong');
// mapping statusPPh ke isFinal
const isFinal = kodeObjekPajakSelected?.statuspph?.toLowerCase() === 'final' ? 1 : 0;
const updateTarifValues = () => {
if (kodeObjekPajakSelected) {
let valueTarif = Number(kodeObjekPajakSelected.tarif) || 0;
if (fgFasilitas === '6') {
valueTarif = 0.5;
} else if (fgFasilitas === '8') {
valueTarif = 0;
}
setValue('tarif', valueTarif, { shouldValidate: true });
setValue('tarifLt', fgIdDipotong === '1' && isFinal === 0 ? '100' : '0', {
shouldValidate: true,
});
}
};
// watch field yang mempengaruhi perhitungan
const handlerSetPphDipotong = useWatch({
control,
name: ['thnPajak', 'fgFasilitas', 'fgIdDipotong', 'jmlBruto', 'tarif'],
});
const calculateAndSetPphDipotong = (
thnPajak: number,
fgFasilitas: string,
fgIdDipotong: string,
jmlBruto: number,
tarif: number
) => {
if (kodeObjekPajakSelected) {
const valTarif = thnPajak < 2024 && fgIdDipotong === '1' && isFinal === 0 ? tarif * 2 : tarif;
const valPphDipotong =
fgFasilitas === '8' // contoh: fasilitas tertentu PPh 0
? 0
: (jmlBruto * valTarif) / 100;
setValue('pphDipotong', Math.round(valPphDipotong || 0), {
shouldValidate: true,
});
}
};
useEffect(() => {
if (handlerSetPphDipotong.filter((item) => !item).length < 2) {
calculateAndSetPphDipotong(
Number(handlerSetPphDipotong[0]),
handlerSetPphDipotong[1] as string,
handlerSetPphDipotong[2] as string,
Number(handlerSetPphDipotong[3]),
Number(handlerSetPphDipotong[4])
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handlerSetPphDipotong]);
return {
updateTarifValues,
};
};
export default usePphDipotong;
import { useMutation } from '@tanstack/react-query';
import dayjs from 'dayjs';
import type { TPostBupot26Request } from '../types/types';
import nrApi from '../utils/api';
import queryKey from '../../constant/queryKey';
const transformParams = ({ isPengganti = false, ...Data }: any): TPostBupot26Request => {
const {
id,
idBupot,
noBupot,
npwpPemotong,
idTku,
masaPajak,
tahunPajak,
tinDipotong,
namaDipotong,
alamatDipotong,
negaraDipotong,
tglLahirDipotong,
tmptLahirDipotong,
nomorPaspor,
nomorKitasKitap,
fgFasilitas,
noDokLainnya,
kodeObjekPajak,
pasalPph,
statusPph,
penghasilanBruto,
normaPenghasilanNeto,
tarif,
pphDipotong,
kap,
kjs,
metodePembayaranBendahara,
nomorSP2D,
tglPemotongan,
userId,
kanal,
revNo: initialRevNo,
} = Data;
// Increment revNo kalau pengganti
const revNo = isPengganti
? parseInt(initialRevNo?.toString() || '0', 10) + 1
: parseInt(initialRevNo?.toString() || '0', 10);
// Ambil NPWP dari localStorage kalau mau fallback
const npwpLog = localStorage.getItem('npwp_log') ?? '';
return {
id: !isPengganti ? (id ?? null) : null,
idBupot: idBupot ?? null,
noBupot: noBupot ?? null,
// Header-level Identitas
npwpPemotong: npwpPemotong ?? npwpLog,
idTku: idTku ?? '',
masaPajak: masaPajak ? dayjs(masaPajak).format('MM') : '',
tahunPajak: tahunPajak ? Number(dayjs(tahunPajak).format('YYYY')) : new Date().getFullYear(),
// Data Wajib Pajak Dipotong
tinDipotong: tinDipotong ?? '',
namaDipotong: namaDipotong ?? '',
alamatDipotong: alamatDipotong ?? '',
negaraDipotong: negaraDipotong.value ?? '',
tglLahirDipotong: tglLahirDipotong ? dayjs(tglLahirDipotong).format('DDMMYYYY') : '',
tmptLahirDipotong: tmptLahirDipotong ?? '',
nomorPaspor: nomorPaspor ?? '',
nomorKitasKitap: nomorKitasKitap ?? '',
// Fasilitas
sertifikatInsentifDipotong: fgFasilitas.value ?? '9',
nomorSertifikatInsentif: noDokLainnya ?? '',
// Objek Pajak
kodeObjekPajak: kodeObjekPajak.value ?? '',
pasalPph: pasalPph ?? '',
statusPph: statusPph ?? '',
penghasilanBruto: Number(penghasilanBruto ?? 0),
normaPenghasilanNeto: Number(normaPenghasilanNeto ?? 0),
tarif: Number(tarif ?? 0),
pphDipotong: Number(pphDipotong ?? 0),
kap: Number(kap ?? 0),
kjs: Number(kjs ?? 0),
dokReferensi: (() => {
const { namaDok, nomorDok, tglDok } = Data;
// pastikan tidak undefined dan tanggal valid
if (!namaDok || !nomorDok || !tglDok) return [];
const parsedDate = dayjs(tglDok);
const tanggalFormatted = parsedDate.isValid() ? parsedDate.format('DDMMYYYY') : '';
if (!tanggalFormatted) return [];
return [
{
dokReferensi: namaDok.value,
nomorDokumen: nomorDok,
tanggal_Dokumen: tanggalFormatted,
},
];
})(),
metodePembayaranBendahara: metodePembayaranBendahara ?? '',
nomorSP2D: nomorSP2D ?? '',
tglPemotongan: tglPemotongan ? dayjs(tglPemotongan).format('DDMMYYYY') : '',
userId: userId ?? '',
kanal: kanal ?? '',
revNo,
feature: '26'
};
};
const useSave = (props?: any) =>
useMutation({
mutationKey: queryKey.bupot26.draft,
mutationFn: (params: any) => nrApi.save(transformParams(params)),
...props,
});
export default useSave;
// hooks/useUpload.ts
import { useMutation } from '@tanstack/react-query';
import nrApi from '../utils/api';
import queryKey from '../../constant/queryKey';
const useUpload = (props?: any) =>
useMutation({
mutationKey: queryKey.bupot26.upload,
mutationFn: (payload: { id: string | number }) => nrApi.upload(payload),
...props,
});
export default useUpload;
import { create } from 'zustand';
console.log('✅ pagination store created');
type TableKey = string;
interface TablePagination {
page: number;
pageSize: number;
}
interface TableFilter {
items: any[];
}
interface PaginationState {
tables: Record<TableKey, TablePagination>;
filters: Record<TableKey, TableFilter>;
setPagination: (table: TableKey, next: Partial<TablePagination>) => void;
resetPagination: (table: TableKey) => void;
setFilter: (table: TableKey, next: Partial<TableFilter>) => void;
resetFilter: (table: TableKey) => void;
}
export const usePaginationStore = create<PaginationState>((set) => ({
tables: {},
filters: {},
setPagination: (table, next) =>
set((state) => {
const prev = state.tables[table] ?? { page: 0, pageSize: 10 };
return {
tables: {
...state.tables,
[table]: {
page: next.page ?? prev.page,
pageSize: next.pageSize ?? prev.pageSize,
},
},
};
}),
resetPagination: (table) =>
set((state) => ({
tables: {
...state.tables,
[table]: { page: 0, pageSize: state.tables[table]?.pageSize ?? 10 },
},
})),
setFilter: (table, next) =>
set((state) => ({
filters: {
...state.filters,
[table]: {
items: next.items ?? state.filters[table]?.items ?? [],
},
},
})),
resetFilter: (table) =>
set((state) => ({
filters: {
...state.filters,
[table]: { items: [] },
},
})),
}));
export type TBaseResponseAPI<T> = {
status: string;
message: string;
data: T;
time: string;
code: number;
metaPage: TBaseResponseMetaPage;
total?: number;
};
type TBaseResponseMetaPage = {
pageNum: number | null;
rowPerPage: number | null;
totalRow: number;
};
export type TGetListDataTableNr = {
id: number;
npwpPemotong: string;
idTku: string;
masaPajak: string;
tahunPajak: string;
fgNpwpNik: string;
npwp: string;
nik: string;
nama: string;
sertifikatInsentifDipotong: string;
nomorSertifikatInsentif: string;
kodeObjekPajak: string;
pasalPPh: string;
statusPPh: string;
dpp: string;
tarif: string;
pphDipotong: string;
kap: string;
kjs: string;
tglpemotongan: string;
userId: string;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string;
fgStatus: string;
internal_id: string;
dokumen_referensi: {
dokReferensi: string;
nomorDokumen: string;
tanggal_Dokumen: string;
metodePembayaranBendahara: string;
nomorSP2D: string;
}[];
revNo: number;
noBupot: string;
idBupot: string;
npwpNikPenandatangan: string;
namaPenandatangan: string;
link: string | null;
errorMsg: string | null;
email: string | null;
glAccount: string;
fgkirimemail: string;
glName: string | null;
keterangan1: string | null;
keterangan2: string | null;
keterangan3: string | null;
keterangan4: string | null;
keterangan5: string | null;
};
export type TGetListDataTableNrResult = TGetListDataTableNr[];
export type ActionItem = {
title: string;
icon: React.ReactNode;
func?: () => void;
disabled?: boolean;
};
export type TDokReferensi = {
dokReferensi: string;
nomorDokumen: string;
tanggal_Dokumen: string; // format: DDMMYYYY
};
export type TPostBupot26Request = {
id: string | null;
idBupot: string;
noBupot: string;
npwpPemotong: string;
idTku: string;
masaPajak: string;
tahunPajak: number;
tinDipotong: string;
namaDipotong: string;
alamatDipotong: string;
negaraDipotong: string;
tglLahirDipotong: string;
tmptLahirDipotong: string;
nomorPaspor: string;
nomorKitasKitap: string;
sertifikatInsentifDipotong: string;
nomorSertifikatInsentif: string;
kodeObjekPajak: string;
pasalPph: string;
statusPph: string;
penghasilanBruto: number;
normaPenghasilanNeto: number;
tarif: number;
pphDipotong: number;
kap: number;
kjs: number;
dokReferensi: TDokReferensi[];
metodePembayaranBendahara: string;
nomorSP2D: string;
tglPemotongan: string;
userId: string;
kanal: string;
revNo: number;
feature: '26'
};
export type TCountry = {
kode: string;
nama: string;
};
export type TCountryResult = TCountry[];
export type TPostUpload = {
id: string;
feature: '26'
};
export type TDeleteNrRequest = {
id: string;
feature: '26'
};
export type TCancelNrRequest = {
id: string | number;
tglPembatalan: string; // format: DDMMYYYY
feature: '26'
};
export type TCancelNrResponse = TBaseResponseAPI<{
id: string | number;
feature: '26'
statusBatal?: string;
message?: string;
}>;
import axios from 'axios';
import type {
TBaseResponseAPI,
TCancelNrRequest,
TCancelNrResponse,
TDeleteNrRequest,
TGetListDataTableNrResult,
TPostBupot26Request,
} from '../types/types';
import unifikasiClient from './unifikasiClient';
const bupot21 = () => {};
const axiosCetakPdf = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API_URL_CETAK,
headers: {
Authorization: `Basic ${window.btoa('admin:ortax123')}`,
password: '',
},
});
// API untuk get list table
bupot21.get = async (config: any) => {
const {
data: { message, metaPage, data },
status: statusCode,
} = await unifikasiClient.get<TBaseResponseAPI<TGetListDataTableNrResult>>('IF_TXR_029/', {
...config,
});
if (statusCode !== 200) {
throw new Error(message);
}
return { total: metaPage ? Number(metaPage.totalRow) : 0, data };
};
bupot21.save = async (config: TPostBupot26Request) => {
const {
data: { message, data, code },
} = await unifikasiClient.post<TBaseResponseAPI<TPostBupot26Request>>('/IF_TXR_029/', {
...config,
});
if (code === 0) {
throw new Error(message);
}
return data;
};
bupot21.getById = async (id: string) => {
const res = await unifikasiClient.get('/IF_TXR_029/', { params: { id, feature: '26' } });
const {
data: { status, message, data },
status: statusCode,
} = res;
if (statusCode !== 200 || status?.toLowerCase() !== 'success') {
console.error('getNrId failed:', { statusCode, status, message });
throw new Error(message || 'Gagal mengambil data NR');
}
const dnData = Array.isArray(data) ? data[0] : data;
return dnData;
};
bupot21.upload = async ({ id }: { id: string | number }) => {
const {
data: { status, message, data, code },
status: statusCode,
} = await unifikasiClient.post('/IF_TXR_029/upload', { id, feature: '26' });
return { status, message, data, code, statusCode };
};
bupot21.delete = async (payload: TDeleteNrRequest, config?: Record<string, any>): Promise<any> => {
const {
data: { status, message, data },
status: statusCode,
} = await unifikasiClient.post<TBaseResponseAPI<any>>('/IF_TXR_029/delete', payload, {
...config,
});
if (statusCode !== 200 || status?.toLowerCase() === 'error') {
throw new Error(message || 'Gagal menghapus data NR');
}
return data;
};
bupot21.cancel = async ({ id, tglPembatalan }: TCancelNrRequest): Promise<TCancelNrResponse> => {
const {
data: { status, message, data, code, time, metaPage, total },
} = await unifikasiClient.post('/IF_TXR_029/batal', {
id,
tglPembatalan,
feature: '26',
});
console.log('Cancel NR response:', { code, message, status });
if (code === 0) {
throw new Error(message || 'Gagal membatalkan data');
}
return {
status,
message,
data,
code,
time,
metaPage,
total,
};
};
bupot21.cetakPdfDetail = async (payload: Record<string, any>) => {
const response = await axiosCetakPdf.post('/report/ctas/bpnr', payload);
const body = response.data;
if (
!response ||
response.status !== 200 ||
body.status === 'fail' ||
body.status === 'error' ||
body.status === '0'
) {
throw new Error(
body.message || 'System tidak dapat memenuhi permintaan, coba beberapa saat lagi'
);
}
return body;
};
export default bupot21;
import dayjs from 'dayjs';
import { FG_FASILITAS_DN } from '../constant';
const FASILITAS_LABEL_MAP: Record<string, string> = {
[FG_FASILITAS_DN.SKB_PPH_PASAL_22]: 'SKB PPh Pasal 22',
[FG_FASILITAS_DN.SKB_PPH_PASAL_23]: 'SKB PPh Pasal 23',
[FG_FASILITAS_DN.SKB_PPH_PHTB]: 'SKB PPh PHTB',
[FG_FASILITAS_DN.DTP]: 'DTP',
[FG_FASILITAS_DN.SKB_PPH_BUNGA_DEPOSITO_DANA_PENSIUN_TABUNGAN]:
'SKB PPh Bunga Deposito Dana Pensiun Tabungan',
[FG_FASILITAS_DN.SUKET_PP23_PP52]: 'Suket PP23/PP52',
[FG_FASILITAS_DN.SKD_WPLN]: 'SKD WPLN',
[FG_FASILITAS_DN.FASILITAS_LAINNYA]: 'Fasilitas Lainnya',
[FG_FASILITAS_DN.TANPA_FASILITAS]: 'Tanpa Fasilitas',
[FG_FASILITAS_DN.SKB_PPH_PASAL_21]: 'SKB PPh Pasal 21',
[FG_FASILITAS_DN.DTP_PPH_PASAL_21]: 'DTP PPh Pasal 21',
};
const formatTanggalIndo = (isoDate?: string): string => {
if (!isoDate) return '';
return dayjs(isoDate).locale('id').format('DD MMMM YYYY');
};
/**
* Normalisasi payload Bupot Unifikasi agar sesuai format yang digunakan API cetak PDF
*/
export const normalizePayloadCetakPdf = (payload: Record<string, any>) => {
if (!payload) return payload;
const adjusted = { ...payload };
if (adjusted.tglpemotongan) {
adjusted.tglPemotongan = formatTanggalIndo(adjusted.tglpemotongan); // versi tampil
}
// === Konversi kode fasilitas ke label ===
const fasilitasCode = adjusted.sertifikatInsentifDipotong;
adjusted.sertifikatInsentifDipotong = FASILITAS_LABEL_MAP[fasilitasCode] || fasilitasCode || '';
// === Field default tambahan ===
adjusted.mixcode = adjusted.mixcode || 'mixcode';
adjusted.qrcode = adjusted.qrcode || 'qrcode';
adjusted.metodePembayaranBendahara = adjusted.metodePembayaranBendahara || '-';
adjusted.nomorSP2D = adjusted.nomorSP2D || '-';
adjusted.npwpDipotong = adjusted.npwp || '';
adjusted.namaDipotong = adjusted.namaDipotong || '';
adjusted.nitkuDipotong = adjusted.nik || '';
adjusted.namaPemotong = adjusted.namaDipotong || '';
adjusted.nitkuPemotong = adjusted.idTku || '';
adjusted.penghasilanBruto = adjusted.penghasilanBruto || '';
adjusted.tanggal_Dokumen = adjusted.dokumen_referensi[0].tanggal_Dokumen;
adjusted.status = 'Proforma';
adjusted.msPajak = adjusted.masaPajak;
adjusted.thnPajak = adjusted.tahunPajak;
adjusted.kdObjPjk = adjusted.kodeObjekPajak;
adjusted.fgPdf = adjusted.fgPdf === 'TIDAK_TERSEDIA' ? '2' : adjusted.fgPdf;
return adjusted;
};
export default normalizePayloadCetakPdf;
import axios from 'axios';
const BASE_URL = `https://nodesandbox.pajakexpress.id:1837`;
const unifikasiClient = axios.create({
baseURL: BASE_URL,
validateStatus(status) {
return (status >= 200 && status < 300) || status === 500;
},
});
// Interceptor untuk selalu update token dari localStorage
unifikasiClient.interceptors.request.use((config) => {
const jwtAccessToken = localStorage.getItem('jwt_access_token');
const xToken = localStorage.getItem('x-token');
if (jwtAccessToken) {
config.headers.Authorization = `Bearer ${jwtAccessToken}`;
}
if (xToken) {
config.headers['x-token'] = xToken;
}
return config;
});
export default unifikasiClient;
import dayjs from 'dayjs';
import { MIN_THN_PAJAK } from '../constant';
export const currentYear = dayjs().year();
export const getHighestStartingYear = (thnAwalUnifikasi: any) =>
Math.max(MIN_THN_PAJAK, thnAwalUnifikasi);
export const selectedInitialMonth = ({ thnAwalUnifikasi, masaAwalUnifikasi }: any) => {
const highestYear = getHighestStartingYear(thnAwalUnifikasi);
return highestYear > thnAwalUnifikasi ? '01' : masaAwalUnifikasi;
};
export const determineStartingMonth = ({ thnPajak, thnAwalUnifikasi, masaAwalUnifikasi }: any) => {
const highestYear = getHighestStartingYear(thnAwalUnifikasi);
const initialMonth = selectedInitialMonth({ thnAwalUnifikasi, masaAwalUnifikasi });
return thnPajak >= highestYear && thnPajak <= currentYear ? initialMonth : '';
};
This diff is collapsed.
This diff is collapsed.
export * from './bupot-26-list-view';
export * from './bupot-26-rekam-view';
// src/workers/normalizeDn.worker.js
// NOTE: keep this file plain JS - no TS imports - copy needed transform functions here.
function formatDateToDDMMYYYY(dateString) {
if (!dateString) return '';
const d = new Date(dateString);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}/${month}/${year}`;
}
// minimal transform helpers used in normalize
function transformFgStatusToFgSignStatus(fgStatus) {
const splitted = (fgStatus || '').split('-') || [];
if (splitted.includes('SIGN') > 0) return 'FAILED';
if (splitted.includes('SIGNING IN PROGRESS')) return 'IN_PROGRESS';
switch (splitted[1]) {
case 'document signed successfully':
case 'Done':
return 'SIGNED';
default:
return null;
}
}
function getFgStatusPdf(link, fgSignStatus) {
if (!link || fgSignStatus === 'IN_PROGRESS') return 'TIDAK_TERSEDIA';
if (!link.includes('https://coretaxdjp.pajak.go.id/')) return 'BELUM_TERBENTUK';
return 'TERBENTUK';
}
function normalisePropsGetDn(params) {
if (!params) return params;
return {
...params,
nomorSP2D: params.dokumen_referensi?.[0]?.nomorSP2D || '',
metodePembayaranBendahara: params.dokumen_referensi?.[0]?.metodePembayaranBendahara || '',
dokReferensi: params.dokumen_referensi?.[0]?.dokReferensi || '',
nomorDokumen: params.dokumen_referensi?.[0]?.nomorDokumen || '',
id: params.id,
internal_id: params.internal_id,
fgStatus: params.fgStatus,
fgSignStatus: transformFgStatusToFgSignStatus(params.fgStatus),
fgPdf: getFgStatusPdf(params.link, transformFgStatusToFgSignStatus(params.fgStatus)),
created_at: formatDateToDDMMYYYY(params.created_at),
updated_at: formatDateToDDMMYYYY(params.updated_at),
};
}
// eslint-disable-next-line func-names
onmessage = function (e) {
const { data } = e;
// data should be array of items
if (!Array.isArray(data)) {
postMessage({ error: 'expected array' });
return;
}
try {
const out = data.map(normalisePropsGetDn);
postMessage({ data: out });
} catch (err) {
postMessage({ error: (err && err.message) || String(err) });
}
};
......@@ -22,10 +22,17 @@ const genderOptions = [
];
const Identitas = ({ isPengganti, kodeNegetaOptions, ptkpOptions }: IdentitasProps) => {
// const { dnId } = useParams();
const { setValue, watch } = useFormContext();
const fgKaryawanAsing = watch('fgKaryawanAsing');
const handleFgPerhitunganChange = (val: any) => {
const newValue = val.target.value;
const newIsGrossUp = newValue === '1';
setValue('fgPerhitungan', newValue);
setValue('isGrossUp', newIsGrossUp);
};
return (
<>
<Grid container rowSpacing={2} alignItems="center" columnSpacing={2} sx={{ mb: 4 }}>
......@@ -65,6 +72,7 @@ const Identitas = ({ isPengganti, kodeNegetaOptions, ptkpOptions }: IdentitasPro
name="fgPerhitungan"
label="Metode Pemotongan"
options={fgPerhitunganOptions}
onChange={handleFgPerhitunganChange}
/>
<Field.Autocomplete
......@@ -75,22 +83,21 @@ const Identitas = ({ isPengganti, kodeNegetaOptions, ptkpOptions }: IdentitasPro
width: '65%',
}}
renderOption={(props, option, state, ownerState) => {
console.log("🚀 ~ Identitas ~ option:", option);
const { key, ...optionProps } = props;
return (
<Box
key={key}
sx={{
letterSpacing: '1.5px',
}}
component="li"
{...optionProps}
>
{ownerState.getOptionLabel(option)}
</Box>
);
}}
console.log('🚀 ~ Identitas ~ option:', option);
const { key, ...optionProps } = props;
return (
<Box
key={key}
sx={{
letterSpacing: '1.5px',
}}
component="li"
{...optionProps}
>
{ownerState.getOptionLabel(option)}
</Box>
);
}}
/>
</Grid>
<Grid size={{ md: 6 }}>
......
import { CalculateRounded } from '@mui/icons-material';
import { LoadingButton } from '@mui/lab';
import { Divider, Grid, Stack, Typography } from '@mui/material';
import { RHFNumeric } from 'src/components/hook-form/rhf-numeric';
import { FORM_FIELDS, FORM_SECTIONS, getFieldNameByIndex, isFieldReadOnly } from '../constant';
import dayjs from 'dayjs';
import { memo, useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import { memo, useEffect, useMemo } from 'react';
import { LoadingButton } from '@mui/lab';
import { CalculateRounded } from '@mui/icons-material';
import { RHFNumeric } from 'src/components/hook-form/rhf-numeric';
import {
getHitungBulananErrorMessage,
useHitungTahunanA1 as hitungTahunanA1,
} from 'src/sections/bupot-21-26/hitung';
import dayjs from 'dayjs';
import { FORM_FIELDS, FORM_SECTIONS, getFieldNameByIndex, isFieldReadOnly } from '../constant';
// ============================================
// REUSABLE COMPONENTS
......@@ -146,7 +146,7 @@ const useRincianCalculations = () => {
// ✅ FIXED: Hook dipanggil di top-level component
// ============================================
const PerhitunganA1Builder = memo(
const PerhitunganA1Builder =
({
listInputs = [],
labelCols = 4,
......@@ -183,9 +183,8 @@ const PerhitunganA1Builder = memo(
</Stack>
);
}
);
PerhitunganA1Builder.displayName = 'PerhitunganA1Builder';
// PerhitunganA1Builder.displayName = 'PerhitunganA1Builder';
// ============================================
// ✅ MAIN COMPONENT: Hook dipanggil di sini
......@@ -197,6 +196,7 @@ export default function PerhitunganA1Container() {
const fgPerhitungan = watch('fgPerhitungan');
const msPjkAwal = dayjs(watch('masaPajakAwal')).get('month') + 1;
const isMetodePemotonganSeTahun = watch('metodePemotongan');
const isGrossUp = watch('isGrossUp');
useRincianCalculations();
......@@ -219,12 +219,7 @@ export default function PerhitunganA1Container() {
mutate(currentValues as any);
};
const handleGrossUpChange = (_: any, checked: boolean) => {
setValue('fgPerhitungan', checked ? '1' : '0');
setValue('isGrossUp', checked);
};
const listInputs = useMemo(() => {
const listInputs = () => {
const result: any[] = [];
let globalIndex = 0;
......@@ -253,16 +248,7 @@ export default function PerhitunganA1Container() {
key: fieldName,
label: (
<FormFieldLabel number={fieldNumber} text={label}>
<Field.Checkbox
name="isGrossUp"
label="Gross Up"
sx={{ padding: 0 }}
slotProps={{
checkbox: {
onChange: handleGrossUpChange,
},
}}
/>
<Field.Checkbox name="isGrossUp" value={isGrossUp} label="Gross Up" sx={{ padding: 0 }} />
</FormFieldLabel>
),
element: <FormNumberInput name={fieldName} readOnly={readOnly} />,
......@@ -304,14 +290,15 @@ export default function PerhitunganA1Container() {
});
return result;
}, [
fgPerhitungan,
handleGrossUpChange,
handleHitung,
isMetodePemotonganSeTahun,
isPending,
msPjkAwal,
]);
return <PerhitunganA1Builder listInputs={listInputs} labelCols={9} inputCols={3} />;
};
useEffect(() => {
const expectedFgPerhitungan = isGrossUp ? '1' : '0';
if (fgPerhitungan !== expectedFgPerhitungan) {
setValue('fgPerhitungan', expectedFgPerhitungan, { shouldValidate: false });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isGrossUp]); // Hanya listen isGrossUp
return <PerhitunganA1Builder listInputs={listInputs()} labelCols={9} inputCols={3} />;
}
......@@ -35,16 +35,15 @@ const RincianPenghasilan = ({
const tanggalPemotongan = watch('tglPemotongan');
const masaPajakAwal = watch('masaPajakAwal');
const masaPajakAkhir = watch('masaPajakAkhir');
console.log("🚀 ~ RincianPenghasilan:", {masaPajakAwal, masaPajakAkhir});
useEffect(() => {
if (!isPengganti) {
if (tanggalPemotongan) {
const date = dayjs(tanggalPemotongan);
setValue('tahunPajak', date.format('YYYY'));
setValue('masaPajak', date.format('MM'));
} else {
setValue('tahunPajak', '');
setValue('masaPajak', '');
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
......@@ -72,6 +71,7 @@ const RincianPenghasilan = ({
setValue('masaPajakAwal', '01')
setValue('masaPajakAkhir', '12')
setValue('noBupotSebelumnya', '')
setValue('rincian14', '0')
}
}}
slotProps={{
......@@ -113,11 +113,11 @@ const RincianPenghasilan = ({
<Field.DatePicker
name="masaPajakAwal"
label="Masa Pajak Awal"
view="month"
views={['month']} // ✅ valid prop
format="MM"
openTo="month"
maxDate={dayjs(masaPajakAkhir)}
// readOnly={isMetodePemotonganSeTahun || isPengganti}
disabled={isMetodePemotonganSeTahun || isPengganti}
/>
</Grid>
<Grid size={{ md: 1 }}>
......@@ -131,11 +131,11 @@ const RincianPenghasilan = ({
<Field.DatePicker
name="masaPajakAkhir"
label="Masa Pajak Akhir"
view='month'
views={['month']} // ✅ valid prop
format="MM"
openTo="month"
minDate={dayjs(masaPajakAwal)}
// readOnly={isMetodePemotonganSeTahun || isPengganti}
disabled={isMetodePemotonganSeTahun || isPengganti}
/>
</Grid>
......
......@@ -403,8 +403,10 @@ export const TahunanA1RekamView = () => {
const jnsKelamin = dataResDetail.jnsKelamin === 'M' ? '0' : '1';
const statusPtkp = `${dataResDetail.statusPtkp}/${dataResDetail.jmlPtkp}`;
const normalized = {
const normalized: TahunanA1FormData = {
...dataResDetail,
masaPajakAwal: `${dayjs(dataResDetail.masaPajakAwal, 'MM')}`,
masaPajakAkhir: `${dayjs(dataResDetail.masaPajakAkhir, 'MM')}`,
idBupot: dataResDetail.idBupot || '',
noBupot: dataResDetail.noBupot || '',
revNo: dataResDetail.revNo || '',
......@@ -427,10 +429,11 @@ export const TahunanA1RekamView = () => {
label: '',
},
metodePemotongan: `${dataResDetail.fgStatusPemotonganPph}`,
fgPerhitungan: dataResDetail.tunjanganPPhGrossUp === 'NO' ? '0' : '1',
kdObjPjk: dataListKOP.filter((val) => val.value === dataResDetail.kodeObjekPajak)[0],
fgFasilitas: fgFasilitasOptions.filter((val) => val.value === dataResDetail.fgFasilitas)[0],
noDokLainnya: dataResDetail.noDokFasilitas,
noDokLainnya: dataResDetail.noDokFasilitas || '',
// Financial Details (Rincian)
rincian1: `${dataResDetail.gajiPensiun || 0}`,
......@@ -458,9 +461,10 @@ export const TahunanA1RekamView = () => {
rincian23: `${dataResDetail.pph21KurangLebihBayar || 0}`,
idTku: MockNitku.filter((val) => val.value === dataResDetail.idTku)[0],
};
} as unknown as TahunanA1FormData;
if (isPengganti) {
normalized['id'] = undefined;
normalized['idBupot'] = dataResDetail.idBupot || '';
normalized['idBupot'] = dataResDetail.noBupot || '';
normalized['revNo'] = `${Number(dataResDetail.revNo) || 0}`;
......
......@@ -2,6 +2,7 @@ export const appRootKey = 'bupot-21-26';
export const bulanan = 'bulanan';
export const tahunanA1 = 'tahunan-a1';
export const bupotfinal = 'bupot-final-tidak-final';
export const bupot26 = 'bupot-26';
const queryKey = {
getKodeObjekPajak: (params: any) => [appRootKey, 'kode-objek-pajak', params],
......@@ -32,6 +33,15 @@ const queryKey = {
cancel: [appRootKey, tahunanA1, 'cancel'],
cetakPdf: (params: any) => [appRootKey, tahunanA1, 'cetak-pdf', params],
},
bupot26: {
all: (params: any) => [appRootKey, bupot26, params],
detail: (params: any) => [appRootKey, bupot26, 'detail', params],
draft: [appRootKey, bupot26, 'draft'],
delete: [appRootKey, bupot26, 'delete'],
upload: [appRootKey, bupot26, 'upload'],
cancel: [appRootKey, bupot26, 'cancel'],
cetakPdf: (params: any) => [appRootKey, bupot26, 'cetak-pdf', params],
},
};
export default queryKey;
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