Commit 04416926 authored by Fachri's avatar Fachri

bupot-nr new

parent 3b0dd65d
import { CONFIG } from 'src/global-config';
import { NrListView } from 'src/sections/bupot-unifikasi/bupot-nr/view';
import { NrListView } from 'src/sections/bupot-unifikasi/bupot-nr/view/nr-list-view';
// import { NrListView } from 'src/sections/bupot-unifikasi/bupot-nr/view';
const metadata = { title: `E-Bupot Unifikasi- ${CONFIG.appName}` };
......
......@@ -108,47 +108,6 @@ export function DnListView() {
const { buildAdvancedFilter, buildRequestParams } = useAdvancedFilter();
// const buildAdvancedFilter = useCallback((filters?: GridFilterModel['items']) => {
// if (!filters || filters.length === 0) return '';
// return filters
// .map((f) => {
// if (!f.value || !f.field) return null;
// const field = `LOWER("${f.field}")`;
// const val = String(f.value).toLowerCase();
// switch (f.operator) {
// case 'contains':
// return `${field} LIKE '%${val}%'`;
// case 'equals':
// case 'is':
// return `${field} = '${val}'`;
// case 'isNot':
// return `${field} <> '${val}'`;
// default:
// return null;
// }
// })
// .filter(Boolean)
// .join(' AND ');
// }, []);
// ---------- params memo (uses pagination from Zustand) ----------
// const params = useMemo(() => {
// const advanced = buildAdvancedFilter(filterModel.items);
// return {
// page,
// limit: pageSize,
// noBupot: '',
// idDipotong: '',
// namaDipotong: '',
// msPajak: '',
// thnPajak: '',
// advanced,
// sortingMode: sortModel[0]?.field ?? '',
// sortingMethod: sortModel[0]?.sort ?? '',
// };
// }, [page, pageSize, sortModel, filterModel.items, buildAdvancedFilter]);
const params = useMemo(() => {
const advanced = buildAdvancedFilter(filterModel.items);
......@@ -230,10 +189,6 @@ export function DnListView() {
width: 200,
type: 'singleSelect',
valueOptions: statusOptions.map((opt) => opt.value),
// valueFormatter: (params: any) => {
// const option = statusOptions.find((opt) => opt.value === params.value);
// return option ? option.label : (params.value as string);
// },
valueFormatter: ({ value }: { value: string }) => {
const option = statusOptions.find((opt) => opt.value === value);
return option ? option.label : value;
......@@ -275,37 +230,6 @@ export function DnListView() {
[statusOptions]
);
// --- selection helpers (kept same)
const normalizeSelectionToArray = (raw: unknown): GridRowId[] => {
if (!raw) return [];
if (typeof raw === 'object' && raw !== null && 'ids' in (raw as any)) {
const ids = (raw as any).ids;
if (ids instanceof Set) return Array.from(ids) as GridRowId[];
if (Array.isArray(ids)) return ids as GridRowId[];
if (ids instanceof Map) return Array.from((ids as Map<any, any>).keys()) as GridRowId[];
if (typeof ids === 'object' && ids !== null) return Object.keys(ids) as GridRowId[];
}
if (Array.isArray(raw)) return raw as GridRowId[];
if (raw instanceof Set) return Array.from(raw) as GridRowId[];
if (raw instanceof Map) return Array.from((raw as Map<any, any>).keys()) as GridRowId[];
if (typeof raw === 'object' && raw !== null) {
const obj = raw as Record<string, any>;
const keys = Object.keys(obj).filter((k) => !!obj[k]);
if (keys.length) return keys as GridRowId[];
}
try {
if ((raw as any)[Symbol.iterator]) {
return Array.from(raw as Iterable<unknown>) as GridRowId[];
}
} catch {
/* ignore */
}
return [];
};
const getSelectedRowByKey = (key?: GridRowId | 'all') => {
const api = apiRef.current;
if (!api) return null;
......
import React from 'react';
import { GridPreferencePanelsValue, 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 { GridToolbarContainer, GridToolbarProps } from '@mui/x-data-grid-premium';
import { Stack, Divider, IconButton, Tooltip } from '@mui/material';
import { 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, 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/useCetakPdfDn';
import normalizePayloadCetakPdf from '../../utils/normalizePayloadCetakPdf';
interface ModalCetakPdfDnProps {
payload?: Record<string, any>;
isOpen: boolean;
onClose: () => void;
}
const formatTanggalIndo = (isoDate: string | undefined | null): string => {
if (!isoDate) return '';
const date = new Date(isoDate);
const formatter = new Intl.DateTimeFormat('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
return formatter.format(date);
};
const ModalCetakPdfDn: 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 ModalCetakPdfDn;
import React, { useEffect, useState } from 'react';
import { useMutation, 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 dnApi from '../../utils/api';
import queryKey from '../../constant/queryKey';
import DialogConfirm from 'src/shared/components/dialog/DialogConfirm';
import { GridRowSelectionModel } from '@mui/x-data-grid-premium';
import useDeleteDn from '../../hooks/useDeleteDn';
interface ModalDeleteDnProps {
dataSelected?: GridRowSelectionModel;
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
isOpenDialogDelete: boolean;
setIsOpenDialogDelete: (v: boolean) => void;
successMessage?: string;
}
/**
* 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 ModalDeleteDn: React.FC<ModalDeleteDnProps> = ({
dataSelected,
setSelectionModel,
tableApiRef,
isOpenDialogDelete,
setIsOpenDialogDelete,
successMessage = 'Data berhasil dihapus',
}) => {
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 } = useDeleteDn({
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) })));
};
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' });
handleCloseModal();
clearSelection();
} catch (error: any) {
enqueueSnackbar(error?.message || 'Gagal menghapus data', { variant: 'error' });
} finally {
// sesuaikan queryKey jika perlu; tetap panggil invalidasi
queryClient.invalidateQueries({ queryKey: ['unifikasi', 'dn'] });
}
};
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 ModalDeleteDn;
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 { 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 { RootState } from 'src/store';
import Agreement from 'src/shared/components/agreement/Agreement';
import { FormProvider, useForm } from 'react-hook-form';
import { LoadingButton } from '@mui/lab';
interface ModalUploadDnProps {
dataSelected?: GridRowSelectionModel;
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
isOpenDialogUpload: boolean;
setIsOpenDialogUpload: (v: boolean) => void;
successMessage?: string;
// onConfirmUpload?: () => void;
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 ModalUploadDn: React.FC<ModalUploadDnProps> = ({
dataSelected,
setSelectionModel,
tableApiRef,
isOpenDialogUpload,
setIsOpenDialogUpload,
successMessage = 'Data berhasil diupload',
onConfirmUpload,
}) => {
const queryClient = useQueryClient();
const uploadDn = 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' });
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', 'dn'] });
}
};
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={uploadDn.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 ModalUploadDn;
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import MenuItem from '@mui/material/MenuItem';
import { Field } from 'src/components/hook-form';
import { JENIS_DOKUMEN } from 'src/sections/bupot-unifikasi/bupot-dn/constant';
const DokumenReferensi = () => {
const MockNitku = [
{
nama: '1091031210912281000000',
},
{
nama: '1091031210912281000001',
},
];
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.Select name="namaDok" label="Nama Dokumen">
{JENIS_DOKUMEN.map((item, index) => (
<MenuItem key={index} value={item.value}>
{item.label}
</MenuItem>
))}
</Field.Select>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="nomorDok" label="Nomor Dokumen" />
</Grid>
<Grid size={{ md: 6 }}>
<Field.DatePicker name="tglDok" label="Tanggal Dokumen" />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Select name="idTku" label="NITKU Pemotong">
{MockNitku.map((item, index) => (
<MenuItem key={index} value={item.nama}>
{item.nama}
</MenuItem>
))}
</Field.Select>
</Grid>
</Grid>
</>
);
};
export default DokumenReferensi;
// import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Grid from '@mui/material/Grid';
import MenuItem from '@mui/material/MenuItem';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useParams } from 'react-router';
import { Field } from 'src/components/hook-form';
type IdentitasProps = {
isPengganti: boolean;
// disabledTambah: boolean;
// disabledHapus: boolean;
};
const Identitas = ({ isPengganti }: IdentitasProps) => {
const { dnId } = useParams();
const { setValue } = useFormContext();
const [jumlahKeterangan, setJumlahKeterangan] = useState<number>(0);
const maxKeterangan = 5;
const handleTambah = () => {
if (jumlahKeterangan < maxKeterangan) {
setJumlahKeterangan(jumlahKeterangan + 1);
}
};
const handleHapus = () => {
if (jumlahKeterangan > 0) {
const newCount = jumlahKeterangan - 1;
setJumlahKeterangan(newCount);
// reset value form field yang dihapus
setValue(`keterangan${newCount + 1}`, null);
}
};
return (
<>
<Grid container rowSpacing={2} alignItems="center" columnSpacing={2} sx={{ mb: 4 }}>
<Grid size={{ md: 6 }}>
<Field.DatePicker name="tglPemotongan" label="Tanggal Pemotongan" />
</Grid>
<Grid size={{ md: 3 }}>
<Field.DatePicker name="thnPajak" label="Tahun Pajak" view="year" format="YYYY" />
</Grid>
<Grid size={{ md: 3 }}>
<Field.DatePicker name="msPajak" label="Masa Pajak" view="month" format="MM" />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="idDipotong" label="Tax ID Number (TIN)" />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="namaDipotong" label="Nama" />
</Grid>
<Grid size={{ md: 12 }}>
<Field.Text name="email" label="Email (Optional)" />
</Grid>
<Grid size={{ md: 12 }}>
<Field.Text name="almtDipotong" label="Alamat" multiline rows={2} />
</Grid>
<Grid size={{ md: 12 }}>
<Field.Select name="kdNgrDipotong" label="Negara">
<MenuItem>Indonesia</MenuItem>
</Field.Select>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="tempatLahirDipotong" label="Tempat Lahir" />
</Grid>
<Grid size={{ md: 6 }}>
<Field.DatePicker name="tglLahirDipotong" label="Tanggal Lahir" />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="noPasporDipotong" label="No. Paspor" />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="noKitasDipotong" label="No.KITAS/KITAP" />
</Grid>
</Grid>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<Box
sx={{
borderRadius: '18px',
border: jumlahKeterangan >= maxKeterangan ? '1px solid #eee' : '1px solid #2e7d3280',
color: jumlahKeterangan >= maxKeterangan ? '#eee' : '#2e7d3280',
p: '0px 10px',
}}
>
<Button disabled={jumlahKeterangan >= maxKeterangan} onClick={handleTambah}>
Tambah Keterangan
</Button>
</Box>
<Box
sx={{
borderRadius: '18px',
border: jumlahKeterangan === 0 ? '1px solid #eee' : '1px solid #f44336',
color: jumlahKeterangan === 0 ? '#eee' : '#f44336',
p: '0px 10px',
}}
>
<Button disabled={jumlahKeterangan === 0} onClick={handleHapus}>
Hapus Keterangan
</Button>
</Box>
</Box>
<Box sx={{ mb: 3 }}>
{Array.from({ length: jumlahKeterangan }).map((_, i) => (
<Grid size={{ md: 12 }} key={i}>
<Field.Text
sx={{ mb: 2 }}
name={`keterangan${i + 1}`}
label={`Keterangan Tambahan ${i + 1}`}
/>
</Grid>
))}
</Box>
</>
);
};
export default Identitas;
import { FC } 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 PanduanNrRekam: FC<PanduanDnRekamProps> = ({ handleOpen, isOpen }) => {
return (
<Box position="sticky">
{/* Tombol toggle */}
<Box
height="100%"
display={isOpen ? 'none' : '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',
padding: '16px',
'& .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' }}>
<span style={{ fontWeight: 600 }}>Deskripsi Form:</span>
<br />
{PANDUAN_REKAM_NR.description.intro}
</Typography>
<Typography variant="body2" sx={{}}>
{PANDUAN_REKAM_NR.description.textList}
</Typography>
<Box component="ol" sx={{ pl: 3, mb: 2 }}>
{PANDUAN_REKAM_NR.description.list.map((item, idx) => (
<Typography key={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={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) => (
<Box key={idx} 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={subIdx} variant="body2" component="li">
{sub}
</Typography>
))}
</Box>
)}
</Box>
))}
</Box>
</Box>
))}
</CardContent>
</Card>
</m.div>
)}
</Box>
);
};
export default PanduanNrRekam;
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import MenuItem from '@mui/material/MenuItem';
import { useEffect, useMemo } from 'react';
import { Field } from 'src/components/hook-form';
import { useFormContext } from 'react-hook-form';
import FieldNumberText from 'src/shared/components/FieldNumberText ';
import usePphDipotong from 'src/sections/bupot-unifikasi/bupot-dn/hooks/usePphDipotong';
import {
FG_FASILITAS_DN,
FG_FASILITAS_MASTER_KEY,
FG_FASILITAS_TEXT,
} from 'src/sections/bupot-unifikasi/bupot-dn/constant';
import { TGetListDataKOPDn } from 'src/sections/bupot-unifikasi/bupot-dn/types/types';
type PPHDipotongProps = {
kodeObjectPajak: TGetListDataKOPDn[];
};
const PphDipotong = ({ kodeObjectPajak }: PPHDipotongProps) => {
const { watch, setValue } = useFormContext();
const selectedKode = watch('kdObjPjk');
const fgFasilitas = watch('fgFasilitas');
const kodeObjekPajakSelected = useMemo(
() => kodeObjectPajak.find((item) => item.kode === selectedKode),
[kodeObjectPajak, selectedKode]
);
// Hook otomatis hitung tarif & pphDipotong
usePphDipotong(kodeObjekPajakSelected);
// Fasilitas options
const fgFasilitasOptions = useMemo(
() =>
Object.entries(FG_FASILITAS_DN).map(([_, value]) => ({
value,
label: FG_FASILITAS_TEXT[value],
})),
[]
);
const fasilitasOptions = useMemo(
() =>
fgFasilitasOptions.filter(
(opt) =>
kodeObjekPajakSelected &&
kodeObjekPajakSelected[FG_FASILITAS_MASTER_KEY[opt.value] as keyof TGetListDataKOPDn] ===
1
),
[fgFasilitasOptions, kodeObjekPajakSelected]
);
// Reset fasilitas jika kode objek pajak berubah
useEffect(() => {
setValue('fgFasilitas', '', { shouldValidate: true });
}, [selectedKode, setValue]);
return (
<Grid container rowSpacing={2} columnSpacing={2}>
{/* Kode Objek Pajak */}
<Grid sx={{ mt: 3 }} size={{ md: 6 }}>
<Field.Select name="kdObjPjk" label="Kode Objek Pajak">
{kodeObjectPajak.map((item) => (
<MenuItem key={item.kode} value={item.kode}>
{`(${item.kode}) ${item.nama}`}
</MenuItem>
))}
</Field.Select>
</Grid>
{/* Divider */}
<Grid size={{ md: 12 }}>
<Divider sx={{ fontWeight: 'bold' }} textAlign="left">
Fasilitas Pajak Penghasilan
</Divider>
</Grid>
{/* Fasilitas */}
<Grid size={{ md: 6 }}>
<Field.Select name="fgFasilitas" label="Fasilitas">
{fasilitasOptions.length === 0 ? (
<MenuItem disabled value="">
No options
</MenuItem>
) : (
fasilitasOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))
)}
</Field.Select>
</Grid>
{/* Nomor Dokumen Lainnya */}
<Grid size={{ md: 6 }}>
<Field.Text
name="noDokLainnya"
label="Nomor Dokumen Lainnya"
disabled={['9', ''].includes(fgFasilitas)}
sx={{
'& .MuiInputBase-root.Mui-disabled': {
backgroundColor: '#f6f6f6',
},
}}
/>
</Grid>
{/* Jumlah Bruto */}
<Grid size={{ md: 6 }}>
<FieldNumberText name="jmlBruto" label="Jumlah Penghasilan Bruto (Rp)" />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text
name="kiraNetto"
label="Perkiraan Penghasilan Netto (%)"
type="number"
slotProps={{
input: {
readOnly: true,
style: { backgroundColor: '#f6f6f6' },
},
}}
/>
</Grid>
{/* Tarif */}
<Grid size={{ md: 6 }}>
<Field.Text
name="tarif"
label="Tarif (%)"
type="number"
value={kodeObjekPajakSelected?.tarif || ''}
slotProps={{
input: {
readOnly: ![FG_FASILITAS_DN.SKD_WPLN, FG_FASILITAS_DN.FASILITAS_LAINNYA].includes(
fgFasilitas
),
style: {
backgroundColor: ![
FG_FASILITAS_DN.SKD_WPLN,
FG_FASILITAS_DN.FASILITAS_LAINNYA,
].includes(fgFasilitas)
? '#f6f6f6'
: undefined,
},
},
}}
/>
</Grid>
{/* PPh Dipotong */}
<Grid size={{ md: 6 }}>
<Field.Text
name="pphDipotong"
label="PPh Yang Dipotong/Dipungut"
type="number"
slotProps={{
input: {
readOnly: true,
style: { backgroundColor: '#f6f6f6' },
},
}}
/>
</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;
const appRootKey = 'unifikasi';
const queryKey = {
getKodeObjekPajak: (params: any) => [appRootKey, 'kode-objek-pajak', params],
dn: {
all: (params: any) => [appRootKey, 'dn', params],
detail: (params: any) => [appRootKey, 'dn', 'detail', params],
draft: [appRootKey, 'dn', 'draft'],
delete: [appRootKey, 'dn', 'delete'],
upload: [appRootKey, 'dn', 'upload'],
cancel: [appRootKey, 'dn', 'cancel'],
cetakPdf: (params: any) => [appRootKey, 'dn-cetak-pdf', params],
},
};
export default queryKey;
import { useMutation } from '@tanstack/react-query';
import { TCancelDnRequest, TCancelDnResponse } from '../types/types';
import dnApi from '../utils/api';
const useCancelDn = (props?: any) =>
useMutation<TCancelDnResponse, Error, TCancelDnRequest>({
mutationKey: ['cancel-dn'],
mutationFn: (payload) => dnApi.cancel(payload),
...props,
});
export default useCancelDn;
import { useMutation } from '@tanstack/react-query';
import dnApi from '../utils/api';
const useCetakPdfDn = (options?: any) =>
useMutation({
mutationKey: ['unifikasi', 'dn', 'cetak-pdf'],
mutationFn: async (params: any) => dnApi.cetakPdfDetail(params),
...options,
});
export default useCetakPdfDn;
import { useMutation } from '@tanstack/react-query';
import { TDeleteDnRequest, TBaseResponseAPI } from '../types/types';
import dnApi from '../utils/api';
const useDeleteDn = (props?: any) =>
useMutation<TBaseResponseAPI<null>, Error, TDeleteDnRequest>({
mutationKey: ['delete-dn'],
mutationFn: (payload) => dnApi.deleteDn(payload),
...props,
});
export default useDeleteDn;
import { isEmpty } from 'lodash';
import { useQuery } from '@tanstack/react-query';
import dnApi from '../utils/api';
import { TGetListDataTableDn, TGetListDataTableDnResult } from '../types/types';
import { FG_PDF_STATUS, FG_SIGN_STATUS } from '../constant';
import queryKey from '../constant/queryKey';
export type TGetDnApiWrapped = {
data: TGetListDataTableDnResult[];
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 normalisePropsGetDn = (params: TGetListDataTableDn) => ({
...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),
});
// ---------- normalizer for params request ----------
const normalisPropsParmasGetDn = (params: any) => {
const sorting = !isEmpty(params.sortModel)
? transformSortModelToSortApiPayload(params.sortModel)
: {};
return {
...params,
page: (typeof params.page === 'number' ? params.page : 0) + 1,
limit: params.pageSize,
masaPajak: params.msPajak || null,
tahunPajak: params.thnPajak || null,
npwp: params.idDipotong || null,
advanced: isEmpty(params.advanced) ? undefined : params.advanced,
...sorting,
};
};
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,
};
};
export const useGetDn = ({ params }: { params: any }) => {
const { page, limit, advanced, sortingMode, sortingMethod } = params;
const normalized = normalizeParams(params);
return useQuery<TGetDnApiWrapped>({
queryKey: ['dn', page, limit, advanced, sortingMode, sortingMethod],
queryFn: async () => {
const res: any = await dnApi.getDn({ 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: TGetListDataTableDnResult[] = [];
const normalizeWithWorker = () =>
new Promise<TGetListDataTableDnResult[]>((resolve, reject) => {
try {
const worker = new Worker(
new URL('../workers/normalizeDn.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 TGetListDataTableDnResult[]);
}
};
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(normalisePropsGetDn) as unknown as TGetListDataTableDnResult[];
}
} catch (err) {
console.error('❌ Worker failed, fallback to sync normalize:', err);
dataArray = rawData.map(normalisePropsGetDn) as unknown as TGetListDataTableDnResult[];
}
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 useGetDnById = (id: string, options = {}) =>
useQuery({
queryKey: queryKey.dn.detail(id),
queryFn: async () => {
console.log('🔍 Fetching getDnById with ID:', id);
const res = await dnApi.getDnById(id);
if (!res) throw new Error('Data tidak ditemukan');
const normalized = {
id: res.id ?? '',
tglPemotongan: res.tglpemotongan ?? '',
thnPajak: res.tahunPajak ?? '',
msPajak: res.masaPajak ?? '',
idDipotong: res.npwpPemotong ?? '',
nitku: res.idTku ?? '',
namaDipotong: res.nama ?? '',
email: res.email ?? '',
keterangan1: res.keterangan1 ?? '',
keterangan2: res.keterangan2 ?? '',
keterangan3: res.keterangan3 ?? '',
keterangan4: res.keterangan4 ?? '',
keterangan5: res.keterangan5 ?? '',
kdObjPjk: res.kodeObjekPajak ?? '',
fgFasilitas: res.sertifikatInsentifDipotong ?? '',
noDokLainnya: res.nomorSertifikatInsentif ?? '',
jmlBruto: res.dpp ?? '',
tarif: String(res.tarif ?? ''),
pphDipotong: String(res.pphDipotong ?? ''),
namaDok: res.dokumen_referensi?.[0]?.dokReferensi ?? '',
nomorDok: res.dokumen_referensi?.[0]?.nomorDokumen ?? '',
tglDok: res.dokumen_referensi?.[0]?.tanggal_Dokumen ?? '',
idTku: res.idTku ?? '',
revNo: res.revNo ?? 0,
noBupot: res.noBupot ?? '',
idBupot: res.idBupot ?? '',
};
console.log('✅ Normalized data:', normalized);
return normalized;
},
enabled: !!id,
refetchOnWindowFocus: false,
...options,
});
export default useGetDn;
import { useQuery } from '@tanstack/react-query';
import { TBaseResponseAPI, TGetListDataKOPDnResult } from '../types/types';
import queryKey from '../constant/queryKey';
import dnApi from '../utils/api';
const useGetKodeObjekPajak = (params?: Record<string, any>) =>
useQuery<TBaseResponseAPI<TGetListDataKOPDnResult>>({
queryKey: queryKey.getKodeObjekPajak(params),
queryFn: () => dnApi.getKodeObjekPajakDn(params),
});
export default useGetKodeObjekPajak;
import { useEffect } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { TGetListDataKOPDn } from '../types/types';
const usePphDipotong = (kodeObjekPajakSelected?: TGetListDataKOPDn) => {
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'],
});
// eslint-disable-next-line @typescript-eslint/no-shadow
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])
);
}
}, [handlerSetPphDipotong]);
return {
updateTarifValues,
};
};
export default usePphDipotong;
import { useMutation } from '@tanstack/react-query';
import dayjs from 'dayjs';
import dnApi from '../utils/api';
import { TPostDnRequest } from '../types/types';
const transformParams = ({ isPengganti = false, ...dnData }: any): TPostDnRequest => {
const {
id,
idBupot,
noBupot,
msPajak,
thnPajak,
idDipotong,
nitku,
namaDipotong,
fgFasilitas,
noDokLainnya,
kdObjPjk,
kdJnsPjk,
statusPph,
jmlBruto,
tarif,
pphDipotong,
kap,
kjs,
revNo: initialRevNo,
tglPemotongan,
namaDok,
nomorDok,
tglDok,
metodePembayaranBendahara,
nomorSP2D,
idTku,
email,
glAccount,
keterangan1,
keterangan2,
keterangan3,
keterangan4,
keterangan5,
} = dnData;
const dokReferensi = [
{
dokReferensi: namaDok || '',
nomorDokumen: nomorDok || '',
tanggal_Dokumen: tglDok ? dayjs(tglDok).format('DDMMYYYY') : '',
metodePembayaranBendahara: metodePembayaranBendahara || '',
nomorSP2D: nomorSP2D || '',
},
];
const revNo = isPengganti ? parseInt(initialRevNo || 0, 10) + 1 : parseInt(initialRevNo || 0, 10);
const npwpLog = localStorage.getItem('npwp_log') ?? '';
return {
id: !isPengganti ? (id ?? null) : null,
idBupot: idBupot ?? null,
noBupot: noBupot ?? null,
npwpPemotong: npwpLog,
idTku: idTku ?? '',
masaPajak: msPajak ? dayjs(msPajak).format('MM') : '',
tahunPajak: thnPajak ? Number(dayjs(thnPajak).format('YYYY')) : 0,
npwp: idDipotong ?? '',
nik: nitku ?? (idDipotong ? `${idDipotong}000000` : ''),
nama: namaDipotong ?? '',
revNo,
fgNpwpNik: 'true', // static
fgJnsBupot: 'BPU', // static
dataDetilBpu: {
sertifikatInsentifDipotong: fgFasilitas ?? '9',
nomorSertifikatInsentif: noDokLainnya ?? '',
kodeObjekPajak: kdObjPjk ?? '',
pasalPPh: kdJnsPjk ?? '',
statusPPh: statusPph ?? '',
dpp: jmlBruto ?? '',
tarif: tarif ?? '',
pphDipotong: pphDipotong ?? '',
kap: kap ?? '',
kjs: kjs ?? '',
dokReferensi,
},
tglPemotongan: tglPemotongan ? dayjs(tglPemotongan).format('DDMMYYYY') : '',
email: email ?? '',
glAccount: glAccount ?? '',
keterangan1: keterangan1 ?? '',
keterangan2: keterangan2 ?? '',
keterangan3: keterangan3 ?? '',
keterangan4: keterangan4 ?? '',
keterangan5: keterangan5 ?? '',
};
};
const useSaveDn = (props?: any) =>
useMutation({
mutationKey: ['Save-Dn'],
mutationFn: (params: any) => dnApi.saveDn(transformParams(params)),
...props,
});
export default useSaveDn;
// hooks/useUpload.ts
import { useMutation } from '@tanstack/react-query';
import dnApi from '../utils/api';
const useUpload = (props?: any) =>
useMutation({
mutationKey: ['upload-dn'],
mutationFn: (payload: { id: string | number }) => dnApi.upload(payload),
...props,
});
export default useUpload;
// import { create } from 'zustand';
// type TableKey = string;
// interface TablePagination {
// page: number;
// pageSize: number;
// }
// interface PaginationState {
// tables: Record<TableKey, TablePagination>;
// setPagination: (table: TableKey, next: Partial<TablePagination>) => void;
// resetPagination: (table: TableKey) => void;
// }
// export const usePaginationStore = create<PaginationState>((set) => ({
// tables: {},
// setPagination: (table, next) =>
// set((state) => ({
// tables: {
// ...state.tables,
// [table]: {
// page: next.page ?? state.tables[table]?.page ?? 0,
// pageSize: next.pageSize ?? state.tables[table]?.pageSize ?? 10,
// },
// },
// })),
// resetPagination: (table) =>
// set((state) => ({
// tables: {
// ...state.tables,
// [table]: { page: 0, pageSize: state.tables[table]?.pageSize ?? 10 },
// },
// })),
// }));
import { create } from 'zustand';
type TableKey = string;
interface TablePagination {
page: number;
pageSize: number;
}
interface PaginationState {
tables: Record<TableKey, TablePagination>;
setPagination: (table: TableKey, next: Partial<TablePagination>) => void;
resetPagination: (table: TableKey) => void;
}
export const usePaginationStore = create<PaginationState>((set) => ({
tables: {},
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 },
},
})),
}));
import axios from 'axios';
import {
TBaseResponseAPI,
TCancelDnRequest,
TCancelDnResponse,
TDeleteDnRequest,
TGetListDataKOPDnResult,
TGetListDataTableDnResult,
TPostDnRequest,
TPostUpload,
} from '../types/types';
import unifikasiClient from './unifikasiClient';
const dnApi = () => {};
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
dnApi.getDn = async (config: any) => {
const {
data: { status, message, metaPage, data },
status: statusCode,
} = await unifikasiClient.get<TBaseResponseAPI<TGetListDataTableDnResult>>('IF_TXR_028/bpu', {
...config,
});
if (statusCode !== 200) {
throw new Error(message);
}
return { total: metaPage ? Number(metaPage.totalRow) : 0, data };
};
dnApi.getKodeObjekPajakDn = async (params?: Record<string, any>) => {
const response = await unifikasiClient.get<TBaseResponseAPI<TGetListDataKOPDnResult>>(
'/sandbox/mst_kop_bpu',
{ params }
);
const body = response.data;
if (response.status !== 200 || body.status !== 'success') {
throw new Error(body.message);
}
return body;
};
dnApi.saveDn = async (config: TPostDnRequest) => {
const {
data: { status, message, data, code },
status: statusCode,
} = await unifikasiClient.post<TBaseResponseAPI<TPostDnRequest>>('/IF_TXR_028/bpu', {
...config,
});
if (code === 0) {
throw new Error(message);
}
return data;
};
dnApi.getDnById = async (id: string) => {
const res = await unifikasiClient.get('/IF_TXR_028/bpu', { params: { id } });
const {
data: { status, message, data },
status: statusCode,
} = res;
if (statusCode !== 200 || status?.toLowerCase() !== 'success') {
console.error('getDnById failed:', { statusCode, status, message });
throw new Error(message || 'Gagal mengambil data DN');
}
const dnData = Array.isArray(data) ? data[0] : data;
return dnData;
};
dnApi.upload = async ({ id }: { id: string | number }) => {
const {
data: { status, message, data, code },
status: statusCode,
} = await unifikasiClient.post('/IF_TXR_028/bpu/upload', { id });
return { status, message, data, code, statusCode };
};
dnApi.deleteDn = async (payload: TDeleteDnRequest, config?: Record<string, any>): Promise<any> => {
const {
data: { status, message, data },
status: statusCode,
} = await unifikasiClient.post<TBaseResponseAPI<any>>('/IF_TXR_028/bpu/delete', payload, {
...config,
});
if (statusCode !== 200 || status?.toLowerCase() === 'error') {
throw new Error(message || 'Gagal menghapus data DN');
}
return data;
};
dnApi.cancel = async ({ id, tglPembatalan }: TCancelDnRequest): Promise<TCancelDnResponse> => {
const {
data: { status, message, data, code, time, metaPage, total },
status: statusCode,
} = await unifikasiClient.post('/IF_TXR_028/bpu/batal', {
id,
tglPembatalan,
});
console.log('Cancel DN response:', { code, message, status });
if (code === 0) {
throw new Error(message || 'Gagal membatalkan data');
}
return {
status,
message,
data,
code,
time,
metaPage,
total,
};
};
dnApi.cetakPdfDetail = async (payload: Record<string, any>) => {
const response = await axiosCetakPdf.post('/report/ctas/bpu', 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 dnApi;
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.nama || '';
adjusted.nitkuDipotong = adjusted.nik || '';
adjusted.namaPemotong = adjusted.nama || '';
adjusted.nitkuPemotong = adjusted.nik || '';
adjusted.penghasilanBruto = adjusted.dpp || '';
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 : '';
};
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
import Grid from '@mui/material/Grid';
import React, { Suspense, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { DashboardContent } from 'src/layouts/dashboard';
import { paths } from 'src/routes/paths';
import HeadingRekam from 'src/shared/components/HeadingRekam';
import z from 'zod';
import Divider from '@mui/material/Divider';
import FormSkeleton from 'src/shared/skeletons/FormSkeleton';
import useGetKodeObjekPajak from '../../bupot-dn/hooks/useGetKodeObjekPajak';
import DokumenReferensi from '../../bupot-dn/components/rekamDn/DokumenReferensi';
import Agreement from 'src/shared/components/agreement/Agreement';
import Stack from '@mui/material/Stack';
import PanduanNrRekam from '../components/rekamNr/PanduanNrRekam';
import Identitas from '../components/rekamNr/Identitas';
import PphDipotong from '../components/rekamNr/PphDipotong';
const bpuSchema = z.object({
npwpPemotong: z.string().min(10, 'NPWP Pemotong wajib diisi'),
idTku: z.string().min(5, 'ID TKU wajib diisi'),
masaPajak: z.string().length(2, 'Masa Pajak harus 2 digit'),
tahunPajak: z.string().length(4, 'Tahun Pajak harus 4 digit'),
npwp: z.string().min(10, 'NPWP wajib diisi'),
nik: z.string().min(10, 'NIK wajib diisi'),
nama: z.string().min(2, 'Nama wajib diisi'),
dpp: z.string().min(1, 'DPP wajib diisi'),
tarif: z.string().min(1, 'Tarif wajib diisi'),
pphDipotong: z.string().min(1, 'PPh Dipotong wajib diisi'),
tglPemotongan: z.string().min(8, 'Format tgl: DDMMYYYY'),
glAccount: z.string().min(3, 'GL Account wajib diisi'),
});
const NrRekamView = () => {
const [isOpenPanduan, setIsOpenPanduan] = useState<boolean>(false);
const [isCheckedAgreement, setIsCheckedAgreement] = useState<boolean>(false);
const { data, isLoading, isError } = useGetKodeObjekPajak();
type BpuFormData = z.infer<typeof bpuSchema>;
const handleOpenPanduan = () => setIsOpenPanduan(!isOpenPanduan);
const defaultValues = {
npwpPemotong: '',
idTku: '',
masaPajak: '',
tahunPajak: '',
npwp: '',
nik: '',
nama: '',
dpp: '',
tarif: '',
pphDipotong: '',
tglPemotongan: '',
glAccount: '',
fgFasilitas: '',
};
const methods = useForm({
mode: 'all',
resolver: zodResolver(bpuSchema),
defaultValues,
});
const {
reset,
handleSubmit,
formState: { isSubmitting },
} = methods;
const SubmitRekam = () => {
console.log('Submit API');
};
return (
<>
<DashboardContent>
<CustomBreadcrumbs
heading="Add Bupot Unifikasi Non Residen"
links={[
{ name: 'Dashboard', href: paths.dashboard.root },
{ name: 'e-Bupot Unifikasi Non Residen', href: paths.unifikasi.nr },
{ name: 'Add Bupot Unifikasi Non Residen' },
]}
/>
<HeadingRekam label="Rekam Data Bukti Potong PPh Non Residen" />
<Grid container columnSpacing={2} /* container otomatis */>
<Grid size={{ xs: isOpenPanduan ? 8 : 11 }}>
<form onSubmit={methods.handleSubmit(SubmitRekam)}>
<FormProvider {...methods}>
<Suspense fallback={<FormSkeleton />}>
<Identitas isPengganti={true} />
<Divider />
<Suspense fallback={<FormSkeleton />}>
<PphDipotong kodeObjectPajak={data?.data ?? []} />
</Suspense>
<DokumenReferensi />
<Divider />
<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" gap={2} justifyContent="end" marginTop={2}>
<LoadingButton
type="submit"
// loading={saveDn.isLoading}
disabled={!isCheckedAgreement}
variant="outlined"
sx={{ color: '#143B88' }}
>
Save as Draft
</LoadingButton>
<LoadingButton
type="button"
disabled={!isCheckedAgreement}
// onClick={handleClickUploadSsp}
// loading={uploadDn.isLoading}
variant="contained"
sx={{ background: '#143B88' }}
>
Save and Upload
</LoadingButton>
</Stack>
</Suspense>
</FormProvider>
</form>
</Grid>
<Grid size={{ xs: isOpenPanduan ? 4 : 1 }}>
<PanduanNrRekam handleOpen={handleOpenPanduan} isOpen={isOpenPanduan} />
</Grid>
</Grid>
</DashboardContent>
</>
);
};
export default NrRekamView;
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