Commit d86bf9fc authored by Fachri's avatar Fachri

BPU Unifikasi Disetor Sendiri

parent cb41f47c
......@@ -3,8 +3,6 @@ import { CONFIG } from 'src/global-config';
// eslint-disable-next-line import/no-unresolved
import { NrListView } from 'src/sections/bupot-unifikasi/bupot-nr/view';
// import { NrListView } from 'src/sections/bupot-unifikasi/bupot-nr/view';
const metadata = { title: `E-Bupot Unifikasi- ${CONFIG.appName}` };
export default function Page() {
......
import { CONFIG } from 'src/global-config';
import { SspListView } from 'src/sections/bupot-unifikasi/bupot-ssp/view/ssp-list-view';
import { SspListView } from 'src/sections/bupot-unifikasi/bupot-ssp/view';
const metadata = { title: `E-Bupot Unifikasi- ${CONFIG.appName}` };
......
......@@ -125,6 +125,7 @@ export const dashboardRoutes: RouteObject[] = [
{ path: 'nr/:id/:type', element: <OverviewUnifikasiRekamNrPage /> },
{ path: 'ssp', element: <OverviewUnifikasiSspPage /> },
{ path: 'ssp/new', element: <OverviewUnifikasiRekamSspPage /> },
{ path: 'ssp/:id/:type', element: <OverviewUnifikasiRekamSspPage /> },
{ path: 'digunggung', element: <OverviewUnifikasiDigunggungPage /> },
{ path: 'digunggung/new', element: <OverviewUnifikasiRekamDigunggungPage /> },
{ path: 'dokumen-dipersamakan', element: <OverviewUnifikasiDokumenDipersamakanPage /> },
......
......@@ -5,13 +5,12 @@ import React, { useEffect } from 'react';
import { Field } from 'src/components/hook-form';
import { JENIS_DOKUMEN } from '../../constant';
import dayjs from 'dayjs';
import { useSelector } from 'react-redux';
import type { RootState } from 'src/store';
import { useAppSelector } from 'src/store';
import { useFormContext } from 'react-hook-form';
const DokumenReferensi = () => {
const { watch, setValue } = useFormContext<Record<string, any>>();
const nitku = useSelector((state: RootState) => state.user.data.nitku_trial);
const nitku = useAppSelector((state) => state.user.data.nitku_trial);
const nitkuValue = watch('idTku');
useEffect(() => {
......@@ -22,37 +21,37 @@ const DokumenReferensi = () => {
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"
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 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"
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>
);
};
......
......@@ -573,6 +573,7 @@ export function DnListView() {
},
'& .MuiDataGrid-columnHeaders': { borderColor: 'divider' },
}}
disableVirtualization
/>
</DashboardContent>
......
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 * as React from 'react';
import { Grid, Typography, Divider, Box } from '@mui/material';
interface ListDetailItem {
label: React.ReactNode;
value: React.ReactNode;
}
interface ListDetailBuilderProps {
rows?: ListDetailItem[];
labelWidth?: number; // optional, default 4
spacingY?: number; // optional, default 2
}
export default function ListDetailBuilder({
rows = [],
labelWidth = 4,
spacingY = 2,
}: ListDetailBuilderProps) {
if (rows.length === 0) return null;
return (
<Box
sx={{
width: '100%',
bgcolor: 'background.paper',
borderRadius: 2,
overflow: 'hidden',
}}
>
{rows.map((row, index) => (
<React.Fragment key={index}>
<Grid container alignItems="flex-start" spacing={2} sx={{ px: 2, py: spacingY }}>
<Grid size={{ xs: 12, md: labelWidth }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: 'text.secondary',
whiteSpace: 'pre-wrap',
}}
>
{row.label}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 12 - labelWidth }}>
<Typography
variant="body2"
sx={{
color: 'text.primary',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{row.value ?? '-'}
</Typography>
</Grid>
</Grid>
{index < rows.length - 1 && <Divider sx={{ mx: 2 }} />}
</React.Fragment>
))}
</Box>
);
}
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/useCancelSsp';
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 ModalCancelSspProps {
dataSelected?: any[];
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
isOpenDialogCancel: boolean;
setIsOpenDialogCancel: (v: boolean) => void;
successMessage?: string;
}
const ModalCancelSsp: React.FC<ModalCancelSspProps> = ({
dataSelected = [],
setSelectionModel,
tableApiRef,
isOpenDialogCancel,
setIsOpenDialogCancel,
successMessage = 'Data berhasil dibatalkan',
}) => {
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 }))
);
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();
}
// ✅ 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 ModalCancelSsp;
import React, { useEffect, useState } from 'react';
import DialogUmum from 'src/shared/components/dialog/DialogUmum';
import DialogContent from '@mui/material/DialogContent';
import ListDetailBuilder from '../ListDetailItem';
import StatusChip from '../StatusChip';
interface ModalCetakPdfSspProps {
payload?: Record<string, any>;
isOpen: boolean;
onClose: () => void;
}
type Row = { label: string; value: React.ReactNode };
const ModalCetakPdfSsp: React.FC<ModalCetakPdfSspProps> = ({ payload, isOpen, onClose }) => {
const [rows, setRows] = useState<Row[]>([]);
useEffect(() => {
if (!payload) return;
const periode =
payload.msPajak && payload.thnPajak ? `${payload.msPajak} - ${payload.thnPajak}` : '-';
const formattedRows: Row[] = [
{ label: 'Periode', value: periode },
{ label: 'Kode Objek Pajak', value: payload.kdObjPjk || '-' },
{ label: 'Nomor Bukti Pemotongan', value: payload.noBupot || '-' },
{
label: 'Identitas Dipotong',
value: payload.nitkuPemotong,
},
{ label: 'Nama Dipotong', value: payload.namaPenandatangan || '-' },
{
label: 'Jumlah Bruto (Rp)',
value: payload.jmlBruto,
},
{
label: 'Tarif (%)',
value: `${Number(payload.tarif || 0)}`,
},
{
label: 'Jumlah Dipotong (Rp)',
value: payload.pphDipotong,
},
{
label: 'Perekam',
value: payload.userId || '-',
},
{
label: 'Created Date',
value: (payload.created_at, 'DD/MM/YYYY HH:mm:ss'),
},
{
label: 'Modified By',
value: payload.updated || '-',
},
{
label: 'Modified Date',
value: (payload.updated_at, 'DD/MM/YYYY HH:mm:ss'),
},
{
label: 'Status',
value: <StatusChip value={payload.fgStatus} revNo={payload.revNo} />,
},
];
setRows(formattedRows);
}, [payload]);
return (
<DialogUmum
maxWidth="lg"
isOpen={isOpen}
onClose={onClose}
title="Detail Bupot Disetor Sendiri"
>
<DialogContent classes={{ root: 'p-16 sm:p-24' }}>
<ListDetailBuilder rows={rows} />
</DialogContent>
</DialogUmum>
);
};
export default ModalCetakPdfSsp;
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 useDeleteDn from '../../hooks/useDeleteSsp';
interface ModalDeleteSspProps {
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 ModalDeleteSsp: React.FC<ModalDeleteSspProps> = ({
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 ModalDeleteSsp;
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 { LoadingButton } from '@mui/lab';
interface ModalUploadSspProps {
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 ModalUploadSsp: React.FC<ModalUploadSspProps> = ({
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' });
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}>
<LoadingButton
type="button"
disabled={!isCheckedAgreement}
onClick={async () => {
if (onConfirmUpload) {
await onConfirmUpload();
setIsOpenDialogUpload(false);
return;
}
await onSubmit();
}}
loading={uploadNr.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 ModalUploadSsp;
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import MenuItem from '@mui/material/MenuItem';
import { useEffect } from 'react';
import { Field } from 'src/components/hook-form';
import { JENIS_DOKUMEN } from 'src/sections/bupot-unifikasi/bupot-dn/constant';
import { JENIS_DOKUMEN } from '../../constant';
import dayjs from 'dayjs';
import { useAppSelector } from 'src/store';
import { useFormContext } from 'react-hook-form';
const DokumenReferensi = () => {
const MockNitku = [
{
nama: '1091031210912281000000',
},
{
nama: '1091031210912281000001',
},
];
const { watch, setValue } = useFormContext<Record<string, any>>();
const nitku = useAppSelector((state) => state.user.data.nitku_trial);
const nitkuValue = watch('idTku');
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.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 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"
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>
);
};
......
import Grid from '@mui/material/Grid';
// import { useParams } from 'react-router';
import dayjs from 'dayjs';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
type IdentitasProps = {
isPengganti: boolean;
// disabledTambah: boolean;
// disabledHapus: boolean;
existingSsp?: any; // Data penuh dari API (opsional, untuk edit/pengganti)
};
const Identitas = ({ isPengganti }: IdentitasProps) => (
// const { dnId } = useParams();
// const { setValue } = useFormContext();
const Identitas = ({ isPengganti, existingSsp }: IdentitasProps) => {
const { setValue, watch } = useFormContext();
// const [jumlahKeterangan, setJumlahKeterangan] = useState<number>(0);
const tanggalPemotongan = watch('tglPemotongan');
// const maxKeterangan = 5;
// 🧩 Auto isi Tahun & Masa Pajak berdasarkan tanggalPemotongan
useEffect(() => {
if (tanggalPemotongan) {
const date = dayjs(tanggalPemotongan);
setValue('thnPajak', date.format('YYYY'));
setValue('msPajak', date.format('MM'));
} else {
setValue('thnPajak', '');
setValue('msPajak', '');
}
}, [tanggalPemotongan, setValue]);
// 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 (
<>
{/* 📋 Identitas Dasar */}
<Grid container rowSpacing={2} alignItems="center" columnSpacing={2}>
{/* 📅 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={isPengganti}
/>
</Grid>
<Grid size={{ md: 3 }}>
<Field.DatePicker
name="msPajak"
label="Masa Pajak"
view="month"
format="MM"
disabled={isPengganti}
/>
</Grid>
</Grid>
</>
);
};
<Grid container rowSpacing={2} alignItems="center" columnSpacing={2}>
<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>
);
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_SSP } from '../../constant';
interface PanduanSspRekamProps {
interface PanduanDnRekamProps {
handleOpen: () => void;
isOpen: boolean;
}
const PanduanSspRekam: FC<PanduanSspRekamProps> = ({ handleOpen, isOpen }) => (
<Box position="sticky">
{/* Tombol toggle */}
<Box
height="100%"
display={isOpen ? 'none' : 'flex'}
justifyContent="center"
alignItems="center"
>
const PanduanSspRekam: FC<PanduanDnRekamProps> = ({ handleOpen, isOpen }) => (
<Box position="sticky">
{/* Tombol toggle */}
{!isOpen && (
<Box height="100%" display="flex" justifyContent="center" alignItems="center">
<Button
variant="contained"
sx={{
......@@ -47,103 +44,116 @@ const PanduanSspRekam: FC<PanduanSspRekamProps> = ({ handleOpen, isOpen }) => (
</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={{
{/* 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',
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_SSP.description.intro}
</Typography>
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_SSP.description.intro}
</Typography>
<Typography variant="body2" sx={{}}>
{PANDUAN_REKAM_SSP.description.textList}
</Typography>
<Box component="ol" sx={{ pl: 3, mb: 2 }}>
{PANDUAN_REKAM_SSP.description.list.map((item, idx) => (
<Typography key={idx} variant="body2" component="li">
{item}
</Typography>
))}
</Box>
<Typography variant="body2">{PANDUAN_REKAM_SSP.description.textList}</Typography>
<Box component="ol" sx={{ pl: 3, mb: 2 }}>
{PANDUAN_REKAM_SSP.description.list.map((item, idx) => (
<Typography key={`desc-${idx}`} variant="body2" component="li">
{item}
</Typography>
))}
</Box>
<Typography variant="body2" sx={{ mb: 2 }}>
{PANDUAN_REKAM_SSP.description.closing}
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
{PANDUAN_REKAM_SSP.description.closing}
</Typography>
{/* Bagian-bagian */}
{PANDUAN_REKAM_SSP.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>
{/* Bagian-bagian */}
{PANDUAN_REKAM_SSP.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) => (
<Box key={idx} component="li" sx={{ mb: 0.5 }}>
<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 && (
{item.subItems?.length > 0 && (
<Box component="ol" sx={{ pl: 3, listStyle: 'decimal' }}>
{item.subItems.map((sub, subIdx) => (
<Typography key={subIdx} variant="body2" component="li">
<Typography
key={`sub-${i}-${idx}-${subIdx}`}
variant="body2"
component="li"
>
{sub}
</Typography>
))}
</Box>
)}
</Box>
))}
</Box>
</Fragment>
))}
</Box>
))}
</CardContent>
</Card>
</m.div>
)}
</Box>
);
</Box>
))}
</CardContent>
</Card>
</m.div>
)}
</Box>
);
export default PanduanSspRekam;
export default memo(PanduanSspRekam);
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],
ssp: {
all: (params: any) => [appRootKey, 'ssp', params],
detail: (params: any) => [appRootKey, 'ssp', 'detail', params],
draft: [appRootKey, 'ssp', 'draft'],
delete: [appRootKey, 'ssp', 'delete'],
upload: [appRootKey, 'ssp', 'upload'],
cancel: [appRootKey, 'ssp', 'cancel'],
cetakPdf: (params: any) => [appRootKey, 'ssp-cetak-pdf', params],
},
};
export default queryKey;
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: 'noBupot',
};
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 { TCancelSspRequest, TCancelSspResponse } from '../types/types';
import sspApi from '../utils/api';
const useCancelSsp = (props?: any) =>
useMutation<TCancelSspResponse, Error, TCancelSspRequest>({
mutationKey: ['cancel-ssp'],
mutationFn: (payload) => sspApi.cancel(payload),
...props,
});
export default useCancelSsp;
import { useMutation } from '@tanstack/react-query';
import type { TBaseResponseAPI, TDeleteSspRequest } from '../types/types';
import sspApi from '../utils/api';
const useDeleteDn = (props?: any) =>
useMutation<TBaseResponseAPI<null>, Error, TDeleteSspRequest>({
mutationKey: ['delete-ssp'],
mutationFn: (payload) => sspApi.deleteNr(payload),
...props,
});
export default useDeleteDn;
import { useQuery } from '@tanstack/react-query';
import type { TBaseResponseAPI, TGetListDataKOPSspResult } from '../types/types';
import queryKey from '../constant/queryKey';
import sspApi from '../utils/api';
const useGetKodeObjekPajakNr = (params?: Record<string, any>) =>
useQuery<TBaseResponseAPI<TGetListDataKOPSspResult>>({
queryKey: queryKey.getKodeObjekPajak(params),
queryFn: () => sspApi.getKodeObjekPajakNr(params),
});
export default useGetKodeObjekPajakNr;
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 queryKey from '../constant/queryKey';
import type { TGetListDataTableSsp, TGetListDataTableSspResult } from '../types/types';
import sspApi from '../utils/api';
export type TGetSspApiWrapped = {
data: TGetListDataTableSspResult[];
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 normalisePropsGetSsp = (params: TGetListDataTableSsp) => ({
...params,
namaDok: params.dokumen_referensi?.[0]?.namaDok || '',
nomorDok: params.dokumen_referensi?.[0].nomorDok || '',
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.thnPajak,
msPajak: params.msPajak,
kdObjPjk: params.kdObjPjk,
noBupot: params.noBupot,
idDipotong: params.userId,
glAccount: params.glAccount,
namaDipotong: params.nama,
jmlBruto: params.jmlBruto,
dpp: params.jmlBruto ?? '',
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 normalizeExistingSsp = (res: TGetListDataTableSsp) => ({
// // 🧾 Data Pajak Utama
// tglPemotongan: res.tglpemotongan ?? '',
// thnPajak: res.thnPajak ?? '',
// msPajak: res.msPajak ?? '',
// // 👤 Identitas Dipotong
// idDipotong: res.npwpPemotong ?? '',
// // 💰 Pajak dan Penghasilan
// kodeObjekPajak: res.kdObjPjk ?? '',
// fgFasilitas: res.sertifikatInsentifDipotong ?? '',
// noDokLainnya: res.nomorSertifikatInsentif ?? '',
// jmlBruto: res.jmlBruto ?? '',
// tarif: String(res.tarif ?? ''),
// pphDipotong: String(res.pphDipotong ?? ''),
// // 📄 Dokumen Referensi
// namaDok: res.dokumen_referensi?.[0]?.namaDok ?? '',
// nomorDok: res.dokumen_referensi?.[0]?.nomorDok ?? '',
// tglDok: res.dokumen_referensi?.[0]?.tglDok ?? '',
// // 🏢 Cabang / Unit
// idTku: res.idTku ?? '',
// // 🆔 Metadata tambahan
// idBupot: res.idBupot ?? '',
// noBupot: res.noBupot ?? '',
// revNo: res.revNo ?? 0,
// });
export const normalizeExistingSsp = (res: TGetListDataTableSsp) => ({
// 🧾 Data Pajak Utama
tglPemotongan: res.tglpemotongan ?? '',
thnPajak: res.thnPajak ?? '',
msPajak: res.msPajak ?? '',
// 👤 Identitas Dipotong
idDipotong: res.npwpPemotong ?? '',
// 💰 Pajak dan Penghasilan
kdObjPjk: res.kdObjPjk ?? '',
fgFasilitas: res.fgFasilitas ?? '', // ✅ FIX
noDokLainnya: res.noDokLainnya ?? '', // ✅ FIX
jmlBruto: res.jmlBruto ?? '',
dpp: res.jmlBruto ?? '',
tarif: String(res.tarif ?? ''),
pphDipotong: String(res.pphDipotong ?? ''),
// ⚙️ Tambahan field penting
pasalPph: res.pasalPph ?? '',
statusPPh: res.statusPPh ?? '',
kap: res.kap ?? '',
kjs: res.kjs ?? '',
// 📄 Dokumen Referensi
namaDok: res.dokumen_referensi?.[0]?.namaDok ?? '',
nomorDok: res.dokumen_referensi?.[0]?.nomorDok ?? '',
tglDok: res.dokumen_referensi?.[0]?.tglDok ?? '',
// 🏢 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,
};
};
export const useGetSsp = ({ params }: { params: any }) => {
const { page, limit, advanced, sortingMode, sortingMethod } = params;
const normalized = normalizeParams(params);
return useQuery<TGetSspApiWrapped>({
queryKey: ['ssp', page, limit, advanced, sortingMode, sortingMethod],
queryFn: async () => {
const res: any = await sspApi.getSsp({ params: normalized });
const rawData: any[] = Array.isArray(res?.data) ? res.data : res?.data ? [res.data] : [];
const total = Number(res?.total ?? res?.totalRow ?? 0);
console.log(rawData);
let dataArray: TGetListDataTableSspResult[] = [];
console.log('✅ Normalized SSP row:', dataArray[0]);
const normalizeWithWorker = () =>
new Promise<TGetListDataTableSspResult[]>((resolve, reject) => {
try {
const worker = new Worker(
new URL('../workers/normalizeSsp.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 TGetListDataTableSspResult[]);
}
};
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');
console.log(normalisePropsGetSsp);
dataArray = rawData.map(normalisePropsGetSsp) as unknown as TGetListDataTableSspResult[];
}
} catch (err) {
console.error('❌ Worker failed, fallback to sync normalize:', err);
dataArray = rawData.map(normalisePropsGetSsp) as unknown as TGetListDataTableSspResult[];
}
return {
data: dataArray,
total,
pageSize: normalized.limit,
page: normalized.page,
};
},
placeholderData: (prev: any) => prev,
refetchOnWindowFocus: false,
refetchOnMount: false,
staleTime: 0,
gcTime: 0,
retry: false,
});
};
export const useGetSspById = (id: string, options = {}) =>
useQuery({
queryKey: queryKey.ssp.detail(id),
queryFn: async () => {
const res = await sspApi.getSspById(id);
console.log(res);
if (!res) throw new Error('Data tidak ditemukan');
const normalized = normalizeExistingSsp(res);
console.log('✅ Normalized data:', normalized);
return normalized;
},
enabled: !!id,
refetchOnWindowFocus: false,
...options,
});
export default useGetSsp;
/* eslint-disable @typescript-eslint/no-shadow */
import { useEffect } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import type { TGetListDataKOPSsp } from '../types/types';
const usePphDipotong = (kodeObjekPajakSelected?: TGetListDataKOPSsp) => {
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 { TPostSspRequest } from '../types/types';
import sspApi from '../utils/api';
/**
* Transformasi data form menjadi payload untuk endpoint IF_TXR_070
*/
const transformParams = ({ isPengganti = false, ...sspData }: any): TPostSspRequest => {
const {
id,
idBupot,
noBupot,
kanal,
npwpPemotong,
idTku,
msPajak,
tahunPajak,
fgFasilitas,
noDokLainnya,
kdObjPjk,
pasalPph,
statusPph,
dpp,
tarif,
pphDipotong,
kap,
kjs,
dokReferensi,
tglPemotongan,
userId,
revNo: initialRevNo,
glAccount,
namaDok,
nomorDok,
tglDok,
} = sspData;
const revNo = isPengganti
? parseInt(initialRevNo?.toString() || '0', 10) + 1
: parseInt(initialRevNo?.toString() || '0', 10);
return {
id: !isPengganti ? (id ?? null) : null,
noBupot: noBupot ?? '',
idBupot: idBupot ?? '',
kanal: kanal ?? '14',
npwpPemotong: npwpPemotong ?? '',
idTku: idTku ?? '',
masaPajak: msPajak ? String(msPajak).padStart(2, '0') : '',
tahunPajak: Number(tahunPajak ?? dayjs().year()),
sertifikatInsentifDipotong: fgFasilitas ?? '9',
nomorSertifikatInsentif: noDokLainnya ?? null,
kodeObjekPajak: kdObjPjk ?? '',
pasalPph: pasalPph ?? '',
statusPph: statusPph ?? '',
dpp: Number(dpp ?? 0),
tarif: Number(tarif ?? 0),
pphDipotong: Number(pphDipotong ?? 0),
kap: Number(kap ?? 0),
kjs: Number(kjs ?? 0),
dokReferensi: (() => {
// fallback dari namaDok, nomorDok, tglDok
if (dokReferensi && Array.isArray(dokReferensi) && dokReferensi.length > 0)
return dokReferensi;
if (!namaDok || !nomorDok || !tglDok) return [];
const parsedDate = dayjs(tglDok);
const formatted = parsedDate.isValid() ? parsedDate.format('DDMMYYYY') : '';
return [
{
dokReferensi: namaDok,
nomorDokumen: nomorDok,
tanggal_Dokumen: formatted,
},
];
})(),
tglPemotongan: tglPemotongan ? dayjs(tglPemotongan).format('DDMMYYYY') : '',
userId: userId ?? '',
revNo,
glAccount: glAccount ?? '',
};
};
/**
* Hook untuk menyimpan data SSP ke endpoint IF_TXR_070
*/
const useSaveSsp = (props?: any) =>
useMutation({
mutationKey: ['save-ssp'],
mutationFn: (params: any) => sspApi.saveSsp(transformParams(params)),
...props,
});
export default useSaveSsp;
// hooks/useUpload.ts
import { useMutation } from '@tanstack/react-query';
import nrApi from '../utils/api';
const useUpload = (props?: any) =>
useMutation({
mutationKey: ['upload-nr'],
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 TGetListDataTableSsp = {
id: number;
npwpPemotong: string;
idTku: string;
msPajak: string;
thnPajak: string;
fgNpwpNik: string;
npwp: string;
nik: string;
nama: string;
sertifikatInsentifDipotong: string;
noDokLainnya: string;
kdObjPjk: string;
pasalPph: string;
statusPPh: string;
jmlBruto: 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: TDokReferensi[];
revNo: number;
noBupot: string;
idBupot: string;
npwpNikPenandatangan: string;
namaPenandatangan: string;
link: string | null;
errorMsg: string | null;
email: string | null;
glAccount: string;
fgkirimemail: string;
fgFasilitas: string;
};
export type TGetListDataTableSspResult = TGetListDataTableSsp[];
export type TGetListDataKOPSsp = {
kode: string;
nama: string;
pasal: string;
statuspph: string;
normanetto: string;
tarif: string;
kap: string;
kjs: string;
noCertificate: number;
certofDomicile: number;
otherCert: number;
};
export type TGetListDataKOPSspResult = TGetListDataKOPSsp[];
export type ActionItem = {
title: string;
icon: React.ReactNode;
func?: () => void;
disabled?: boolean;
};
export type TDokReferensi = {
namaDok: string;
nomorDok: string;
tglDok: string; // format: DDMMYYYY
};
export type TPostSspRequest = {
id: string | null;
idBupot: string;
noBupot: string;
kanal: string;
npwpPemotong: string;
idTku: string;
masaPajak: string;
tahunPajak: number;
sertifikatInsentifDipotong: string;
nomorSertifikatInsentif: string | null;
kodeObjekPajak: string;
pasalPph: string;
statusPph: string;
dpp: number;
tarif: number;
pphDipotong: number;
kap: number;
kjs: number;
dokReferensi: TDokReferensi[];
tglPemotongan: string;
userId: string;
revNo: number;
glAccount: string;
};
export type TPostUpload = {
id: string;
};
export type TDeleteSspRequest = {
id: string;
};
export type TCancelSspRequest = {
id: string | number;
tglPembatalan: string; // format: DDMMYYYY
};
export type TCancelSspResponse = TBaseResponseAPI<{
id: string | number;
statusBatal?: string;
message?: string;
}>;
import type {
TBaseResponseAPI,
TCancelSspRequest,
TCancelSspResponse,
TDeleteSspRequest,
TGetListDataKOPSspResult,
TGetListDataTableSspResult,
TPostSspRequest,
} from '../types/types';
import unifikasiClient from './unifikasiClient';
const sspApi = () => {};
// API untuk get list table
sspApi.getSsp = async (config: any) => {
const {
data: { message, metaPage, data },
status: statusCode,
} = await unifikasiClient.get<TBaseResponseAPI<TGetListDataTableSspResult>>('IF_TXR_070/', {
...config,
});
if (statusCode !== 200) {
throw new Error(message);
}
return { total: metaPage ? Number(metaPage.totalRow) : 0, data };
};
sspApi.getKodeObjekPajakNr = async (params?: Record<string, any>) => {
const response = await unifikasiClient.get<TBaseResponseAPI<TGetListDataKOPSspResult>>(
'/sandbox/mst_kop_bpsp',
{ params }
);
const body = response.data;
if (response.status !== 200 || body.status !== 'success') {
throw new Error(body.message);
}
return body;
};
sspApi.saveSsp = async (config: TPostSspRequest) => {
const {
data: { message, data, code },
} = await unifikasiClient.post<TBaseResponseAPI<TPostSspRequest>>('/IF_TXR_070/', {
...config,
});
if (code === 0) {
throw new Error(message);
}
return data;
};
sspApi.getSspById = async (id: string) => {
const res = await unifikasiClient.get('/IF_TXR_070/', { params: { id } });
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;
};
sspApi.upload = async ({ id }: { id: string | number }) => {
const {
data: { status, message, data, code },
status: statusCode,
} = await unifikasiClient.post('/IF_TXR_070/upload', { id });
return { status, message, data, code, statusCode };
};
sspApi.deleteNr = async (
payload: TDeleteSspRequest,
config?: Record<string, any>
): Promise<any> => {
const {
data: { status, message, data },
status: statusCode,
} = await unifikasiClient.post<TBaseResponseAPI<any>>('/IF_TXR_070/delete', payload, {
...config,
});
if (statusCode !== 200 || status?.toLowerCase() === 'error') {
throw new Error(message || 'Gagal menghapus data NR');
}
return data;
};
sspApi.cancel = async ({ id, tglPembatalan }: TCancelSspRequest): Promise<TCancelSspResponse> => {
const {
data: { status, message, data, code, time, metaPage, total },
} = await unifikasiClient.post('/IF_TXR_070/batal', {
id,
tglPembatalan,
});
console.log('Cancel SSP response:', { code, message, status });
if (code === 0) {
throw new Error(message || 'Gagal membatalkan data');
}
return {
status,
message,
data,
code,
time,
metaPage,
total,
};
};
export default sspApi;
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 : '';
};
// export * from './ssp-list-view';
export * from './ssp-list-view';
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