Commit dbdbc079 authored by Fachri's avatar Fachri

Merge branch 'develop' into 'main'

bpu non residen

See merge request !1
parents 9ffe9ff5 4752248f
......@@ -6,6 +6,10 @@ import { LicenseInfo } from '@mui/x-license';
import App from './app';
import { routesSection } from './routes/sections';
import { ErrorBoundary } from './routes/components';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
dayjs.extend(customParseFormat);
// ----------------------------------------------------------------------
LicenseInfo.setLicenseKey(
......
import { CONFIG } from 'src/global-config';
import { DashboardView } from 'src/sections/dashboard/view';
// import { OverviewAppView } from 'src/sections/overview/app/view';
// ----------------------------------------------------------------------
const metadata = { title: `Dashboard - ${CONFIG.appName}` };
export default function OverviewAppPage() {
return (
<>
<title>{metadata.title}</title>
{/* <OverviewAppView /> */}
{/* aaa */}
<DashboardView />
</>
);
......
import { CONFIG } from 'src/global-config';
import { NrListView } from 'src/sections/bupot-unifikasi/bupot-nr/view/nr-list-view';
// 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}` };
......
......@@ -143,30 +143,5 @@ export const paths = {
edit: (id: string) => `${ROOTS.DASHBOARD}/user/${id}/edit`,
demo: { edit: `${ROOTS.DASHBOARD}/user/${MOCK_ID}/edit` },
},
product: {
root: `${ROOTS.DASHBOARD}/product`,
new: `${ROOTS.DASHBOARD}/product/new`,
details: (id: string) => `${ROOTS.DASHBOARD}/product/${id}`,
edit: (id: string) => `${ROOTS.DASHBOARD}/product/${id}/edit`,
demo: {
details: `${ROOTS.DASHBOARD}/product/${MOCK_ID}`,
edit: `${ROOTS.DASHBOARD}/product/${MOCK_ID}/edit`,
},
},
invoice: {
root: `${ROOTS.DASHBOARD}/invoice`,
new: `${ROOTS.DASHBOARD}/invoice/new`,
details: (id: string) => `${ROOTS.DASHBOARD}/invoice/${id}`,
edit: (id: string) => `${ROOTS.DASHBOARD}/invoice/${id}/edit`,
demo: {
details: `${ROOTS.DASHBOARD}/invoice/${MOCK_ID}`,
edit: `${ROOTS.DASHBOARD}/invoice/${MOCK_ID}/edit`,
},
},
order: {
root: `${ROOTS.DASHBOARD}/order`,
details: (id: string) => `${ROOTS.DASHBOARD}/order/${id}`,
demo: { details: `${ROOTS.DASHBOARD}/order/${MOCK_ID}` },
},
},
};
......@@ -122,6 +122,7 @@ export const dashboardRoutes: RouteObject[] = [
{ path: 'dn/:id/:type', element: <OverviewUnifikasiRekamDnPage /> },
{ path: 'nr', element: <OverviewUnifikasiNrPage /> },
{ path: 'nr/new', element: <OverviewUnifikasiRekamNrPage /> },
{ path: 'nr/:id/:type', element: <OverviewUnifikasiRekamNrPage /> },
{ path: 'ssp', element: <OverviewUnifikasiSspPage /> },
{ path: 'ssp/new', element: <OverviewUnifikasiRekamSspPage /> },
{ path: 'digunggung', element: <OverviewUnifikasiDigunggungPage /> },
......@@ -140,8 +141,7 @@ export const dashboardRoutes: RouteObject[] = [
{ index: true, element: <OverviewBupotBulananPage /> },
{ path: 'bulanan', element: <OverviewBupotBulananPage /> },
{ path: 'bulanan/rekam', element: <OverviewBupotBulananRekamPage /> },
{ path: 'bulanan/:id/ubah', element: <OverviewBupotBulananRekamPage /> },
{ path: 'bulanan/:id/pengganti', element: <OverviewBupotBulananRekamPage /> },
{ path: 'bulanan/:id/:type', element: <OverviewBupotBulananRekamPage /> },
{ path: 'bupot-final', element: <OverviewBupotFinalTdkFinalPage /> },
{ path: 'tahunan', element: <OverviewBupotA1Page /> },
{ path: 'bupot-26', element: <OverviewBupotPasal26Page /> },
......
import { Close } from '@mui/icons-material';
import { Dialog, DialogContent, DialogTitle, IconButton, Typography } from '@mui/material';
// import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import { useAppSelector } from 'src/store';
interface DialogPenandatanganProps {
isOpen: boolean;
onClose: () => void;
title?: string;
// onSubmit: (data: any) => void;
// isLoadingButtonSubmit: boolean;
// agreementText?: string;
// isPembatalan: boolean;
// isPending: boolean;
// feature: string;
// isWarning: boolean;
// isCountDown: boolean;
}
export default function DialogPenandatangan({
isOpen,
onClose,
title = 'Penandatangan',
}: DialogPenandatanganProps) {
const penandatanganOptions = useAppSelector((state: any) => state.user.data.signer_npwp);
const form = useForm({
mode: 'all',
});
// const [isCheckedAgreement, setIsCheckedAgreement] = useState(false);
const handleClose = () => {
form.reset();
onClose();
};
// const declareOptions = [
// { label: feature === 'spt faktur' ? 'PKP' : 'Wajib Pajak', value: 0 },
// { label: 'Wakil/Kuasa', value: 1 },
// ];
// const handleSubmitLocal = (data: any) => {
// if (isCountDown)
// setCountdown(30); // start countdown saat submit
// else setCountdown(null);
// onSubmit(data); // tetap panggil props onSubmit
// };
return (
<Dialog
fullWidth
maxWidth="md"
open={isOpen}
onClose={handleClose}
aria-labelledby="dialog-rekap"
>
<DialogTitle id="dialog-rekap">
<Typography textTransform="capitalize" fontWeight="bold" variant="inherit" color="initial">
{title}
</Typography>
</DialogTitle>
<IconButton
aria-label="close"
onClick={handleClose}
sx={(theme: any) => ({
position: 'absolute',
right: 8,
top: 8,
color: theme.palette.grey[500],
})}
>
<Close />
</IconButton>
<DialogContent>
{/* <form onSubmit={form.handleSubmit(handleSubmitLocal)}> */}
<Field.Autocomplete
name="nikNpwpTtd"
label="NPWP/NIK Penandatangan"
options={[{ value: penandatanganOptions, label: `NAMA${penandatanganOptions}` }]}
sx={{ background: 'white' }}
/>
{/*
<Agreement
isCheckedAgreement={isCheckedAgreement}
setIsCheckedAgreement={setIsCheckedAgreement}
text={agreementText}
/>
<LoadingButton
loading={isLoadingButtonSubmit}
disabled={!isCheckedAgreement}
variant="contained"
type="submit"
>
Save
</LoadingButton> */}
{/* </form> */}
</DialogContent>
</Dialog>
);
}
import React from 'react';
import type { GridPreferencePanelsValue} from '@mui/x-data-grid-premium';
import { useGridApiContext } from '@mui/x-data-grid-premium';
import { IconButton, Tooltip } from '@mui/material';
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
// ✅ React.memo: cegah render ulang tanpa alasan
const CustomColumnsButton: React.FC = React.memo(() => {
const apiRef = useGridApiContext();
// ✅ useCallback biar referensi handleClick stabil di setiap render
const handleClick = React.useCallback(() => {
if (!apiRef.current) return;
apiRef.current.showPreferences('columns' as GridPreferencePanelsValue);
}, [apiRef]);
return (
<Tooltip title="Kolom">
<IconButton
size="small"
onClick={handleClick}
sx={{
color: '#123375',
'&:hover': { backgroundColor: 'rgba(18, 51, 117, 0.08)' },
}}
>
<ViewColumnIcon fontSize="small" />
</IconButton>
</Tooltip>
);
});
export default CustomColumnsButton;
import * as React from 'react';
import type { GridToolbarProps } from '@mui/x-data-grid-premium';
import { GridToolbarContainer } from '@mui/x-data-grid-premium';
import { Stack, Divider, IconButton, Tooltip } from '@mui/material';
import type { ActionItem } from '../types/types';
import { CustomFilterButton } from './CustomFilterButton';
import CustomColumnsButton from './CustomColumnsButton';
interface CustomToolbarProps extends GridToolbarProps {
actions?: ActionItem[][];
columns: any[]; // GridColDef[]
filterModel: any;
setFilterModel: (m: any) => void;
statusOptions?: { value: string; label: string }[];
}
// ✅ React.memo mencegah render ulang kalau props sama
export const CustomToolbar = React.memo(function CustomToolbar({
actions = [],
columns,
filterModel,
setFilterModel,
statusOptions = [],
...gridToolbarProps
}: CustomToolbarProps) {
return (
<GridToolbarContainer
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 1.5,
}}
{...gridToolbarProps}
>
<Stack direction="row" alignItems="center" gap={1}>
{actions.map((group, groupIdx) => (
<Stack key={groupIdx} direction="row" gap={0.5} alignItems="center">
{group.map((action, idx) => (
<Tooltip key={idx} title={action.title}>
<span>
<IconButton
sx={{ color: action.disabled ? 'action.disabled' : '#123375' }}
size="small"
onClick={action.func}
disabled={action.disabled}
>
{action.icon}
</IconButton>
</span>
</Tooltip>
))}
{groupIdx < actions.length - 1 && <Divider orientation="vertical" flexItem />}
</Stack>
))}
</Stack>
<Stack direction="row" alignItems="center" gap={0.5}>
<CustomColumnsButton />
<CustomFilterButton
columns={columns}
filterModel={filterModel}
setFilterModel={setFilterModel}
statusOptions={statusOptions}
/>
</Stack>
</GridToolbarContainer>
);
});
import { LoadingButton } from '@mui/lab';
import { Grid, MenuItem, Stack } from '@mui/material';
import type { GridRowSelectionModel } from '@mui/x-data-grid-premium';
import { useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import Agreement from 'src/shared/components/agreement/Agreement';
import DialogProgressBar from 'src/shared/components/dialog/DialogProgressBar';
import DialogUmum from 'src/shared/components/dialog/DialogUmum';
import useDialogProgressBar from 'src/shared/hooks/useDialogProgressBar';
import { useAppSelector } from 'src/store';
import queryKey from '../constant/queryKey';
import useUploadBulanan from '../hooks/useUploadeBulanan';
interface DialogPenandatanganProps {
dataSelected?: GridRowSelectionModel;
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
isOpenDialogUpload: boolean;
setIsOpenDialogUpload: (v: boolean) => void;
successMessage?: string;
onConfirmUpload?: () => Promise<void> | void;
}
const normalizeSelection = (sel?: any): (string | number)[] => {
if (!sel) return [];
if (Array.isArray(sel)) return sel as (string | number)[];
if (sel instanceof Set) return Array.from(sel) as (string | number)[];
if (typeof sel === 'object') {
// common shape from newer MUI: { ids: Set(...), type: 'include' }
if (sel.ids instanceof Set) return Array.from(sel.ids) as (string | number)[];
// maybe it's a map-like object { '1': true, '2': true }
const maybeIds = Object.keys(sel).filter((k) => k !== 'type' && k !== 'size');
if (maybeIds.length > 0) {
// try to convert numeric-like keys to number where applicable
return maybeIds.map((k) => {
const n = Number(k);
return Number.isNaN(n) ? k : n;
});
}
}
return [];
};
const DialogPenandatangan: React.FC<DialogPenandatanganProps> = ({
dataSelected,
setSelectionModel,
tableApiRef,
isOpenDialogUpload,
setIsOpenDialogUpload,
successMessage = 'Data berhasil diupload',
onConfirmUpload,
}) => {
const [isOpenDialogProgressBar, setIsOpenDialogProgressBar] = useState(false);
const [isCheckedAgreement, setIsCheckedAgreement] = useState<boolean>(false);
const signer = useAppSelector((state: any) => state.user.data.signer);
const queryClient = useQueryClient();
const methods = useForm({
defaultValues: {
signer: signer || '',
},
});
const {
numberOfData,
setNumberOfData,
numberOfDataFail,
numberOfDataProcessed,
numberOfDataSuccess,
processSuccess,
processFail,
resetToDefault,
status,
} = useDialogProgressBar();
const { mutateAsync, isPending } = useUploadBulanan({
onSuccess: () => processSuccess(),
onError: () => processFail(),
});
const handleMultipleDelete = async () => {
const ids = normalizeSelection(dataSelected);
return Promise.allSettled(ids.map(async (id) => mutateAsync({ id: String(id) })));
};
const clearSelection = () => {
// clear grid selection via apiRef if available
tableApiRef?.current?.setRowSelectionModel?.([]);
// also clear local state if setter provided
// set to undefined to follow the native hook type (or to empty array cast)
setSelectionModel?.(undefined);
};
const handleCloseModal = () => {
setIsOpenDialogUpload(false);
resetToDefault();
};
const onSubmit = async () => {
try {
setIsOpenDialogProgressBar(true);
await handleMultipleDelete();
enqueueSnackbar(successMessage, { variant: 'success' });
handleCloseModal();
clearSelection();
} catch (error: any) {
enqueueSnackbar(error?.message || 'Gagal upload data', { variant: 'error' });
} finally {
// sesuaikan queryKey jika perlu; tetap panggil invalidasi
queryClient.invalidateQueries({ queryKey: queryKey.bulanan.all('') });
}
};
useEffect(() => {
setNumberOfData(normalizeSelection(dataSelected).length);
}, [isOpenDialogUpload, dataSelected, setNumberOfData]);
return (
<>
<FormProvider {...methods}>
<DialogUmum
isOpen={isOpenDialogUpload}
onClose={handleCloseModal}
title="Upload Bukti Potong"
>
<Stack spacing={2} sx={{ mt: 2 }}>
<Grid size={{ md: 12 }}>
<Field.Select name="signer" label="NPWP/NIK Penandatangan">
<MenuItem value={signer}>{signer}</MenuItem>
</Field.Select>
</Grid>
<Grid size={12}>
<Agreement
isCheckedAgreement={isCheckedAgreement}
setIsCheckedAgreement={setIsCheckedAgreement}
text="Dengan ini saya menyatakan bahwa Bukti Pemotongan/Pemungutan Unifikasi telah saya isi dengan benar secara elektronik sesuai dengan"
/>
</Grid>
<Stack direction="row" justifyContent="flex-end" spacing={1} mt={1}>
<LoadingButton
type="button"
disabled={!isCheckedAgreement}
// onClick={onSubmit}
onClick={async () => {
if (onConfirmUpload) {
await onConfirmUpload();
setIsOpenDialogUpload(false);
return;
}
await onSubmit();
}}
loading={isPending}
variant="contained"
sx={{ background: '#143B88' }}
>
Save
</LoadingButton>
</Stack>
</Stack>
</DialogUmum>
</FormProvider>
<DialogProgressBar
isOpen={isOpenDialogProgressBar}
handleClose={() => {
handleCloseModal();
setIsOpenDialogProgressBar(false);
}}
numberOfData={numberOfData}
numberOfDataProcessed={numberOfDataProcessed}
numberOfDataFail={numberOfDataFail}
numberOfDataSuccess={numberOfDataSuccess}
status={status}
/>
</>
);
};
export default DialogPenandatangan;
......@@ -206,20 +206,6 @@ export const FG_BUPOT = {
BULANAN: '1',
};
// export const FG_STATUS = {
// NORMAL: '0',
// NORMAL_PENGGANTI: '1',
// DIGANTI: '2',
// BATAL: '3',
// HAPUS: '4',
// SUBMITTED: '5',
// DRAFT: '6',
// FAILED: '7',
// PENDING: '8',
// EXTERNAL: '9',
// ON_SCHEDULE: '10',
// };
export const FG_STATUS: Record<string, string> = {
'0': 'NORMAL',
'1': 'NORMAL_PENGGANTI',
......@@ -234,6 +220,13 @@ export const FG_STATUS: Record<string, string> = {
'10': 'ON_SCHEDULE',
};
export const FG_STATUS_BUPOT = {
DRAFT: 'DRAFT',
NORMAL_DONE: 'NORMAL-Done',
AMENDED: 'AMENDED',
CANCELLED: 'CANCELLED',
};
export const FG_PDF_STATUS = {
TERBENTUK: '0',
BELUM_TERBENTUK: '1',
......
const appRootKey = 'bupot';
export const appRootKey = 'bupot-21-26';
export const bulanan = 'bulanan';
const queryKey = {
getKodeObjekPajak: (params: any) => [appRootKey, 'kode-objek-pajak', params],
......
type FilterItem = {
field: string;
operator: string;
value?: string | number | Array<string | number> | null;
join?: 'AND' | 'OR';
};
type BaseParams = Record<string, any>;
export function useAdvancedFilter() {
const numericFields = new Set(['masaPajak', 'tahunPajak', 'dpp', 'pphDipotong']);
const dateFields = new Set(['created_at', 'updated_at']);
const fieldMap: Record<string, string> = {
noBupot: 'nomorBupot',
};
const dbField = (field: string) => fieldMap[field] ?? field;
const escape = (v: string) => String(v).replace(/'/g, "''");
const toDbDate = (value: string | Date) => {
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}${m}${d}`;
}
const digits = String(value).replace(/[^0-9]/g, '');
if (digits.length >= 8) return digits.slice(0, 8);
return digits;
};
const normalizeOp = (op: string) => op?.toString().trim();
function buildAdvancedFilter(filters?: FilterItem[] | null) {
if (!filters || filters.length === 0) return '';
const exprs: string[] = [];
const joins: ('AND' | 'OR')[] = [];
for (let i = 0; i < filters.length; i++) {
const f = filters[i];
if (!f || !f.field) continue;
const op = normalizeOp(f.operator ?? '');
const fieldName = dbField(f.field);
let expr: string | null = null;
// --- DATE FIELDS ---
if (dateFields.has(fieldName)) {
const rawVal = f.value;
if (!rawVal && !/is empty|is not empty/i.test(op)) continue;
const ymd = toDbDate(rawVal as string | Date);
if (!ymd) continue;
if (/^is$/i.test(op)) {
expr = `"${fieldName}" >= '${ymd} 00:00:00' AND "${fieldName}" <= '${ymd} 23:59:59'`;
} else if (/is on or after/i.test(op)) {
expr = `"${fieldName}" >= '${ymd}'`;
} else if (/is on or before/i.test(op)) {
expr = `"${fieldName}" <= '${ymd}'`;
}
}
// --- EMPTY ---
if (/is empty/i.test(op)) {
expr = `LOWER("${fieldName}") IS NULL`;
} else if (/is not empty/i.test(op)) {
expr = `LOWER("${fieldName}") IS NOT NULL`;
}
// --- IS ANY OF ---
if (!expr && /is any of/i.test(op)) {
let values: Array<string | number> = [];
if (Array.isArray(f.value)) values = f.value as any;
else if (typeof f.value === 'string')
values = f.value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
else if (f.value != null) values = [f.value as any];
if (values.length > 0) {
if (fieldName === 'fgStatus' || fieldName === 'fg_status') {
const ors = values.map((v) => {
const s = escape(String(v).toLowerCase());
return `LOWER("${fieldName}") LIKE LOWER('%${s}%')`;
});
expr = `(${ors.join(' OR ')})`;
} else {
const ors = values.map((v) => {
const s = escape(String(v).toLowerCase());
return `LOWER("${fieldName}") = '${s}'`;
});
expr = `(${ors.join(' OR ')})`;
}
}
}
// --- FGSTATUS special single-value is / is not ---
if (!expr && (fieldName === 'fgStatus' || fieldName === 'fg_status')) {
const valRaw = f.value == null ? '' : String(f.value);
if (valRaw !== '' || /is any of|is empty|is not empty/i.test(op)) {
const valEscaped = escape(valRaw.toLowerCase());
if (/^is$/i.test(op)) {
expr = `LOWER("${fieldName}") LIKE LOWER('%${valEscaped}%')`;
} else if (/is not/i.test(op)) {
expr = `LOWER("${fieldName}") NOT LIKE LOWER('%${valEscaped}%')`;
}
}
}
// --- GENERIC ---
if (!expr) {
const valRaw = f.value == null ? '' : String(f.value);
if (valRaw !== '') {
const valEscaped = escape(valRaw.toLowerCase());
if (numericFields.has(fieldName) && /^(=|>=|<=)$/.test(op)) {
expr = `"${fieldName}" ${op} '${valEscaped}'`;
} else if (/^contains$/i.test(op)) {
expr = `LOWER("${fieldName}") LIKE LOWER('%${valEscaped}%')`;
} else if (/^equals$/i.test(op)) {
const values = Array.isArray(f.value)
? (f.value as any[]).map((v) => escape(String(v).toLowerCase()))
: [escape(String(f.value).toLowerCase())];
expr = `LOWER("${fieldName}") IN (${values.map((v) => `'${v}'`).join(',')})`;
} else if (/^(>=|<=|=)$/.test(op) && !numericFields.has(fieldName)) {
expr = `LOWER("${fieldName}") ${op} '${valEscaped}'`;
} else if (/^(is)$/i.test(op)) {
expr = `LOWER("${fieldName}") = '${valEscaped}'`;
} else {
expr = `LOWER("${fieldName}") = '${valEscaped}'`;
}
}
}
if (expr) {
exprs.push(expr);
const joinBefore = f.join ?? (exprs.length > 1 ? 'AND' : 'AND');
joins.push(joinBefore);
}
}
if (exprs.length === 0) return '';
let out = exprs[0];
for (let i = 1; i < exprs.length; i++) {
const j = joins[i] ?? 'AND';
out = `(${out}) ${j} (${exprs[i]})`;
}
return out;
}
/**
* ✅ FIXED: Clean undefined values dan handle sorting dengan benar
*/
function buildRequestParams(base: BaseParams = {}, advanced: string) {
const out: BaseParams = {};
// ✅ Copy semua base params kecuali yang undefined
Object.keys(base).forEach((key) => {
if (base[key] !== undefined) {
out[key] = base[key];
}
});
// ✅ Field mapping
if ('noBupot' in out) {
out.nomorBupot = out.noBupot;
delete out.noBupot;
}
// ✅ Hanya tambahkan advanced jika ada isinya
if (advanced && advanced.trim() !== '') {
out.advanced = advanced.trim();
}
// ✅ Clean up undefined sorting (jangan kirim ke backend)
if (out.sortingMode === undefined) {
delete out.sortingMode;
}
if (out.sortingMethod === undefined) {
delete out.sortingMethod;
}
return out;
}
return { buildAdvancedFilter, buildRequestParams } as const;
}
......@@ -75,13 +75,11 @@ const normalisePropsGetBulanan = (params: TGetListDataTableDn) => ({
idDipotong: params.userId,
});
const normalisPropsParmasGetDn = (params: any) => {
const normalisPropsParmas = (params: any) => {
const sorting = !isEmpty(params.sort) ? transformSortModelToSortApiPayload(params.sort) : {};
return {
...params,
page: params.Page,
limit: params.Limit,
masaPajak: params.msPajak || null,
tahunPajak: params.thnPajak || null,
npwp: params.idDipotong || null,
......@@ -93,7 +91,7 @@ const useGetBulanan = ({ params, ...props }: any) => {
const query = useQuery<TBaseResponseAPI<TGetListDataTableDnResult>>({
queryKey: queryKey.bulanan.all(params),
queryFn: async () => {
const response = await bulananApi.getList({ params: normalisPropsParmasGetDn(params) });
const response = await bulananApi.getList({ params: normalisPropsParmas(params) });
return {
...response,
......
......@@ -4,12 +4,11 @@ import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack';
import dayjs from 'dayjs';
import { Suspense, useMemo, useState } from 'react';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { Field } from 'src/components/hook-form';
import { DashboardContent } from 'src/layouts/dashboard';
import { usePathname } from 'src/routes/hooks';
import { paths } from 'src/routes/paths';
import Agreement from 'src/shared/components/agreement/Agreement';
import HeadingRekam from 'src/shared/components/HeadingRekam';
......@@ -20,15 +19,17 @@ import JumlahPerhitunganForm from '../components/rekam/JumlahPerhitunganForm';
import PanduanDnRekam from '../components/rekam/PanduanDnRekam';
import PerhitunganPPhPasal21 from '../components/rekam/PerhitunganPPhPasal21';
import {
ActionRekam,
FG_FASILITAS_PPH_21,
FG_FASILITAS_PPH_21_TEXT,
KODE_OBJEK_PAJAK,
KODE_OBJEK_PAJAK_TEXT,
} from '../constant';
import { checkCurrentPage } from '../utils/utils';
import useSaveBulanan from '../hooks/useSaveBulanan';
import DialogPenandatangan from '../../DialogPenandatangan';
import DialogPenandatangan from '../components/DialogPenandatangan';
import useUploadBulanan from '../hooks/useUploadeBulanan';
import { useNavigate, useParams } from 'react-router';
import { enqueueSnackbar } from 'notistack';
import useGetBulanan from '../hooks/useGetBulanan';
const bulananSchema = z
.object({
......@@ -168,16 +169,21 @@ const bulananSchema = z
);
export const BulananRekamView = () => {
// const { id } = useParams();
const pathname = usePathname();
const { id, type } = useParams<{ id?: string; type?: 'ubah' | 'pengganti' | 'new' }>();
const navigate = useNavigate();
const { mutate: saveBulanan, isPending: isSaving } = useSaveBulanan();
const { mutate: saveBulanan, isPending: isSaving } = useSaveBulanan({
onSuccess: () => enqueueSnackbar('Data berhasil disimpan', { variant: 'success' }),
});
const { mutate: uploadBulanan, isPending: isUpload } = useUploadBulanan();
const [isOpenPanduan, setIsOpenPanduan] = useState<boolean>(false);
const [isCheckedAgreement, setIsCheckedAgreement] = useState<boolean>(false);
const [isOpenDialogPenandatangan, setIsOpenDialogPenandatangan] = useState(false);
const actionRekam = checkCurrentPage(pathname);
const isEdit = type === 'ubah';
const isPengganti = type === 'pengganti';
const dataListKOP = useMemo(
() =>
[KODE_OBJEK_PAJAK.BULANAN_01, KODE_OBJEK_PAJAK.BULANAN_02, KODE_OBJEK_PAJAK.BULANAN_03].map(
......@@ -189,6 +195,15 @@ export const BulananRekamView = () => {
[]
);
const { data: existingBulanan, isLoading: isLoadingBulanan } = useGetBulanan({
params: {
page: 1,
limit: 1,
id,
},
enabled: !!id,
});
type BpuFormData = z.infer<typeof bulananSchema>;
const handleOpenPanduan = () => setIsOpenPanduan(!isOpenPanduan);
......@@ -236,13 +251,22 @@ export const BulananRekamView = () => {
defaultValues,
});
console.log('🚀 ~ BulananRekamView ~ methods:', methods.formState.errors);
useEffect(() => {
if ((isEdit || isPengganti) && existingBulanan && !isLoadingBulanan) {
const normalized = {
...existingBulanan,
};
methods.reset(normalized as any);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, isPengganti, existingBulanan, isLoadingBulanan]);
const handleDraft = async (data: BpuFormData) => {
// Transform data sesuai dengan struktur yang dibutuhkan
const transformedData = {
...data,
id: actionRekam === ActionRekam.UBAH ? data.id : undefined,
id: isEdit || isPengganti ? data.id : undefined,
msPajak: data.masaPajak,
thnPajak: data.tahunPajak,
passportNo: data.passport || '',
......@@ -263,13 +287,42 @@ export const BulananRekamView = () => {
await saveBulanan(transformedData);
};
const handleUploud = async (data: BpuFormData) => {
try {
const response = await handleDraft(data);
uploadBulanan({
id: response?.id ?? '',
});
enqueueSnackbar('Berhasil Menyimpan Data', { variant: 'success' });
} catch (error: any) {
enqueueSnackbar(error.message, { variant: 'error' });
} finally {
navigate('/pph21/bulanan');
}
};
const handleClickUpload = async () => {
setIsOpenDialogPenandatangan(true);
};
const SubmitRekam = async (data: BpuFormData) => {
const respon = await handleDraft(data);
console.log({ respon });
try {
const respon = await handleDraft(data);
console.log({ respon });
enqueueSnackbar(
isEdit
? 'Data berhasil diperbarui'
: isPengganti
? 'Data pengganti berhasil disimpan'
: 'Data berhasil disimpan',
{ variant: 'success' }
);
navigate('/pph21/bulanan');
} catch (error: any) {
enqueueSnackbar(error.message || 'Gagal menyimpan data', { variant: 'error' });
console.error('❌ SaveDn error:', error);
}
};
const MockNitku = [
......@@ -345,7 +398,7 @@ export const BulananRekamView = () => {
type="button"
disabled={!isCheckedAgreement}
onClick={methods.handleSubmit(handleClickUpload)}
loading={isSaving}
loading={isSaving || isUpload}
variant="contained"
sx={{ background: '#143B88' }}
>
......@@ -361,12 +414,13 @@ export const BulananRekamView = () => {
</Grid>
</Grid>
</DashboardContent>
<DialogPenandatangan
isOpen={isOpenDialogPenandatangan}
onClose={() => {
setIsOpenDialogPenandatangan(false);
}}
/>
{isOpenDialogPenandatangan && (
<DialogPenandatangan
isOpenDialogUpload={isOpenDialogPenandatangan}
setIsOpenDialogUpload={setIsOpenDialogPenandatangan}
onConfirmUpload={() => methods.handleSubmit(handleUploud)()}
/>
)}
</>
);
};
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { produce } from 'immer';
type TableKey = string;
interface TablePagination {
page: number; // 0-based untuk MUI DataGrid
pageSize: number;
}
interface PaginationState {
tables: Record<TableKey, TablePagination>;
}
interface PaginationActions {
setPagination: (table: TableKey, next: Partial<TablePagination>) => void;
resetPagination: (table: TableKey) => void;
resetAllPaginations: () => void;
getPagination: (table: TableKey) => TablePagination;
removePagination: (table: TableKey) => void;
}
type PaginationStore = PaginationState & PaginationActions;
// ✅ Default untuk MUI DataGrid (0-based)
const DEFAULT_PAGINATION: Readonly<TablePagination> = Object.freeze({
page: 0, // 0-based untuk MUI
pageSize: 10,
});
const STORAGE_KEY = 'pagination-storage';
export const usePaginationStore = create<PaginationStore>()(
devtools(
persist(
(set, get) => ({
tables: {},
setPagination: (table, next) => {
set(
produce<PaginationStore>((draft) => {
const current = draft.tables[table] ?? { ...DEFAULT_PAGINATION };
draft.tables[table] = {
page: next.page ?? current.page,
pageSize: next.pageSize ?? current.pageSize,
};
}),
false,
{ type: 'SET_PAGINATION', table, next }
);
},
resetPagination: (table) => {
set(
produce<PaginationStore>((draft) => {
const currentPageSize = draft.tables[table]?.pageSize ?? DEFAULT_PAGINATION.pageSize;
draft.tables[table] = {
page: DEFAULT_PAGINATION.page,
pageSize: currentPageSize,
};
}),
false,
{ type: 'RESET_PAGINATION', table }
);
},
resetAllPaginations: () => {
set({ tables: {} }, false, { type: 'RESET_ALL_PAGINATIONS' });
},
getPagination: (table) => {
const state = get();
return state.tables[table] ?? { ...DEFAULT_PAGINATION };
},
removePagination: (table) => {
set(
produce<PaginationStore>((draft) => {
delete draft.tables[table];
}),
false,
{ type: 'REMOVE_PAGINATION', table }
);
},
}),
{
name: STORAGE_KEY,
partialize: (state) => ({ tables: state.tables }),
}
),
{ name: 'PaginationStore', enabled: process.env.NODE_ENV === 'development' }
)
);
// ============================================================================
// CUSTOM HOOKS WITH 1-BASED CONVERSION
// ============================================================================
/**
* ✅ Hook dengan konversi otomatis ke 1-based untuk backend
* MUI DataGrid: 0-based (page 0, 1, 2, ...)
* Backend API: 1-based (page 1, 2, 3, ...)
*/
export const useTablePagination = (tableKey: TableKey) => {
const pagination = usePaginationStore((s) => s.tables[tableKey] ?? DEFAULT_PAGINATION);
const setPagination = usePaginationStore((s) => s.setPagination);
const resetPagination = usePaginationStore((s) => s.resetPagination);
return [
pagination, // untuk MUI DataGrid (0-based)
(next: Partial<TablePagination>) => setPagination(tableKey, next),
() => resetPagination(tableKey),
] as const;
};
/**
* ✅ Hook khusus yang return page dalam format 1-based untuk API
*/
export const useTablePaginationForAPI = (tableKey: TableKey) => {
const pagination = usePaginationStore((s) => s.tables[tableKey] ?? DEFAULT_PAGINATION);
return {
page: pagination.page + 1, // Convert to 1-based
pageSize: pagination.pageSize,
limit: pagination.pageSize, // alias
};
};
export const useTablePage = (tableKey: TableKey) =>
usePaginationStore((s) => s.tables[tableKey]?.page ?? DEFAULT_PAGINATION.page);
export const useTablePageSize = (tableKey: TableKey) =>
usePaginationStore((s) => s.tables[tableKey]?.pageSize ?? DEFAULT_PAGINATION.pageSize);
export const createTableKey = (...parts: string[]): TableKey => parts.filter(Boolean).join('-');
export type { TableKey, TablePagination, PaginationStore };
export { DEFAULT_PAGINATION };
// import Box from '@mui/material/Box';
// import Button from '@mui/material/Button';
// import Grid from '@mui/material/Grid';
// import dayjs from 'dayjs';
// import React, { useEffect, useState } from 'react';
// import { useFormContext } from 'react-hook-form';
// import { Field } from 'src/components/hook-form';
// type IdentitasProps = {
// isPengganti: boolean;
// existingDn?: any; // data penuh dari API
// };
// const Identitas = ({ isPengganti, existingDn }: IdentitasProps) => {
// const { setValue, watch, getValues } = useFormContext();
// const tanggalPemotongan = watch('tglPemotongan');
// const maxKeterangan = 5;
// const [jumlahKeterangan, setJumlahKeterangan] = useState<number>(0);
// // 🧩 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]);
// useEffect(() => {
// // ambil nilai form saat ini (setelah reset di parent)
// const currentValues = getValues();
// const arr = [
// currentValues.keterangan1,
// currentValues.keterangan2,
// currentValues.keterangan3,
// currentValues.keterangan4,
// currentValues.keterangan5,
// ];
// const count = arr.filter((k) => !!k && k.trim() !== '').length;
// console.log('🧠 Detected keterangan count:', count, arr);
// // kalau ada field terisi, render sebanyak itu
// if (count > 0) {
// setJumlahKeterangan(count);
// }
// }, [existingDn, getValues]);
// // ➕ Tambah field
// const handleTambah = () => {
// if (jumlahKeterangan < maxKeterangan) {
// setJumlahKeterangan((prev) => prev + 1);
// }
// };
// // ➖ Hapus field terakhir
// const handleHapus = () => {
// if (jumlahKeterangan > 0) {
// setValue(`keterangan${jumlahKeterangan}`, '');
// setJumlahKeterangan((prev) => prev - 1);
// }
// };
// console.log(existingDn);
// return (
// <>
// <Grid container rowSpacing={2} alignItems="center" columnSpacing={2} sx={{ mb: 4 }}>
// {/* 📅 Tanggal & Masa Pajak */}
// <Grid size={{ md: 6 }}>
// <Field.DatePicker
// name="tglPemotongan"
// label="Tanggal Pemotongan"
// format="DD/MM/YYYY"
// maxDate={dayjs()}
// />
// </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>
// {/* 🧾 NPWP dan NITKU */}
// <Grid size={{ md: 6 }}>
// <Field.Text
// name="idDipotong"
// label="NPWP"
// onChange={(e) => {
// const value = e.target.value.replace(/\D/g, '').slice(0, 16);
// setValue('idDipotong', value, { shouldValidate: true, shouldDirty: true });
// setValue('nitku', value.length === 16 ? value + '000000' : value, {
// shouldValidate: true,
// shouldDirty: true,
// });
// }}
// />
// </Grid>
// <Grid size={{ md: 6 }}>
// <Field.Text
// name="nitku"
// label="NITKU"
// onChange={(e) => {
// const value = e.target.value.replace(/\D/g, '').slice(0, 22);
// setValue('nitku', value, { shouldValidate: true, shouldDirty: true });
// }}
// />
// </Grid>
// {/* 👤 Nama dan Email */}
// <Grid size={{ md: 6 }}>
// <Field.Text name="namaDipotong" label="Nama" />
// </Grid>
// <Grid size={{ md: 6 }}>
// <Field.Text name="email" label="Email (optional)" />
// </Grid>
// </Grid>
// {/* ✏️ Tombol Tambah / Hapus Keterangan */}
// <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>
// {/* 🗒️ Input Keterangan Tambahan */}
// <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 Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Grid from '@mui/material/Grid';
......@@ -360,7 +188,7 @@ const Identitas = ({ isPengganti, existingDn }: IdentitasProps) => {
{/* 🗒️ Input Keterangan Tambahan */}
<Box sx={{ mb: 3 }}>
{Array.from({ length: jumlahKeterangan }).map((_, i) => (
<Grid size={{ md: 12 }} key={i}>
<Grid size={{ md: 12 }} key={`keterangan${i + 1}`}>
<Field.Text
sx={{ mb: 2 }}
name={`keterangan${i + 1}`}
......
// type FilterItem = {
// field: string;
// operator: string;
// value?: string | number | Array<string | number> | null;
// join?: 'AND' | 'OR'; // optional: join connector BEFORE this item (first item usually undefined)
// };
// type BaseParams = Record<string, any>;
// export function useAdvancedFilter() {
// const numericFields = new Set(['masaPajak', 'tahunPajak', 'dpp', 'pphDipotong']);
// const dateFields = new Set(['created_at', 'updated_at']);
// const fieldMap: Record<string, string> = {
// noBupot: 'nomorBupot',
// };
// const dbField = (field: string) => fieldMap[field] ?? field;
// const escape = (v: string) => String(v).replace(/'/g, "''");
// const toDbDate = (value: string | Date) => {
// if (value instanceof Date) {
// const y = value.getFullYear();
// const m = String(value.getMonth() + 1).padStart(2, '0');
// const d = String(value.getDate()).padStart(2, '0');
// return `${y}${m}${d}`;
// }
// const digits = String(value).replace(/[^0-9]/g, '');
// if (digits.length >= 8) return digits.slice(0, 8);
// return digits;
// };
// const normalizeOp = (op: string) => op?.toString().trim();
// function buildAdvancedFilter(filters?: FilterItem[] | null) {
// if (!filters || filters.length === 0) return '';
// const exprs: string[] = []; // each item's expression
// const joins: ('AND' | 'OR')[] = []; // join before each expr (for item 0, push nothing/AND by default)
// 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);
// // build expression for this item
// let expr: string | null = null;
// // DATE handling
// if (dateFields.has(fieldName)) {
// const rawVal = f.value;
// if (!rawVal && !/is empty|is not empty/i.test(op)) {
// continue;
// }
// if (/^is$/i.test(op)) {
// const ymd = toDbDate(rawVal as string | Date);
// if (!ymd) continue;
// expr = `\"${fieldName}\" >= '${ymd} 00:00:00' AND \"${fieldName}\" <= '${ymd} 23:59:59'`;
// } else if (/is on or after/i.test(op)) {
// const ymd = toDbDate(rawVal as string | Date);
// if (!ymd) continue;
// expr = `\"${fieldName}\" >= '${ymd}'`;
// } else if (/is on or before/i.test(op)) {
// const ymd = toDbDate(rawVal as string | Date);
// if (!ymd) continue;
// expr = `\"${fieldName}\" <= '${ymd}'`;
// }
// }
// // EMPTY checks (user requested LOWER("col") IS NULL semantics)
// 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 handling
// if (!expr && /is any of/i.test(op)) {
// // collect values array
// let values: Array<string | number> = [];
// if (Array.isArray(f.value)) values = f.value as any;
// else if (typeof f.value === 'string')
// values = (f.value as string)
// .split(',')
// .map((s) => s.trim())
// .filter(Boolean);
// else if (f.value != null) values = [f.value as any];
// if ((values || []).length === 0) {
// expr = null;
// } else {
// // special-case fgStatus: need LIKE %val% OR LIKE %val2%
// if (fieldName === 'fgStatus' || fieldName === 'fg_status') {
// const ors = (values as any[]).map((v) => {
// const s = escape(String(v).toLowerCase());
// return `LOWER(\"${fieldName}\") LIKE LOWER('%${s}%')`;
// });
// expr = `(${ors.join(' OR ')})`;
// } else {
// // default: OR of equality (case-insensitive)
// const ors = (values as any[]).map((v) => {
// const s = escape(String(v).toLowerCase());
// return `LOWER(\"${fieldName}\") = '${s}'`;
// });
// expr = `(${ors.join(' OR ')})`;
// }
// }
// }
// // FGSTATUS special single-value is / is not / contains semantics
// 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)) {
// expr = null;
// } else {
// 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 text/numeric handling when expr still not set
// if (!expr) {
// const valRaw = f.value == null ? '' : String(f.value);
// if (valRaw === '') {
// expr = null;
// } else {
// const valEscaped = escape(valRaw.toLowerCase());
// // numeric fields: operators (=, >=, <=)
// 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)) {
// // equals should produce IN (wrap single value as IN)
// // attempt to parse CSV if provided
// let values: string[] = [];
// if (Array.isArray(f.value))
// values = (f.value as any[]).map((v) => escape(String(v).toLowerCase()));
// else values = [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)) {
// // fallback: treat as equals
// expr = `LOWER(\"${fieldName}\") = '${valEscaped}'`;
// } else {
// // fallback equality
// expr = `LOWER(\"${fieldName}\") = '${valEscaped}'`;
// }
// }
// }
// if (expr) {
// exprs.push(expr);
// // record join for this item (use provided join or default AND except for first item)
// const joinBefore = (f.join as 'AND' | 'OR') ?? (exprs.length > 1 ? 'AND' : 'AND');
// joins.push(joinBefore);
// }
// }
// // now combine exprs with joins; joins[i] is join BEFORE exprs[i]
// 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;
type FilterItem = {
field: string;
operator: string;
......
......@@ -92,24 +92,6 @@ const normalisePropsGetDn = (params: TGetListDataTableDn) => ({
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,
......
// 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';
console.log('✅ pagination store created');
type TableKey = string;
interface TablePagination {
......@@ -43,14 +9,22 @@ interface TablePagination {
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 };
......@@ -71,4 +45,20 @@ export const usePaginationStore = create<PaginationState>((set) => ({
[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: [] },
},
})),
}));
......@@ -84,14 +84,21 @@ export function DnListView() {
const page = usePaginationStore((s) => s.tables[tableKey]?.page ?? 0);
const pageSize = usePaginationStore((s) => s.tables[tableKey]?.pageSize ?? 10);
const setPagination = usePaginationStore((s) => s.setPagination);
const resetPagination = usePaginationStore((s) => s.resetPagination);
const [filterModel, setFilterModel] = useState<GridFilterModel>({ items: [] });
const { tables, filters, setPagination, resetPagination, setFilter } =
usePaginationStore.getState();
const [filterModel, setFilterModel] = useState<GridFilterModel>({
items: filters[tableKey]?.items ?? [],
});
const [sortModel, setSortModel] = useState<GridSortModel>([]);
const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel | undefined>(
undefined
);
const [localPagination, setLocalPagination] = useState({
page: tables[tableKey]?.page ?? 0,
pageSize: tables[tableKey]?.pageSize ?? 10,
});
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
const [isUploadModalOpen, setIsUploadModalOpen] = useState<boolean>(false);
......@@ -107,6 +114,49 @@ export function DnListView() {
const { buildAdvancedFilter, buildRequestParams } = useAdvancedFilter();
const isSyncingRef = useRef(false);
const isEqual = (a: any, b: any) => a === b || JSON.stringify(a) === JSON.stringify(b);
// 🔁 Sync store -> local
useEffect(() => {
const unsub = usePaginationStore.subscribe((state) => {
const newStoreItems = state.filters[tableKey]?.items ?? [];
const localItems = filterModel.items ?? [];
if (!isEqual(newStoreItems, localItems)) {
isSyncingRef.current = true;
setFilterModel({ items: newStoreItems });
queueMicrotask(() => (isSyncingRef.current = false));
}
});
return () => unsub();
}, [filterModel.items]);
useEffect(() => {
const unsub = usePaginationStore.subscribe((state) => {
const storePage = state.tables[tableKey]?.page ?? 0;
const storePageSize = state.tables[tableKey]?.pageSize ?? 10;
setLocalPagination((prev) =>
prev.page !== storePage || prev.pageSize !== storePageSize
? { page: storePage, pageSize: storePageSize }
: prev
);
});
return () => unsub();
}, []);
// 🔁 Sync local -> store
useEffect(() => {
if (isSyncingRef.current) return;
const currentStore = usePaginationStore.getState().filters[tableKey]?.items ?? [];
if (!isEqual(currentStore, filterModel.items)) {
setFilter(tableKey, { items: filterModel.items });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterModel]);
const params = useMemo(() => {
const advanced = buildAdvancedFilter(filterModel.items);
......@@ -147,17 +197,19 @@ export function DnListView() {
}
}, [rows]);
const throttledPaginationChange = useThrottle((model: GridPaginationModel) => {
if (model.pageSize !== pageSize) {
setPagination(tableKey, { page: 0, pageSize: model.pageSize });
} else {
setPagination(tableKey, { page: model.page });
}
}, 250);
const handlePaginationChange = (model: GridPaginationModel) => {
// Update UI langsung (instan)
setLocalPagination(model);
// Sinkronisasi Zustand (tanpa delay visual)
setPagination(tableKey, {
page: model.page,
pageSize: model.pageSize,
});
};
const debouncedFilterChange = useDebounce((model: GridFilterModel) => {
setFilterModel(model);
resetPagination(tableKey);
}, 400);
const debouncedSortChange = useDebounce((model: GridSortModel) => {
......@@ -165,10 +217,10 @@ export function DnListView() {
resetPagination(tableKey);
}, 400);
const paginationModel: GridPaginationModel = {
page,
pageSize,
};
// const paginationModel: GridPaginationModel = {
// page,
// pageSize,
// };
// ---------- status options and columns (kept identical to your original) ----------
type Status = 'draft' | 'normal' | 'cancelled' | 'amended';
......@@ -281,6 +333,33 @@ export function DnListView() {
}, [apiRef]);
// ---------- memoized toolbar validation (avoid recompute heavy every click) ----------
// const validatedActions = useMemo(() => {
// const dataSelected = dataSelectedRef.current;
// const count = dataSelected.length;
// const hasSelection = count > 0;
// if (!hasSelection) {
// return {
// canDetail: false,
// canEdit: false,
// canDelete: false,
// canUpload: false,
// canReplacement: false,
// canCancel: false,
// };
// }
// const allDraft = dataSelected.every((d) => d.fgStatus === FG_STATUS_DN.DRAFT);
// const allNormal = dataSelected.every((d) => d.fgStatus === FG_STATUS_DN.NORMAL_DONE);
// return {
// canDetail: count === 1,
// canEdit: count === 1 && allDraft,
// canDelete: hasSelection && allDraft,
// canUpload: hasSelection && allDraft,
// canReplacement: count === 1 && dataSelected[0].fgStatus === FG_STATUS_DN.NORMAL_DONE,
// canCancel: hasSelection && allNormal,
// };
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [selectionVersion]);
const validatedActions = useMemo(() => {
const dataSelected = dataSelectedRef.current;
const count = dataSelected.length;
......@@ -295,14 +374,23 @@ export function DnListView() {
canCancel: false,
};
}
const allDraft = dataSelected.every((d) => d.fgStatus === FG_STATUS_DN.DRAFT);
const allNormal = dataSelected.every((d) => d.fgStatus === FG_STATUS_DN.NORMAL_DONE);
// 🟢 ambil data pertama saja, karena canReplacement hanya berlaku kalau count === 1
const first = dataSelected[0];
return {
canDetail: count === 1,
canEdit: count === 1 && allDraft,
canDelete: hasSelection && allDraft,
canUpload: hasSelection && allDraft,
canReplacement: count === 1 && dataSelected[0].fgStatus === FG_STATUS_DN.NORMAL_DONE,
// 🔽 tambahkan logika baru di sini
canReplacement:
count === 1 && first.fgStatus === FG_STATUS_DN.NORMAL_DONE && first.has_draft !== true, // ❌ disable kalau sudah ada draft
canCancel: hasSelection && allNormal,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
......@@ -452,7 +540,7 @@ export function DnListView() {
rowCount={totalRows}
pagination
paginationMode="server"
paginationModel={paginationModel}
paginationModel={localPagination}
initialState={{
pagination: {
paginationModel: {
......@@ -461,15 +549,13 @@ export function DnListView() {
},
},
}}
// onPaginationModelChange={handlePaginationChange}
onPaginationModelChange={throttledPaginationChange}
onPaginationModelChange={handlePaginationChange}
pageSizeOptions={[5, 10, 15, 25, 50, 100]}
filterMode="server"
onFilterModelChange={debouncedFilterChange}
sortingMode="server"
onSortModelChange={debouncedSortChange}
rowSelectionModel={rowSelectionModel}
// onRowSelectionModelChange={handleRowSelectionChange}
onRowSelectionModelChange={throttledSelectionChange}
pinnedColumns={pinnedColumns}
cellSelection
......
......@@ -24,36 +24,50 @@ import useUpload from '../hooks/useUpload';
import { useGetDnById } from '../hooks/useGetDn';
import ModalUploadDn from '../components/dialog/ModalUploadDn';
const bpuSchema = z.object({
tglPemotongan: z.string().nonempty('Tanggal Pemotongan harus diisi'),
thnPajak: z.string().nonempty('Tahun Pajak harus diisi'),
msPajak: z.string().nonempty('Masa Pajak harus diisi'),
idDipotong: z
.string()
.nonempty('NPWP harus diisi')
.regex(/^\d{16}$/, 'NPWP harus 16 digit'),
nitku: z
.string()
.nonempty('NITKU harus diisi')
.regex(/^\d{22}$/, 'NITKU harus 22 digit'),
namaDipotong: z.string().nonempty('Nama harus diisi'),
email: z.string().email({ message: 'Email tidak valid' }).optional().or(z.literal('')),
keterangan1: z.string().optional(),
keterangan2: z.string().optional(),
keterangan3: z.string().optional(),
keterangan4: z.string().optional(),
keterangan5: z.string().optional(),
kdObjPjk: z.string().nonempty('Kode Objek Pajak harus diisi'),
fgFasilitas: z.string().nonempty('Fasilitas harus diisi'),
noDokLainnya: z.string().nonempty('No Dokumen Lainnya harus diisi'),
jmlBruto: z.string().nonempty('Jumlah Penghasilan Bruto harus diisi'),
tarif: z.union([z.string().nonempty('Tarif harus diisi'), z.number()]),
pphDipotong: z.string().nonempty('PPh Yang Dipotong/Dipungut harus diisi'),
namaDok: z.string().nonempty('Nama Dokumen harus diisi'),
nomorDok: z.string().nonempty('Nomor Dokumen harus diisi'),
tglDok: z.string().nonempty('Tanggal Dokumen harus diisi'),
idTku: z.string().nonempty('Cabang harus diisi'),
});
const bpuSchema = z
.object({
tglPemotongan: z.string().nonempty('Tanggal Pemotongan harus diisi'),
thnPajak: z.string().nonempty('Tahun Pajak harus diisi'),
msPajak: z.string().nonempty('Masa Pajak harus diisi'),
idDipotong: z
.string()
.nonempty('NPWP harus diisi')
.regex(/^\d{16}$/, 'NPWP harus 16 digit'),
nitku: z
.string()
.nonempty('NITKU harus diisi')
.regex(/^\d{22}$/, 'NITKU harus 22 digit'),
namaDipotong: z.string().nonempty('Nama harus diisi'),
email: z.string().email({ message: 'Email tidak valid' }).optional().or(z.literal('')),
keterangan1: z.string().optional(),
keterangan2: z.string().optional(),
keterangan3: z.string().optional(),
keterangan4: z.string().optional(),
keterangan5: z.string().optional(),
kdObjPjk: z.string().nonempty('Kode Objek Pajak harus diisi'),
fgFasilitas: z.string().nonempty('Fasilitas harus diisi'),
noDokLainnya: z.string().optional(),
jmlBruto: z.string().nonempty('Jumlah Penghasilan Bruto harus diisi'),
tarif: z.union([z.string().nonempty('Tarif harus diisi'), z.number()]),
pphDipotong: z.string().nonempty('PPh Yang Dipotong/Dipungut harus diisi'),
namaDok: z.string().nonempty('Nama Dokumen harus diisi'),
nomorDok: z.string().nonempty('Nomor Dokumen harus diisi'),
tglDok: z.string().nonempty('Tanggal Dokumen harus diisi'),
idTku: z.string().nonempty('Cabang harus diisi'),
})
.superRefine((data, ctx) => {
// Field dianggap DISABLED kalau fgFasilitas kosong ('') atau '9'
const isDisabled = ['', '9'].includes(data.fgFasilitas);
// Jika tidak disabled, berarti aktif → wajib isi
if (!isDisabled && (!data.noDokLainnya || data.noDokLainnya.trim() === '')) {
ctx.addIssue({
path: ['noDokLainnya'],
code: 'custom',
message: 'No Dokumen Lainnya harus diisi',
});
}
});
const DnRekamView = () => {
const { id, type } = useParams<{ id?: string; type?: 'ubah' | 'pengganti' | 'new' }>();
......
import React from 'react';
import { GridPreferencePanelsValue, useGridApiContext } from '@mui/x-data-grid-premium';
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';
......
import * as React from 'react';
import { GridToolbarContainer, GridToolbarProps } from '@mui/x-data-grid-premium';
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 { ActionItem } from '../types/types';
import type { ActionItem } from '../types/types';
import { CustomFilterButton } from './CustomFilterButton';
import CustomColumnsButton from './CustomColumnsButton';
......
......@@ -4,7 +4,7 @@ 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 useCetakPdfDn from '../../hooks/useCetakPdfNr';
import normalizePayloadCetakPdf from '../../utils/normalizePayloadCetakPdf';
interface ModalCetakPdfDnProps {
......@@ -13,18 +13,7 @@ interface ModalCetakPdfDnProps {
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 ModalCetakPdfNr: React.FC<ModalCetakPdfDnProps> = ({ payload, isOpen, onClose }) => {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
......@@ -104,4 +93,4 @@ const ModalCetakPdfDn: React.FC<ModalCetakPdfDnProps> = ({ payload, isOpen, onCl
);
};
export default ModalCetakPdfDn;
export default ModalCetakPdfNr;
import React, { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
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 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';
import type { GridRowSelectionModel } from '@mui/x-data-grid-premium';
import useDeleteDn from '../../hooks/useDeleteNr';
interface ModalDeleteDnProps {
dataSelected?: GridRowSelectionModel;
......@@ -46,7 +44,7 @@ const normalizeSelection = (sel?: any): (string | number)[] => {
return [];
};
const ModalDeleteDn: React.FC<ModalDeleteDnProps> = ({
const ModalDeleteNr: React.FC<ModalDeleteDnProps> = ({
dataSelected,
setSelectionModel,
tableApiRef,
......@@ -145,4 +143,4 @@ const ModalDeleteDn: React.FC<ModalDeleteDnProps> = ({
);
};
export default ModalDeleteDn;
export default ModalDeleteNr;
......@@ -3,7 +3,7 @@ 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 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';
......@@ -11,12 +11,12 @@ 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 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 ModalUploadDnProps {
interface ModalUploadNrProps {
dataSelected?: GridRowSelectionModel;
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
......@@ -55,7 +55,7 @@ const normalizeSelection = (sel?: any): (string | number)[] => {
return [];
};
const ModalUploadDn: React.FC<ModalUploadDnProps> = ({
const ModalUploadNr: React.FC<ModalUploadNrProps> = ({
dataSelected,
setSelectionModel,
tableApiRef,
......@@ -66,7 +66,7 @@ const ModalUploadDn: React.FC<ModalUploadDnProps> = ({
}) => {
const queryClient = useQueryClient();
const uploadDn = useUpload();
const uploadNr = useUpload();
// custom hooks for progress state
const {
numberOfData,
......@@ -124,7 +124,7 @@ const ModalUploadDn: React.FC<ModalUploadDnProps> = ({
enqueueSnackbar(error?.message || 'Gagal upload data', { variant: 'error' });
} finally {
// sesuaikan queryKey jika perlu; tetap panggil invalidasi
queryClient.invalidateQueries({ queryKey: ['unifikasi', 'dn'] });
queryClient.invalidateQueries({ queryKey: ['unifikasi', 'nr'] });
}
};
......@@ -158,17 +158,17 @@ const ModalUploadDn: React.FC<ModalUploadDnProps> = ({
<LoadingButton
type="button"
disabled={!isCheckedAgreement}
// onClick={onSubmit}
onClick={async () => {
if (onConfirmUpload) {
await onConfirmUpload();
setIsOpenDialogUpload(false);
return;
}
await onSubmit();
}}
loading={uploadDn.isPending}
loading={uploadNr.isPending}
variant="contained"
sx={{ background: '#143B88' }}
>
......@@ -195,4 +195,4 @@ const ModalUploadDn: React.FC<ModalUploadDnProps> = ({
);
};
export default ModalUploadDn;
export default ModalUploadNr;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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