Commit a6b0b18d authored by Rais Aryaguna's avatar Rais Aryaguna

add: feature Tahunan A1

parent 4c199c1d
import { isNaN } from 'lodash';
import { forwardRef, useEffect, useState } from 'react';
// import { NumberFormat } from 'react-number-format';
import { NumericFormat } from 'react-number-format';
// const formatNegativeValue = (value, negativeMask) => {
// if (!value || value === '0') return value;
// const numValue = parseFloat(value);
// if (numValue >= 0) return value;
// const formattedAbsValue = new Intl.NumberFormat('id-ID', {
// minimumFractionDigits: 0,
// maximumFractionDigits: 2,
// }).format(Math.abs(numValue));
// switch (negativeMask) {
// case 'prefix':
// return `-${formattedAbsValue}`;
// case 'suffix':
// return `${formattedAbsValue}-`;
// case 'both':
// return `(${formattedAbsValue})`;
// default:
// return `-${formattedAbsValue}`;
// }
// };
export const getIntegerDigitCount = (value) => {
if (!value) return 0;
// Convert to string and remove all non-digit characters
const cleanValue = value.toString().replace(/[^\d,.-]/g, '');
// Split by decimal separator and get the integer part
const integerPart = cleanValue.split(',')[0];
// Remove negative sign if present and count remaining digits
return integerPart.replace(/[-]/g, '').length;
};
const NumberFormatRupiahWithAllowedNegative = forwardRef(
function NumberFormatRupiahWithAllowedNegative(props, ref) {
const {
onChange,
allowNegativeValue = false,
allowDecimalValue = false,
negativeMask = 'prefix',
value: controlledValue,
maxValue,
minValue,
maxLength,
minLength,
...other
} = props;
const [isNegative, setIsNegative] = useState(false);
useEffect(() => {
if (allowNegativeValue && controlledValue) {
const numValue = parseFloat(controlledValue.toString().replace(/[^\d.-]/g, ''));
setIsNegative(numValue < 0);
}
}, [controlledValue, allowNegativeValue]);
const handleKeyPress = (e) => {
if (!allowNegativeValue) return;
const kode = e.charCode;
if (kode === 45) {
const newIsNegative = !isNegative;
setIsNegative(newIsNegative);
const currentValue = props.value || '0';
const numValue = parseFloat(currentValue.toString().replace(/[^\d.-]/g, ''));
if (numValue !== 0) {
const newValue = newIsNegative ? -Math.abs(numValue) : Math.abs(numValue);
if (
(maxValue === undefined || newValue <= maxValue) &&
(minValue === undefined || newValue >= minValue)
) {
onChange({
target: {
name: props.name,
value: newValue.toString(),
},
});
} else {
setIsNegative(!newIsNegative);
}
}
}
};
return (
<NumericFormat
{...other}
value={controlledValue}
isNumericString
onKeyPress={handleKeyPress}
thousandSeparator="."
decimalSeparator=","
decimalScale={allowDecimalValue ? 2 : 0}
getInputRef={ref}
allowNegative={allowNegativeValue}
isAllowed={(values) => {
const { floatValue, value } = values;
if (floatValue === undefined) return true;
// Check maximum value
if (Math.abs(floatValue) > 999999999999) {
return false;
}
// Specific handling for decimal input
if (value.includes(',') && value.split(',')[1] && value.split(',')[1].length > 2) {
return false;
}
// Get integer digit count (excluding leading zeros and decimal places)
const integerPart = value.split(',')[0].replace(/^0+/, '');
const integerDigits = integerPart.length;
// Length validations for non-zero integer part
if (maxLength !== undefined && integerDigits > maxLength) {
return false;
}
if (minLength !== undefined && integerDigits < minLength) {
return false;
}
// Min/Max validations (only when float value is complete)
if (floatValue !== undefined) {
if (maxValue !== undefined && floatValue > maxValue) {
return false;
}
if (minValue !== undefined && floatValue < minValue) {
return false;
}
}
return true;
}}
format={(value) => {
if (!value) return value;
const numValue = parseFloat(value.toString().replace(/[^\d.-]/g, ''));
if (isNaN(numValue)) return value;
const formatted = new Intl.NumberFormat('id-ID', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(Math.abs(numValue));
if (numValue < 0 || isNegative) {
switch (negativeMask) {
case 'prefix':
return `-${formatted}`;
case 'suffix':
return `${formatted}-`;
case 'both':
return `(${formatted})`;
default:
return `-${formatted}`;
}
}
return formatted;
}}
onValueChange={(values) => {
let finalValue = values.value;
if (allowNegativeValue && finalValue && finalValue !== '0') {
const numValue = parseFloat(finalValue.replace(/[^\d.-]/g, ''));
if (!isNaN(numValue)) {
const shouldBeNegative = numValue < 0 || isNegative;
const potentialValue = shouldBeNegative ? -Math.abs(numValue) : Math.abs(numValue);
if (
(maxValue === undefined || potentialValue <= maxValue) &&
(minValue === undefined || potentialValue >= minValue)
) {
setIsNegative(shouldBeNegative);
finalValue = shouldBeNegative
? `-${Math.abs(numValue)}`
: Math.abs(numValue).toString();
} else {
return;
}
}
}
onChange({
target: {
name: props.name,
value: finalValue,
},
});
}}
/>
);
}
);
export default NumberFormatRupiahWithAllowedNegative;
import { forwardRef } from 'react';
import type { NumericFormatProps } from 'react-number-format';
import { NumericFormat } from 'react-number-format';
/**
* Type definition for negative value display mask
* @typedef {string} NegativeMaskType
* @property {'prefix'} prefix - Display negative as: -1.000.000
* @property {'suffix'} suffix - Display negative as: 1.000.000-
* @property {'both'} both - Display negative as: (1.000.000)
*/
export type NegativeMaskType = 'prefix' | 'suffix' | 'both';
/**
* Props for NumberFormatRupiahWithAllowedNegative component
*/
export interface NumberFormatRupiahWithAllowedNegativeProps
extends Omit<NumericFormatProps, 'onChange' | 'value'> {
/** Field name for form integration */
name: string;
/** Controlled value from parent component */
value?: string | number;
/** Callback fired when value changes */
onChange: (event: { target: { name: string; value: string } }) => void;
/** Allow negative values. Default: false */
allowNegativeValue?: boolean;
/** Allow decimal values (2 decimal places). Default: false */
allowDecimalValue?: boolean;
/** Format style for negative values. Default: 'prefix' */
negativeMask?: NegativeMaskType;
/** Maximum allowed value (inclusive) */
maxValue?: number;
/** Minimum allowed value (inclusive) */
minValue?: number;
/** Maximum length of integer part (excluding separators) */
maxLength?: number;
/** Minimum length of integer part (excluding separators) */
minLength?: number;
}
/**
* NumberFormatRupiahWithAllowedNegative Component
*
* A customized numeric input component for Indonesian Rupiah format with support for negative values.
* Built on top of react-number-format's NumericFormat component.
*
* @component
* @example
* // Basic usage (positive only)
* <NumberFormatRupiahWithAllowedNegative
* name="amount"
* value={value}
* onChange={handleChange}
* />
*
* @example
* // With negative values (accounting style)
* <NumberFormatRupiahWithAllowedNegative
* name="expense"
* value={-100000}
* onChange={handleChange}
* allowNegativeValue={true}
* allowDecimalValue={true}
* negativeMask="both" // Displays as: (100.000,00)
* maxValue={0}
* minValue={-1000000000}
* />
*
* @example
* // With validation constraints
* <NumberFormatRupiahWithAllowedNegative
* name="bonus"
* value={value}
* onChange={handleChange}
* allowNegativeValue={true}
* negativeMask="prefix" // Displays as: -100.000
* maxValue={10000000}
* minValue={-5000000}
* maxLength={7}
* />
*/
const NumberFormatRupiahWithAllowedNegative = forwardRef<
HTMLInputElement,
NumberFormatRupiahWithAllowedNegativeProps
>(function NumberFormatRupiahWithAllowedNegative(props, ref) {
const {
onChange,
name,
allowNegativeValue = false,
allowDecimalValue = false,
negativeMask = 'prefix',
value: controlledValue,
maxValue,
minValue,
maxLength,
minLength,
...other
} = props;
// Parse the controlled value to determine if it's negative
const numValue = parseFloat((controlledValue || '0').toString().replace(/[^\d.-]/g, ''));
const isNegative = numValue < 0;
// Determine prefix and suffix based on negative mask configuration
let prefix = '';
let suffix = '';
if (isNegative && allowNegativeValue) {
switch (negativeMask) {
case 'prefix':
prefix = '-';
break;
case 'suffix':
suffix = '-';
break;
case 'both':
prefix = '(';
suffix = ')';
break;
default:
prefix = '-';
}
}
/**
* Handles keyboard press events to toggle negative/positive values
* Press '-' or '_' key to toggle between negative and positive
*
* @param {React.KeyboardEvent<HTMLInputElement>} e - Keyboard event
*/
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!allowNegativeValue) return;
if (e.key === '-' || e.key === '_') {
e.preventDefault();
const currentValue = controlledValue || '0';
const cleanValue = currentValue.toString().replace(/[^\d.-]/g, '');
const currentNum = parseFloat(cleanValue);
// Only toggle if value is a valid non-zero number
if (!isNaN(currentNum) && currentNum !== 0) {
const newValue = currentNum > 0 ? -Math.abs(currentNum) : Math.abs(currentNum);
// Validate against min/max constraints before updating
if (
(maxValue === undefined || newValue <= maxValue) &&
(minValue === undefined || newValue >= minValue)
) {
onChange({
target: {
name,
value: newValue.toString(),
},
});
}
}
}
};
return (
<NumericFormat
{...other}
// Display absolute value; prefix/suffix will indicate sign
value={Math.abs(numValue) || ''}
getInputRef={ref}
name={name}
// Indonesian locale formatting
thousandSeparator="."
decimalSeparator=","
decimalScale={allowDecimalValue ? 2 : 0}
// Disable built-in negative handling (we handle it manually)
allowNegative={false}
// Apply calculated prefix/suffix for negative indication
prefix={prefix}
suffix={suffix}
onKeyDown={handleKeyPress}
/**
* Validates input value against various constraints
*
* @param {Object} values - Value object from NumericFormat
* @returns {boolean} Whether the input is allowed
*/
isAllowed={(values) => {
const { floatValue, value } = values;
if (floatValue === undefined) return true;
// Constraint 1: Maximum absolute value (12 digits)
if (Math.abs(floatValue) > 999999999999) {
return false;
}
// Constraint 2: Maximum 2 decimal places
if (value.includes(',') && value.split(',')[1]?.length > 2) {
return false;
}
// Constraint 3: Integer length validation
const integerPart = value
.replace(/[^\d,]/g, '')
.split(',')[0]
.replace(/^0+/, '');
if (maxLength !== undefined && integerPart.length > maxLength) {
return false;
}
if (minLength !== undefined && integerPart.length < minLength) {
return false;
}
return true;
}}
/**
* Handles value changes and applies min/max constraints
* Preserves negative sign when converting to final value
*
* @param {Object} values - Value object from NumericFormat
*/
onValueChange={(values) => {
const { floatValue } = values;
// Handle empty/cleared input
if (floatValue === undefined) {
onChange({ target: { name, value: '' } });
return;
}
// Preserve the negative sign based on original value
// eslint-disable-next-line prefer-const
let finalValue = isNegative ? -Math.abs(floatValue) : Math.abs(floatValue);
// Validate against constraints
if (maxValue !== undefined && finalValue > maxValue) {
return;
}
if (minValue !== undefined && finalValue < minValue) {
return;
}
// Emit change event with string value
onChange({
target: {
name,
value: finalValue.toString(),
},
});
}}
/>
);
});
// Set display name for debugging purposes
NumberFormatRupiahWithAllowedNegative.displayName = 'NumberFormatRupiahWithAllowedNegative';
export default NumberFormatRupiahWithAllowedNegative;
...@@ -11,6 +11,7 @@ type RHFNumericProps = { ...@@ -11,6 +11,7 @@ type RHFNumericProps = {
maxValue?: number; maxValue?: number;
minValue?: number; minValue?: number;
readOnly?: boolean; readOnly?: boolean;
negativeMask?: 'prefix' | 'suffix' | 'both';
[key: string]: any; [key: string]: any;
}; };
...@@ -22,6 +23,7 @@ export function RHFNumeric({ ...@@ -22,6 +23,7 @@ export function RHFNumeric({
maxValue, maxValue,
minValue, minValue,
readOnly, readOnly,
negativeMask = 'prefix',
...props ...props
}: RHFNumericProps) { }: RHFNumericProps) {
const { control } = useFormContext(); const { control } = useFormContext();
...@@ -69,6 +71,7 @@ export function RHFNumeric({ ...@@ -69,6 +71,7 @@ export function RHFNumeric({
allowDecimalValue, allowDecimalValue,
maxValue, maxValue,
minValue, minValue,
negativeMask,
}, },
...props.InputProps, ...props.InputProps,
}} }}
......
...@@ -99,7 +99,7 @@ export const navData: NavSectionProps['data'] = [ ...@@ -99,7 +99,7 @@ export const navData: NavSectionProps['data'] = [
children: [ children: [
{ title: 'Bupot Bulanan', path: paths.pph21.bulanan }, { title: 'Bupot Bulanan', path: paths.pph21.bulanan },
{ title: 'Bupot Final/Tidak Final', path: paths.pph21.bupotFinal }, { title: 'Bupot Final/Tidak Final', path: paths.pph21.bupotFinal },
{ title: 'Bupot Tahunan A1', path: paths.pph21.tahuan }, { title: 'Bupot Tahunan A1', path: paths.pph21.tahunan },
{ title: 'Bupot Pasal 26', path: paths.pph21.bupot26 }, { title: 'Bupot Pasal 26', path: paths.pph21.bupot26 },
], ],
}, },
......
...@@ -167,6 +167,12 @@ export const endpoints = { ...@@ -167,6 +167,12 @@ export const endpoints = {
delete: '/IF_TXR_028/21/delete', delete: '/IF_TXR_028/21/delete',
upload: '/IF_TXR_028/21/upload', upload: '/IF_TXR_028/21/upload',
canceled: '/IF_TXR_028/21/batal', canceled: '/IF_TXR_028/21/batal',
},
tahunanA1: {
list: '/IF_TXR_055/a1',
delete: '/IF_TXR_055/a1/delete',
upload: '/IF_TXR_055/a1/upload',
canceled: '/IF_TXR_055/a1/batal',
} }
}, },
masterData: { masterData: {
......
import { CONFIG } from 'src/global-config'; import { CONFIG } from 'src/global-config';
import { TahunanA1ListView } from 'src/sections/bupot-21-26/bupot-a1/view';
const metadata = { title: `E-Bupot 21/26- ${CONFIG.appName}` }; const metadata = { title: `E-Bupot 21/26- ${CONFIG.appName}` };
...@@ -7,7 +8,7 @@ export default function Page() { ...@@ -7,7 +8,7 @@ export default function Page() {
<> <>
<title>{metadata.title}</title> <title>{metadata.title}</title>
<p>Bupot Tahunan A1</p> <TahunanA1ListView/>
</> </>
); );
} }
import { CONFIG } from 'src/global-config';
import { TahunanA1RekamView } from 'src/sections/bupot-21-26/bupot-a1/view';
const metadata = { title: `E-Bupot Tahunan Rekam- ${CONFIG.appName}` };
export default function Page() {
return (
<>
<title>{metadata.title}</title>
<TahunanA1RekamView/>
</>
);
}
...@@ -116,8 +116,9 @@ export const paths = { ...@@ -116,8 +116,9 @@ export const paths = {
bupotFinal: `${ROOTS.PPH21}/bupot-final`, bupotFinal: `${ROOTS.PPH21}/bupot-final`,
bupotFinalRekam: `${ROOTS.PPH21}/bupot-final/rekam`, bupotFinalRekam: `${ROOTS.PPH21}/bupot-final/rekam`,
bupotFinalEdit: (id: string, path:string) => `${ROOTS.PPH21}/bupot-final/${id}/${path}`, bupotFinalEdit: (id: string, path:string) => `${ROOTS.PPH21}/bupot-final/${id}/${path}`,
tahuan: `${ROOTS.PPH21}/tahunan`, tahunan: `${ROOTS.PPH21}/tahunan`,
detailstahuan: (id: string) => `${ROOTS.PPH21}/tahunan/${id}`, tahunanRekam: `${ROOTS.PPH21}/tahunan/rekam`,
bupotTahunanEdit: (id: string, path:string) => `${ROOTS.PPH21}/tahunan/${id}/${path}`,
tahunanA2: `${ROOTS.PPH21}/tahunan-a2`, tahunanA2: `${ROOTS.PPH21}/tahunan-a2`,
detailstahunanA2: (id: string) => `${ROOTS.PPH21}/tahunan-a2/${id}`, detailstahunanA2: (id: string) => `${ROOTS.PPH21}/tahunan-a2/${id}`,
bupot26: `${ROOTS.PPH21}/bupot-26`, bupot26: `${ROOTS.PPH21}/bupot-26`,
......
...@@ -63,6 +63,7 @@ const OverviewBupotFinalTdkFinalRekamPage = lazy( ...@@ -63,6 +63,7 @@ const OverviewBupotFinalTdkFinalRekamPage = lazy(
() => import('src/pages/pph21/bupotFinalTidakFinalRekam') () => import('src/pages/pph21/bupotFinalTidakFinalRekam')
); );
const OverviewBupotA1Page = lazy(() => import('src/pages/pph21/bupoTahunanA1')); const OverviewBupotA1Page = lazy(() => import('src/pages/pph21/bupoTahunanA1'));
const OverviewBupotA1RekamPage = lazy(() => import('src/pages/pph21/bupoTahunanA1Rekam'));
const OverviewBupotPasal26Page = lazy(() => import('src/pages/pph21/bupotPasal26')); const OverviewBupotPasal26Page = lazy(() => import('src/pages/pph21/bupotPasal26'));
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
...@@ -151,6 +152,8 @@ export const dashboardRoutes: RouteObject[] = [ ...@@ -151,6 +152,8 @@ export const dashboardRoutes: RouteObject[] = [
{ path: 'bupot-final/rekam', element: <OverviewBupotFinalTdkFinalRekamPage /> }, { path: 'bupot-final/rekam', element: <OverviewBupotFinalTdkFinalRekamPage /> },
{ path: 'bupot-final/:id/:type', element: <OverviewBupotFinalTdkFinalRekamPage /> }, { path: 'bupot-final/:id/:type', element: <OverviewBupotFinalTdkFinalRekamPage /> },
{ path: 'tahunan', element: <OverviewBupotA1Page /> }, { path: 'tahunan', element: <OverviewBupotA1Page /> },
{ path:'tahunan/rekam', element:<OverviewBupotA1RekamPage/>},
{ path:'tahunan/:id/:type', element:<OverviewBupotA1RekamPage/>},
{ path: 'bupot-26', element: <OverviewBupotPasal26Page /> }, { path: 'bupot-26', element: <OverviewBupotPasal26Page /> },
], ],
}, },
......
// ============================================
// CONSTANTS & CONFIGURATION
// ============================================
import { FG_PERHITUNGAN } from "../../constant";
// Field Names Mapping
const FORM_FIELDS = {
// Data Penghasilan Setahun
GAJI: 'rincian1',
TUNJANGAN_PPH: 'rincian2',
TUNJANGAN_LAINNYA: 'rincian3',
HONORARIUM: 'rincian4',
PREMI_ASURANSI: 'rincian5',
NATURA: 'rincian6',
TANTIEM_BONUS: 'rincian7',
JUMLAH_BRUTO: 'rincian8',
// Pengurangan
BIAYA_JABATAN: 'rincian9',
IURAN_PENSIUN: 'rincian10',
ZAKAT: 'rincian11',
JUMLAH_PENGURANGAN: 'rincian12',
// Penghitungan PPh Pasal 21
PENGHASILAN_NETO: 'rincian13',
PENGHASILAN_NETO_SEBELUMNYA: 'rincian14',
JUMLAH_PENGHASILAN_NETO: 'rincian15',
PTKP: 'rincian16',
PENGHASILAN_KENA_PAJAK: 'rincian17',
PPH_ATAS_PKP: 'rincian18',
PPH_TERUTANG: 'rincian19',
PPH_DIPOTONG_SEBELUMNYA: 'rincian20',
PPH_TERUTANG_BUKTI_INI: 'rincian21',
PPH_DIPOTONG_DITANGGUNG: 'rincian22',
PPH_KURANG_LEBIH: 'rincian23',
};
// Section Configuration
const FORM_SECTIONS = {
PENGHASILAN: {
title: 'DATA PENGHASILAN SETAHUN',
startNumber: 1,
fields: [
'GAJI ATAU UANG PENSIUNAN BERKALA',
'TUNJANGAN PPh',
'TUNJANGAN LAINNYA, UANG LEMBUR DAN SEGALANYA',
'HONORARIUM DAN IMBALAN LAIN SEJENISNYA',
'PREMI ASURANSI YANG DIBAYARKAN PEMBERI KERJA',
'PENERIMAAN DALAM BENTUK NATURA DAN KENIKMATAN LAINNYA YANG DIKENAKAN PEMOTONGAN PPh PASAL 21',
'TANTIEM, BONUS, GRATIFIKASI, JASA PRODUKSI DAN THR',
'JUMLAH PENGHASILAN BRUTO (1 S.D. 7)',
],
},
PENGURANGAN: {
title: 'PENGURANGAN',
startNumber: 9,
fields: [
'BIAYA JABATAN/BIAYA PENSIUN',
'IURAN TERKAIT PENSIUN ATAU HARI TUA',
'ZAKAT/SUMBANGAN KEAGAMAAN YANG BERSIFAT WAJIB YANG DIBAYARKAN MELALUI PEMBERI KERJA',
'JUMLAH PENGURANGAN (9 S.D. 11)',
],
},
PENGHITUNGAN: {
title: 'PENGHITUNGAN PPh PASAL 21',
startNumber: 13,
fields: [
'JUMLAH PENGHASILAN NETO (8 - 12)',
'PENGHASILAN NETO DARI PEMOTONGAN SEBELUMNYA',
'JUMLAH PENGHASILAN NETO UNTUK PERHITUNGAN PPh PASAL 21 (SETAHUN/DISETAHUNKAN)',
'PENGHASILAN TIDAK KENA PAJAK (PTKP)',
'PENGHASILAN KENA PAJAK SETAHUN/DISETAHUNKAN (15 - 16)',
'PPh PASAL 21 ATAS PENGHASILAN KENA PAJAK SETAHUN/DISETAHUNKAN',
'PPh PASAL 21 TERUTANG',
'PPh PASAL 21 DIPOTONG DARI BUKTI PEMOTONGAN SEBELUMNYA',
'PPh PASAL 21 TERUTANG PADA BUKTI PEMOTONGAN INI (DAPAT DIKREDITKAN PADA SPT TAHUNAN)',
'PPh PASAL 21 YANG TELAH DIPOTONG/DITANGGUNG PEMERINTAH',
'PPh PASAL 21 KURANG (LEBIH) DIPOTONG PADA MASA PAJAK DESEMBER/MASA PAJAK TERAKHIR (21 - 22)',
],
},
};
// Calculated/ReadOnly Fields
const CALCULATED_FIELDS = new Set([
FORM_FIELDS.JUMLAH_BRUTO,
FORM_FIELDS.BIAYA_JABATAN,
FORM_FIELDS.JUMLAH_PENGURANGAN,
FORM_FIELDS.PENGHASILAN_NETO,
FORM_FIELDS.PENGHASILAN_NETO_SEBELUMNYA,
FORM_FIELDS.JUMLAH_PENGHASILAN_NETO,
FORM_FIELDS.PTKP,
FORM_FIELDS.PENGHASILAN_KENA_PAJAK,
FORM_FIELDS.PPH_ATAS_PKP,
FORM_FIELDS.PPH_TERUTANG,
FORM_FIELDS.PPH_DIPOTONG_SEBELUMNYA,
FORM_FIELDS.PPH_TERUTANG_BUKTI_INI,
FORM_FIELDS.PPH_KURANG_LEBIH,
]);
// ============================================
// HELPER FUNCTIONS
// ============================================
const isFieldReadOnly = (fieldName: string, msPjkAwal: number, isMetodePemotonganSeTahun: any ,fgPerhitungan: string) => {
// Calculated fields are always readonly
if (CALCULATED_FIELDS.has(fieldName)) {
return true;
}
// Special case: Tunjangan PPh
if (fieldName === FORM_FIELDS.TUNJANGAN_PPH) {
console.log("🚀 ~ isFieldReadOnly:",{ msPjkAwal, isMetodePemotonganSeTahun});
return fgPerhitungan === FG_PERHITUNGAN.GROSS_UP;
}
if (fieldName === FORM_FIELDS.PENGHASILAN_NETO_SEBELUMNYA) {
return msPjkAwal >= 2 && isMetodePemotonganSeTahun !== '1'
}
// If PPh21 is active, all input fields are readonly
return false;
};
const getFieldNameByIndex = (index: any) => {
const fieldNames = Object.values(FORM_FIELDS);
return fieldNames[index] || `rincian${index + 1}`;
};
const perhitunganA1List = Object.values(FORM_FIELDS).map(values=>({[values]:0}))
export {
FORM_FIELDS,
FORM_SECTIONS,
CALCULATED_FIELDS,
isFieldReadOnly,
getFieldNameByIndex,
perhitunganA1List,
}
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 * 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 '../table/CustomFilterButton';
import CustomColumnsButton from '../table/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, useMemo, 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, { appRootKey, bulanan } from 'src/sections/bupot-21-26/constant/queryKey';
import useUploadBulanan from '../../hooks/useDeleteTahunanA1';
import { createTableKey, useTablePagination } from '../../../paginationStore';
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 TABLE_KEY = useMemo(() => createTableKey(appRootKey, bulanan), []);
const [paginationState] = useTablePagination(TABLE_KEY);
const [isOpenDialogProgressBar, setIsOpenDialogProgressBar] = useState(false);
const [isCheckedAgreement, setIsCheckedAgreement] = useState<boolean>(false);
const signer = useAppSelector((state) => 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({page: paginationState.page + 1, limit: paginationState.pageSize}) });
}
};
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;
import { Button, Stack, Typography } from '@mui/material';
import type { GridRowSelectionModel } from '@mui/x-data-grid-premium';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { useQueryClient } from '@tanstack/react-query';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import minMax from 'dayjs/plugin/minMax';
import { enqueueSnackbar } from 'notistack';
import React, { useEffect, useMemo, useState } from 'react';
import queryKey, { appRootKey, bulanan } from 'src/sections/bupot-21-26/constant/queryKey';
import DialogProgressBar from 'src/shared/components/dialog/DialogProgressBar';
import DialogUmum from 'src/shared/components/dialog/DialogUmum';
import useDialogProgressBar from 'src/shared/hooks/useDialogProgressBar';
import { createTableKey, useTablePagination } from '../../../paginationStore';
import useCencelBulanan from '../../hooks/useCencelTahunanA1';
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 ModalCancelDnProps {
dataSelected?: any[];
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
isOpenDialogCancel: boolean;
setIsOpenDialogCancel: (v: boolean) => void;
successMessage?: string;
}
const ModalCancelBulanan: React.FC<ModalCancelDnProps> = ({
dataSelected = [],
setSelectionModel,
tableApiRef,
isOpenDialogCancel,
setIsOpenDialogCancel,
successMessage = 'Data berhasil dibatalkan',
}) => {
const queryClient = useQueryClient();
const TABLE_KEY = useMemo(() => createTableKey(appRootKey, bulanan), []);
const [paginationState] = useTablePagination(TABLE_KEY);
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 } = useCencelBulanan({
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(queryKey.bulanan.all(''), (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: queryKey.tahunanA1.all({page: paginationState.page + 1, limit: paginationState.pageSize}) });
// ⚠️ 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 21 Tahunan"
>
<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 ModalCancelBulanan;
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import DialogContent from '@mui/material/DialogContent';
import { enqueueSnackbar } from 'notistack';
import { useEffect, useState } from 'react';
import DialogUmum from 'src/shared/components/dialog/DialogUmum';
import { useAppSelector } from 'src/store';
import { useCetakBulanan } from '../../../cetakpdf';
interface ModalCetakPdfDnProps {
payload?: Record<string, any>;
isOpen: boolean;
onClose: () => void;
}
const ModalCetakPdfBulanan: React.FC<ModalCetakPdfDnProps> = ({ payload, isOpen, onClose }) => {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const AppSelector = useAppSelector((selector) => selector.user.data);
const { mutateAsync } = useCetakBulanan({
onError: (error: any) => {
enqueueSnackbar(error?.message || 'Gagal memuat PDF', { variant: 'error' });
setLoading(false);
},
onSuccess: (res: any) => {
const fileUrl = res?.url || res?.data?.url;
if (!fileUrl) {
enqueueSnackbar('URL PDF tidak ditemukan di respons API', { variant: 'warning' });
setLoading(false);
return;
}
setPdfUrl(fileUrl);
setLoading(false);
enqueueSnackbar(res?.MsgStatus || 'PDF berhasil dibentuk', { variant: 'success' });
},
});
useEffect(() => {
const runCetak = async () => {
if (!isOpen || !payload) return;
setLoading(true);
setPdfUrl(null);
try {
console.log('Payload final cetak PDF:', payload);
await mutateAsync({
...payload,
npwpPemotong: AppSelector.npwp_trial,
namaPemotong: AppSelector.company_name,
nitkuPemotong: AppSelector.nitku_trial,
namaPenandatangan: AppSelector.signer,
} as any);
} catch (err) {
console.error('❌ Error cetak PDF:', err);
enqueueSnackbar('Gagal generate PDF', { variant: 'error' });
setLoading(false);
}
};
runCetak();
}, [isOpen, payload, mutateAsync]);
return (
<DialogUmum
maxWidth="lg"
isOpen={isOpen}
onClose={onClose}
title="Detail Bupot Unifikasi (PDF)"
>
<DialogContent classes={{ root: 'p-16 sm:p-24' }}>
{loading && (
<Box display="flex" justifyContent="center" alignItems="center" height="60vh">
<CircularProgress />
</Box>
)}
{!loading && pdfUrl && (
<iframe
src={pdfUrl}
style={{
width: '100%',
height: '80vh',
border: 'none',
borderRadius: 8,
}}
title="Preview PDF Bupot"
/>
)}
{!loading && !pdfUrl && (
<Box textAlign="center" color="text.secondary" py={4}>
PDF tidak tersedia untuk ditampilkan.
</Box>
)}
</DialogContent>
</DialogUmum>
);
};
export default ModalCetakPdfBulanan;
import React, { useEffect, useMemo, 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 useDeleteTahunanA1 from '../../hooks/useDeleteTahunanA1';
import queryKey, { appRootKey, tahunanA1 } from 'src/sections/bupot-21-26/constant/queryKey';
import { createTableKey, useTablePagination } from '../../../paginationStore';
interface ModalDeleteDnProps {
dataSelected?: GridRowSelectionModel;
setSelectionModel?: React.Dispatch<React.SetStateAction<GridRowSelectionModel | undefined>>;
tableApiRef?: React.MutableRefObject<any>;
isOpenDialogDelete: boolean;
setIsOpenDialogDelete: (v: boolean) => void;
successMessage?: string;
}
/**
* Normalize various possible shapes of grid selection model into array of ids.
* Handles:
* - array of ids: [1, 2, 'a']
* - Set-of-ids: new Set([1,2])
* - object like { ids: Set(...), type: 'include' }
* - fallback: tries to extract keys if it's an object map
*/
const normalizeSelection = (sel?: any): (string | number)[] => {
if (!sel) return [];
if (Array.isArray(sel)) return sel as (string | number)[];
if (sel instanceof Set) return Array.from(sel) as (string | number)[];
if (typeof sel === 'object') {
// common shape from newer MUI: { ids: Set(...), type: 'include' }
if (sel.ids instanceof Set) return Array.from(sel.ids) as (string | number)[];
// maybe it's a map-like object { '1': true, '2': true }
const maybeIds = Object.keys(sel).filter((k) => k !== 'type' && k !== 'size');
if (maybeIds.length > 0) {
// try to convert numeric-like keys to number where applicable
return maybeIds.map((k) => {
const n = Number(k);
return Number.isNaN(n) ? k : n;
});
}
}
return [];
};
const ModalDeleteBulanan: React.FC<ModalDeleteDnProps> = ({
dataSelected,
setSelectionModel,
tableApiRef,
isOpenDialogDelete,
setIsOpenDialogDelete,
successMessage = 'Data berhasil dihapus',
}) => {
const queryClient = useQueryClient();
const TABLE_KEY = useMemo(() => createTableKey(appRootKey, tahunanA1), []);
const [paginationState] = useTablePagination(TABLE_KEY);
// 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 } = useDeleteTahunanA1({
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: queryKey.tahunanA1.all({page:paginationState.page +1, limit: paginationState.pageSize}) });
}
};
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 ModalDeleteBulanan;
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, useMemo, 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, { appRootKey, bulanan } from 'src/sections/bupot-21-26/constant/queryKey';
import useUploadBulanan from '../../hooks/useDeleteTahunanA1';
import { createTableKey, useTablePagination } from '../../../paginationStore';
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 ModalUploadBulanan: 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) => state.user.data.signer);
const queryClient = useQueryClient();
const TABLE_KEY = useMemo(() => createTableKey(appRootKey, bulanan), []);
const [paginationState] = useTablePagination(TABLE_KEY);
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.tahunanA1.all({page: paginationState.page + 1, limit: paginationState.pageSize}) });
}
};
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 ModalUploadBulanan;
import { Box } from '@mui/material';
import Grid from '@mui/material/Grid';
import { useFormContext } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import { fgPerhitunganOptions, KELAMIN, KELAMIN_TEXT } from 'src/sections/bupot-21-26/constant';
type IdentitasProps = {
isPengganti?: boolean;
kodeNegetaOptions: {
label: string;
value: string;
}[];
ptkpOptions: {
value: string;
label: string;
}[];
};
const genderOptions = [
{ value: KELAMIN.LAKI, label: KELAMIN_TEXT[KELAMIN.LAKI] },
{ value: KELAMIN.PEREMPUAN, label: KELAMIN_TEXT[KELAMIN.PEREMPUAN] },
];
const Identitas = ({ isPengganti, kodeNegetaOptions, ptkpOptions }: IdentitasProps) => {
// const { dnId } = useParams();
const { setValue, watch } = useFormContext();
const fgKaryawanAsing = watch('fgKaryawanAsing');
return (
<>
<Grid container rowSpacing={2} alignItems="center" columnSpacing={2} sx={{ mb: 4 }}>
{/* NPWP dengan onChange langsung */}
<Grid size={{ md: 6 }}>
<Field.Text
name="npwp"
label="NPWP/NIK"
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '').slice(0, 16); // hanya angka, max 16
setValue('npwp', value, { shouldValidate: true, shouldDirty: true });
setValue('nitku', value.length === 16 ? value + '000000' : value, {
shouldValidate: true,
shouldDirty: true,
});
}}
disabled={isPengganti}
/>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="nama" label="Nama" disabled={isPengganti} />
</Grid>
<Grid size={{ md: 12 }}>
<Field.Text name="alamat" label="Alamat" />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Autocomplete
name="optJnsKelamin"
label="Jenis Kelamin"
options={genderOptions}
readOnly={isPengganti}
/>
</Grid>
<Grid size={{ md: 6 }} display="flex" justifyContent="space-between">
<Field.RadioGroup
row
name="fgPerhitungan"
label="Metode Pemotongan"
options={fgPerhitunganOptions}
/>
<Field.Autocomplete
name="statusPtkp"
label="Status/Jumlah Tanggungan untuk PTKP"
options={ptkpOptions}
sx={{
width: '65%',
}}
renderOption={(props, option, state, ownerState) => {
console.log("🚀 ~ Identitas ~ option:", option);
const { key, ...optionProps } = props;
return (
<Box
key={key}
sx={{
letterSpacing: '1.5px',
}}
component="li"
{...optionProps}
>
{ownerState.getOptionLabel(option)}
</Box>
);
}}
/>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="namaJabatan" label="Nama Jabatan" disabled={isPengganti} />
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text name="email" label="Email (optional)" disabled={isPengganti} />
</Grid>
<Grid size={{ md: 3 }} alignSelf="center">
<Field.Checkbox
name="fgKaryawanAsing"
label="Status Karyawan Asing"
disabled={isPengganti}
/>
</Grid>
<Grid size={{ md: 3 }}>
<Field.Autocomplete
name="kodeNegara"
label="Negara"
options={kodeNegetaOptions}
readOnly={!fgKaryawanAsing}
disabled={isPengganti}
/>
</Grid>
<Grid size={{ md: 6 }}>
<Field.Text
name="passport"
label="Paspor"
slotProps={{
input: {
readOnly: !fgKaryawanAsing,
},
}}
disabled={isPengganti}
/>
</Grid>
</Grid>
{/* 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>
<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 { Divider, Grid, Stack, Typography } from '@mui/material';
import { RHFNumeric } from 'src/components/hook-form/rhf-numeric';
import { FORM_FIELDS, FORM_SECTIONS, getFieldNameByIndex, isFieldReadOnly } from '../constant';
import { useFormContext } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import { memo, useEffect, useMemo } from 'react';
import { LoadingButton } from '@mui/lab';
import { CalculateRounded } from '@mui/icons-material';
import {
getHitungBulananErrorMessage,
useHitungTahunanA1 as hitungTahunanA1,
} from 'src/sections/bupot-21-26/hitung';
import dayjs from 'dayjs';
// ============================================
// REUSABLE COMPONENTS
// ============================================
export const FormFieldLabel = ({
number,
text,
children = <></>,
}: {
number: number;
text: string;
children?: React.ReactNode;
}) => (
<Stack direction="row" spacing={2} justifyContent="space-between" alignItems="center">
<Stack direction="row" spacing={2}>
<Typography variant="subtitle1" fontWeight="bold" sx={{ minWidth: '40px' }}>
{String(number).padStart(2, '0')}.
</Typography>
<Typography variant="subtitle1" fontWeight="bold">
{text}
</Typography>
</Stack>
{children}
</Stack>
);
export const SectionTitle = ({ title }: { title: string }) => (
<Divider sx={{ fontWeight: 'bold' }} textAlign="left">
{title}
</Divider>
);
const FormNumberInput = ({
name,
readOnly,
size = 'small',
}: {
name: string;
readOnly: boolean;
size?: string;
}) => (
<RHFNumeric
label=""
name={name}
size={size}
allowNegativeValue={name === FORM_FIELDS.PPH_KURANG_LEBIH}
negativeMask="both"
readOnly={readOnly}
/>
);
const useRincianCalculations = () => {
const { watch, setValue, clearErrors, trigger } = useFormContext();
useEffect(() => {
const subscription = watch((values, { name }) => {
if (!name) return;
let rincian8;
switch (name) {
// ✅ Rincian 1-7: Hitung total ke rincian8
case 'rincian1':
case 'rincian2':
case 'rincian3':
case 'rincian4':
case 'rincian5':
case 'rincian6':
case 'rincian7':
rincian8 =
Number(values.rincian1 || 0) +
Number(values.rincian2 || 0) +
Number(values.rincian3 || 0) +
Number(values.rincian4 || 0) +
Number(values.rincian5 || 0) +
Number(values.rincian6 || 0) +
Number(values.rincian7 || 0);
setValue('rincian8', rincian8);
break;
// ✅ Rincian 9-11: Hitung total ke rincian12
case 'rincian9':
case 'rincian10':
case 'rincian11':
setValue(
'rincian12',
Number(values.rincian9 || 0) +
Number(values.rincian10 || 0) +
Number(values.rincian11 || 0)
);
break;
// ✅ Rincian 8 & 12: Hitung selisih ke rincian13
case 'rincian8':
case 'rincian12':
setValue('rincian13', Number(values.rincian8 || 0) - Number(values.rincian12 || 0));
break;
// ✅ Rincian 18: Copy ke rincian19
case 'rincian18':
setValue('rincian19', Number(values.rincian18 || 0));
break;
// ✅ Rincian 19 & 20: Hitung selisih ke rincian21
case 'rincian19':
case 'rincian20':
setValue('rincian21', Number(values.rincian19 || 0) - Number(values.rincian20 || 0));
break;
// ✅ Rincian 21 & 22: Hitung selisih ke rincian23
case 'rincian21':
case 'rincian22':
setValue('rincian23', Number(values.rincian21 || 0) - Number(values.rincian22 || 0));
break;
// ✅ Rincian 14: Trigger validasi rincian20
case 'rincian14':
trigger('rincian20');
break;
default:
break;
}
});
return () => subscription.unsubscribe();
}, [watch, setValue, clearErrors, trigger]);
};
// ============================================
// ✅ FIXED: Hook dipanggil di top-level component
// ============================================
const PerhitunganA1Builder = memo(
({
listInputs = [],
labelCols = 4,
inputCols = 8,
align = 'center',
}: {
listInputs: any;
labelCols: any;
inputCols: any;
align?: any;
}) => {
if (listInputs.length === 0) {
return null;
}
return (
<Stack gap={2}>
{listInputs.map((input: any) => {
if (input.isSection) {
return (
<Grid container key={input.key} mt={3}>
<Grid size={{ xs: 12 }}>{input.element}</Grid>
</Grid>
);
}
return (
<Grid container alignItems={align} spacing={3} key={input.key}>
<Grid size={{ xs: 12, md: labelCols }}>{input.label}</Grid>
<Grid size={{ xs: 12, md: inputCols }}>{input.element}</Grid>
</Grid>
);
})}
</Stack>
);
}
);
PerhitunganA1Builder.displayName = 'PerhitunganA1Builder';
// ============================================
// ✅ MAIN COMPONENT: Hook dipanggil di sini
// ============================================
export default function PerhitunganA1Container() {
// ✅ Hook dipanggil di top-level component
const { watch, setValue, getValues } = useFormContext();
const fgPerhitungan = watch('fgPerhitungan');
const msPjkAwal = dayjs(watch('masaPajakAwal')).get('month') + 1;
const isMetodePemotonganSeTahun = watch('metodePemotongan');
useRincianCalculations();
const { mutate, isPending } = hitungTahunanA1({
onSuccess: (data: any) => {
console.log('✅ Berhasil hitung Tahunan A1:', data);
// TODO: Update form values dengan data dari response
Object.keys(data).forEach((key) => {
setValue(key, data[key]);
});
},
onError: (error) => {
console.error('❌ Error:', getHitungBulananErrorMessage(error));
},
});
const handleHitung = () => {
const currentValues = getValues(); // Ambil data terbaru
console.log('📊 Data yang dikirim:', currentValues);
mutate(currentValues as any);
};
const handleGrossUpChange = (_: any, checked: boolean) => {
setValue('fgPerhitungan', checked ? '1' : '0');
setValue('isGrossUp', checked);
};
const listInputs = useMemo(() => {
const result: any[] = [];
let globalIndex = 0;
Object.values(FORM_SECTIONS).forEach((section) => {
// Add section title
result.push({
key: `section-${section.title}`,
isSection: true,
element: <SectionTitle title={section.title} />,
});
// Add fields
section.fields.forEach((label, localIndex) => {
const fieldNumber = section.startNumber + localIndex;
const fieldName = getFieldNameByIndex(globalIndex);
const readOnly = isFieldReadOnly(fieldName, msPjkAwal, isMetodePemotonganSeTahun ,fgPerhitungan);
// Special case: Tunjangan PPh dengan checkbox Gross Up
if (fieldName === FORM_FIELDS.TUNJANGAN_PPH) {
result.push({
key: fieldName,
label: (
<FormFieldLabel number={fieldNumber} text={label}>
<Field.Checkbox
name="isGrossUp"
label="Gross Up"
sx={{ padding: 0 }}
slotProps={{
checkbox: {
onChange: handleGrossUpChange,
},
}}
/>
</FormFieldLabel>
),
element: <FormNumberInput name={fieldName} readOnly={readOnly} />,
});
}
// Special case: PPh dengan tombol Hitung
else if (fieldName === FORM_FIELDS.PPH_ATAS_PKP) {
result.push({
key: fieldName,
label: (
<FormFieldLabel number={fieldNumber} text={label}>
<LoadingButton
variant="contained"
size="medium"
color="primary"
sx={{ width: 120 }}
startIcon={<CalculateRounded />}
loading={isPending}
onClick={handleHitung} // ✅ Gunakan handler yang ambil data fresh
>
Hitung
</LoadingButton>
</FormFieldLabel>
),
element: <FormNumberInput name={fieldName} readOnly={readOnly} />,
});
}
// Regular field
else {
result.push({
key: fieldName,
label: <FormFieldLabel number={fieldNumber} text={label} />,
element: <FormNumberInput name={fieldName} readOnly={readOnly} />,
});
}
globalIndex++;
});
});
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fgPerhitungan, isPending]);
return <PerhitunganA1Builder listInputs={listInputs} labelCols={9} inputCols={3} />;
}
import { Box, Typography } from '@mui/material';
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import dayjs from 'dayjs';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import { Field } from 'src/components/hook-form';
import { SETAHUN, SETAHUN_TEXT } from 'src/sections/bupot-21-26/constant';
type PPHDipotongProps = {
isPengganti?: boolean;
kodeObjectPajak: {
value: string;
label: string;
}[];
fgFasilitasOptions: {
value: string;
label: string;
}[];
};
const setahunOptions = [
{ value: SETAHUN.SETAHUN, label: SETAHUN_TEXT[SETAHUN.SETAHUN] },
{ value: SETAHUN.DISETAHUNKAN, label: SETAHUN_TEXT[SETAHUN.DISETAHUNKAN] },
{ value: SETAHUN.BAGIAN_TAHUN, label: SETAHUN_TEXT[SETAHUN.BAGIAN_TAHUN] },
];
const RincianPenghasilan = ({
kodeObjectPajak,
fgFasilitasOptions,
isPengganti,
}: PPHDipotongProps) => {
const { watch, setValue } = useFormContext();
const isMetodePemotonganSeTahun = watch('metodePemotongan') === '1';
const tanggalPemotongan = watch('tglPemotongan');
const masaPajakAwal = watch('masaPajakAwal');
const masaPajakAkhir = watch('masaPajakAkhir');
useEffect(() => {
if (!isPengganti) {
if (tanggalPemotongan) {
const date = dayjs(tanggalPemotongan);
setValue('tahunPajak', date.format('YYYY'));
setValue('masaPajak', date.format('MM'));
} else {
setValue('tahunPajak', '');
setValue('masaPajak', '');
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tanggalPemotongan, !isPengganti]);
const fgFasilitas = watch('fgFasilitas');
return (
<Grid container rowSpacing={2} columnSpacing={2}>
{/* Divider */}
<Grid size={{ md: 12 }}>
<Divider sx={{ fontWeight: 'bold' }} textAlign="left">
Rincian Penghasilan dan Penghitungan PPh Pasal 21
</Divider>
</Grid>
<Grid size={{ md: !isMetodePemotonganSeTahun ? 6 : 12 }}>
<Field.RadioGroup
row
name="metodePemotongan"
label="Jenis Pemotongan"
options={setahunOptions}
slotProps={{
radio: {
slotProps: {
input: {
readOnly: isPengganti,
},
},
},
}}
/>
</Grid>
{!isMetodePemotonganSeTahun && (
<Grid size={{ md: 6 }}>
<Field.Text name="noBupotSebelumnya" label="Nomor Bupot Sebelumnya" />
</Grid>
)}
<Grid size={{ md: 3 }}>
<Field.DatePicker
name="tglPemotongan"
label="Tanggal Pemotongan"
format="DD/MM/YYYY"
maxDate={dayjs()}
/>
</Grid>
<Grid size={{ md: 3 }}>
<Field.DatePicker
name="tahunPajak"
label="Tahun Pajak"
view="year"
format="YYYY"
minDate={dayjs('2025')}
readOnly={isPengganti}
openTo="year"
/>
</Grid>
<Grid size={{ md: 2.5 }}>
<Field.DatePicker
name="masaPajakAwal"
label="Masa Pajak Awal"
view="month"
format="MM"
openTo="month"
maxDate={dayjs(masaPajakAkhir)}
readOnly={isMetodePemotonganSeTahun || isPengganti}
/>
</Grid>
<Grid size={{ md: 1 }}>
<Box className="flex items-center" sx={{ justifyContent: 'center', alignItems: 'center' }}>
<Typography variant="body1" textAlign="center" fontWeight="bold">
s.d.
</Typography>
</Box>
</Grid>
<Grid size={{ md: 2.5 }}>
<Field.DatePicker
name="masaPajakAkhir"
label="Masa Pajak Akhir"
view="month"
format="MM"
openTo="month"
minDate={dayjs(masaPajakAwal)}
readOnly={isMetodePemotonganSeTahun || isPengganti}
/>
</Grid>
{/* Kode objek pajak */}
<Grid size={{ md: 6 }}>
<Field.Autocomplete name="kdObjPjk" label="Kode Objek Pajak" options={kodeObjectPajak} />
</Grid>
{/* Fasilitas */}
<Grid size={{ md: 6 }}>
<Field.Autocomplete name="fgFasilitas" label="Fasilitas" options={fgFasilitasOptions} />
</Grid>
{/* Dokumen lainnya */}
<Grid size={{ md: 6 }}>
<Field.Text
name="noDokLainnya"
label="Nomor Dokumen Lainnya"
disabled={['9', ''].includes(fgFasilitas.value)}
sx={{ '& .MuiInputBase-root.Mui-disabled': { backgroundColor: '#f6f6f6' } }}
/>
</Grid>
</Grid>
);
};
export default RincianPenghasilan;
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;
type FilterItem = {
field: string;
operator: string;
value?: string | number | Array<string | number> | null;
join?: 'AND' | 'OR';
};
type BaseParams = Record<string, any>;
/**
* Advanced filtering hook untuk building SQL WHERE clauses
*
* @example
* const { buildAdvancedFilter, buildRequestParams } = useAdvancedFilter();
* const filters = [{ field: 'noBupot', operator: 'contains', value: '123' }];
* const sql = buildAdvancedFilter(filters);
*/
export function useAdvancedFilter() {
// ✅ Konstanta untuk field types
const numericFields = new Set(['masaPajak', 'tahunPajak', 'dpp', 'pphDipotong']);
const dateFields = new Set(['created_at', 'updated_at']);
/**
* ✅ FIXED: Ubah mapping dari nomorBupot → noBupot
* Sekarang konsisten: frontend menggunakan noBupot, backend juga noBupot
*/
const fieldMap: Record<string, string> = {
// Tambahkan mapping lain jika diperlukan di sini
// Contoh: 'frontendField': 'backendField'
};
/**
* Get database field name with mapping
*/
const dbField = (field: string): string => fieldMap[field] ?? field;
/**
* Escape single quotes untuk prevent SQL injection
* ⚠️ NOTE: Ini partial protection, gunakan parameterized queries di backend!
*/
const escape = (v: string): string => String(v).replace(/'/g, "''");
/**
* Convert various date formats to YYYYMMDD
*/
const toDbDate = (value: string | Date): string => {
try {
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;
} catch (error) {
console.warn('Invalid date format:', value);
return '';
}
};
/**
* ✅ IMPROVED: Normalize operator dengan lowercase
*/
const normalizeOp = (op: string): string =>
op?.toString().trim().toLowerCase() || '';
/**
* Build advanced filter SQL WHERE clause
*/
function buildAdvancedFilter(filters?: FilterItem[] | null): string {
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;
// ============================================
// 1️⃣ DATE FIELDS
// ============================================
if (dateFields.has(fieldName)) {
const rawVal = f.value;
if (!rawVal && !op.match(/is empty|is not empty/)) continue;
const ymd = toDbDate(rawVal as string | Date);
if (!ymd) continue;
if (op === 'is') {
expr = `"${fieldName}" >= '${ymd} 00:00:00' AND "${fieldName}" <= '${ymd} 23:59:59'`;
} else if (op === 'is on or after') {
expr = `"${fieldName}" >= '${ymd}'`;
} else if (op === 'is on or before') {
expr = `"${fieldName}" <= '${ymd}'`;
}
}
// ============================================
// 2️⃣ EMPTY/NOT EMPTY
// ============================================
if (op === 'is empty') {
expr = `"${fieldName}" IS NULL`;
} else if (op === 'is not empty') {
expr = `"${fieldName}" IS NOT NULL`;
}
// ============================================
// 3️⃣ IS ANY OF (Multiple values)
// ============================================
if (!expr && op === 'is any of') {
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) {
// ✅ IMPROVED: Normalize field name comparison
const isStatusField = fieldName.toLowerCase() === 'fgstatus';
if (isStatusField) {
// Status field: LIKE for partial matching
const ors = values.map((v) => {
const s = escape(String(v).toLowerCase());
return `LOWER("${fieldName}") LIKE '%${s}%'`;
});
expr = `(${ors.join(' OR ')})`;
} else {
// Other fields: Exact match
const ors = values.map((v) => {
const s = escape(String(v).toLowerCase());
return `LOWER("${fieldName}") = '${s}'`;
});
expr = `(${ors.join(' OR ')})`;
}
}
}
// ============================================
// 4️⃣ FGSTATUS SPECIAL (Single value)
// ============================================
if (!expr && fieldName.toLowerCase() === 'fgstatus') {
const valRaw = f.value == null ? '' : String(f.value);
if (valRaw !== '' && !['is any of', 'is empty', 'is not empty'].includes(op)) {
const valEscaped = escape(valRaw.toLowerCase());
if (op === 'is') {
expr = `LOWER("${fieldName}") LIKE '%${valEscaped}%'`;
} else if (op === 'is not') {
expr = `LOWER("${fieldName}") NOT LIKE '%${valEscaped}%'`;
}
}
}
// ============================================
// 5️⃣ GENERIC OPERATORS
// ============================================
if (!expr) {
const valRaw = f.value == null ? '' : String(f.value);
if (valRaw !== '') {
const valEscaped = escape(valRaw.toLowerCase());
const isNumericField = numericFields.has(fieldName);
// Numeric field operators
if (isNumericField && /^(=|>=|<=|>|<)$/.test(op)) {
expr = `"${fieldName}" ${op} '${valEscaped}'`;
}
// Text operators
else if (op === 'contains') {
expr = `LOWER("${fieldName}") LIKE '%${valEscaped}%'`;
}
else if (op === 'equals') {
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 (op === 'is') {
expr = `LOWER("${fieldName}") = '${valEscaped}'`;
}
// Fallback
else {
expr = `LOWER("${fieldName}") = '${valEscaped}'`;
}
}
}
// Add expression with proper join
if (expr) {
exprs.push(expr);
const joinBefore = f.join ?? 'AND';
joins.push(joinBefore);
}
}
// Build final SQL
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: Tidak ada lagi field mapping untuk noBupot
* Build request parameters dengan clean up undefined values
*/
function buildRequestParams(base: BaseParams = {}, advanced: string): BaseParams {
const out: BaseParams = {};
// Copy all defined params
Object.keys(base).forEach((key) => {
if (base[key] !== undefined) {
out[key] = base[key];
}
});
// ✅ REMOVED: Field mapping noBupot → nomorBupot
// Sekarang frontend dan backend sama-sama gunakan 'noBupot'
// Add advanced filter if exists
if (advanced && advanced.trim() !== '') {
out.advanced = advanced.trim();
}
// Clean up undefined sorting params
if (out.sortingMode === undefined) {
delete out.sortingMode;
}
if (out.sortingMethod === undefined) {
delete out.sortingMethod;
}
return out;
}
return {
buildAdvancedFilter,
buildRequestParams
} as const;
}
import { useMutation, type UseMutationOptions } from '@tanstack/react-query';
import queryKey from 'src/sections/bupot-21-26/constant/queryKey';
import tahunanA1Api from '../utils/api';
import type{ TPortBulananCenceledRequest } from '../types/types';
const useCencelBulanan = (
props?: Omit<
UseMutationOptions<any, Error, TPortBulananCenceledRequest, unknown>,
'mutationKey' | 'mutationFn'
>
) =>
useMutation<any, Error, TPortBulananCenceledRequest, unknown>({
mutationKey: queryKey.tahunanA1.upload,
mutationFn: (params: TPortBulananCenceledRequest) => tahunanA1Api.batal(params),
...props,
});
export default useCencelBulanan;
import { useMutation, type UseMutationOptions } from '@tanstack/react-query';
import bulananApi from '../utils/api';
import type { TPortBulananRequest } from '../types/types';
import queryKey from 'src/sections/bupot-21-26/constant/queryKey';
const useCetakPdfDn = (
props?: Omit<
UseMutationOptions<any, Error, TPortBulananRequest, unknown>,
'mutationKey' | 'mutationFn'
>
) =>
useMutation<any, Error, TPortBulananRequest, unknown>({
mutationKey: queryKey.bulanan.upload,
mutationFn: (params: TPortBulananRequest) => bulananApi.upload(params),
...props,
});
export default useCetakPdfDn;
import { useMutation, type UseMutationOptions } from '@tanstack/react-query';
import queryKey from 'src/sections/bupot-21-26/constant/queryKey';
import type { TPortBulananRequest } from '../types/types';
import tahunanA1Api from '../utils/api';
const useDeleteTahunanA1 = (
props?: Omit<
UseMutationOptions<any, Error, TPortBulananRequest, unknown>,
'mutationKey' | 'mutationFn'
>
) =>
useMutation<any, Error, TPortBulananRequest, unknown>({
mutationKey: queryKey.tahunanA1.upload,
mutationFn: (params: TPortBulananRequest) => tahunanA1Api.delete(params),
...props,
});
export default useDeleteTahunanA1;
import { useQuery } from '@tanstack/react-query';
import { isEmpty } from 'lodash';
import { FG_PDF_STATUS, FG_SIGN_STATUS } from 'src/sections/bupot-21-26/constant';
import queryKey from 'src/sections/bupot-21-26/constant/queryKey'
import type {
TBaseResponseAPI,
TGetListDataTableDn,
TGetListDataTableDnResult,
} from '../types/types';
import tahunanA1Api from '../utils/api';
export const transformFgStatusToFgSignStatus = (fgStatus: any) => {
const splittedFgStatus = fgStatus?.split('-') || [];
if (splittedFgStatus.includes('SIGN') > 0) {
// failed
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 (isEmpty(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',
});
const normalisePropsGetBulanan = (params: TGetListDataTableDn) => ({
...params,
fgSignStatus: transformFgStatusToFgSignStatus(params.fgStatus),
fgPdf: getFgStatusPdf(params.link, transformFgStatusToFgSignStatus(params.fgStatus)),
revNo: params.revNo,
masaPajak: params.masaPajakAwal,
pasalPPh: params.pasalPPh,
idDipotong: params.userId,
});
const normalisPropsParmas = (params: any) => {
const sorting = !isEmpty(params.sort) ? transformSortModelToSortApiPayload(params.sort) : {};
return {
...params,
masaPajak: params.msPajak || null,
tahunPajak: params.thnPajak || null,
npwp: params.idDipotong || null,
advanced: isEmpty(params.advanced) ? undefined : params.advanced,
...sorting,
};
};
const useGetTahunan = ({ params, ...props }: any) => {
const query = useQuery<TBaseResponseAPI<TGetListDataTableDnResult>>({
queryKey: queryKey.tahunanA1.all(params),
queryFn: async () => {
const response = await tahunanA1Api.getList({ params: normalisPropsParmas(params) });
return {
...response,
data: response.data.map((data) => normalisePropsGetBulanan(data)),
};
},
initialData: {
data: [],
total: 0,
},
refetchOnWindowFocus: false,
...props,
});
return query;
};
export default useGetTahunan;
import { useMutation, type UseMutationOptions } from '@tanstack/react-query';
import { isEmpty } from 'lodash';
import queryKey from 'src/sections/bupot-21-26/constant/queryKey';
import { FG_FASILITAS_PPH_21, FG_PERHITUNGAN, KELAMIN } from '../../constant';
import type { TPostTahunanA1Request } from '../types/types';
import tahunanA1Api from '../utils/api';
import { formatDate } from '../utils/formatDate';
const extractKapFromKodeObjekPajak = (kodeObjekPajak: string) =>
`4111${kodeObjekPajak.split('-')[0]}`;
export const extractKjsFromKodeObjekPajak = (kodeObjekPajak: string) =>
kodeObjekPajak.split('-')[1];
const transformParams = ({ isPengganti = false, ...Data }: any): TPostTahunanA1Request => {
const { revNo: initialRevNo, statusPtkp } = Data;
const revNo = isPengganti ? parseInt(initialRevNo || 0, 10) + 1 : parseInt(initialRevNo || 0, 10);
const [status, jmlPtkp] = statusPtkp.split('/');
const npwpLog = localStorage.getItem('npwp_log') ?? '';
return {
...Data,
revNo,
noBupot: Data.noBupot || null,
idBupot: Data.idBupot || null,
fgTransaction: Data.fgTransaction,
npwpPemotong: Data.npwpPemotong || npwpLog,
idTku: Data.idTku,
masaPajakAwal: formatDate(Data.masaPajakAwal, 'MM'),
masaPajakAkhir: formatDate(Data.masaPajakAkhir, 'MM'),
tahunPajak: Data.tahunPajak,
fgNpwpNik: true,
npwp: Data.npwp,
nik: Data.nitku,
nama: Data.nama,
alamat: Data.alamat,
email: Data.email,
cc_email: Data.cc_email,
jnsKelamin: Data.optJnsKelamin.value === KELAMIN.LAKI ? 'M' : 'F',
statusPtkp: status,
jmlPtkp,
nominalPtkp: Data.rincian16,
kodeObjekPajak: Data.kdObjPjk,
pasalPPh: 'PPh Pasal 21',
kap: extractKapFromKodeObjekPajak(Data.kdObjPjk),
kjs: extractKjsFromKodeObjekPajak(Data.kdObjPjk),
fgStatusPemotonganPph: `${Data.metodePemotongan}`,
blnPenghasilanDisetahunkan:
Number(formatDate(Data.masaPajakAkhir, 'MM')) -
Number(formatDate(Data.masaPajakAwal, 'MM')) +
1,
fgJnsBupot: 'A1',
dataDetilBupotA1: {
fgKaryawanAsing: Data.fgKaryawanAsing,
passport: isEmpty(Data.passport) ? null : Data.passport,
kdNegara: !Data.fgKaryawanAsing ? 'IDN' : Data.kodeNegara,
posisiJabatan: Data.namaJabatan,
gajiPensiun: Data.rincian1,
tunjanganPPh: Data.rincian2,
tunjanganPPhGrossUp: Data.fgPerhitungan === FG_PERHITUNGAN.GROSS_UP ? 'YES' : 'NO',
tunjanganLainnyaLembur: Data.rincian3,
honorarium: Data.rincian4,
premiAsuransi: Data.rincian5,
natura: Data.rincian6,
tantiemBonus: Data.rincian7,
biayaJabatan: Data.rincian9,
iuranPensiun: Data.rincian10,
zakat: Data.rincian11,
fgFasilitas: Data.fgFasilitas === FG_FASILITAS_PPH_21.DTP ? '11' : Data.fgFasilitas,
noDokFasilitas: Data.noDokFasilitas,
},
totalPenghasilanBruto: Data.rincian8,
totalPengurang: Data.rincian12,
totalPenghasilanNeto: Data.rincian13,
noBupotSebelumnya: Data.noBupotSebelumnya || null,
totalPenghasilanNetoDariBupotSebelumnya: Data.rincian14,
totalPenghasilanNetoPph21: Data.rincian15,
pkpSetahunDisetahunkan: Data.rincian17,
pph21SetahunDisetahunkan: Data.rincian18,
pph21Terutang: Data.rincian19,
pph21DariBupotSebelumnya: Data.rincian20,
pph21DapatDikreditkan: Data.rincian21,
pph21WithheldDtp: Data.rincian22,
pph21KurangLebihBayar: Data.rincian23,
tglPemotongan: !isEmpty(Data?.tglPemotongan)
? formatDate(Data?.tglPemotongan, 'DDMMYYYY')
: formatDate(new Date(), 'DDMMYYYY'),
userId: Data.userId,
kanal: '14',
};
};
const useSaveTahunanA1 = (
props?: Omit<UseMutationOptions<any, Error, any, unknown>, 'mutationKey' | 'mutationFn'>
) =>
useMutation({
mutationKey: queryKey.tahunanA1.draft,
mutationFn: (params: any) => tahunanA1Api.save(transformParams(params)),
...props,
});
export default useSaveTahunanA1;
export type TBaseResponseAPI<T> = {
status: string;
message: string;
data: T;
time: string;
code: number;
metaPage: TBaseResponseMetaPage;
total?: number;
};
export type TBaseResponseCreateAPI<T> = {
code: number;
data?: T;
message: string;
status: string;
time: Date;
};
export interface BupotRecord {
// --- Kunci numerik dinamis ("1" sampai "53") ---
// [key: `${number}`]: number | undefined;
id: number;
idBupot: string | null;
noBupot: string | null;
thnPajak: string;
msPajak: string;
namaPemotong: string | null;
fgIdDipotong: string; // "true" / "false" (string)
idDipotong: string;
namaDipotong: string;
tglPemotongan: string;
kdJnsPjk: string;
namaTtd: string | null;
nikNpwpTtd: string | null;
created_at: string;
updated_at: string;
errorMsg: string | null;
created: string;
updated: string;
email: string | null;
npwp16Pemotong: string;
nitkuPemotong: string;
npwp16Dipotong: string;
fgKirimEmail: number;
statusEmail: string | null;
messageid: string | null;
passphrasePenandatangan: string | null;
dcPenandatangan: string | null;
serialNumberPenandatangan: string | null;
userId: string;
foreignEmployee: string; // "true" / "false" (string)
passportNo: string;
countryCode: string | null;
statusPtkp: string;
jmlPtkp: number;
posisiJabatan: string;
kdObjPjk: string;
nmObjPjk: string | null;
pasalPPh: string | null;
bruto: string;
tarif: string;
pphDipotong: string;
fgStatus: string;
fgFasilitas: string;
noDokLainnya: string;
kap: string;
kjs: string;
internal_id: string;
fgLapor: number;
revNo: number;
tglPembatalan: string | null;
fgGrossUp: number;
link: string | null;
glAccount: string;
fgkirimemail: string;
tunjanganPPh: string;
pph21ditanggungperusahaan: string;
pph21ditanggungkaryawan: string;
alamat: string;
keterangan1: string | null;
keterangan2: string | null;
keterangan3: string | null;
keterangan4: string | null;
keterangan5: string | null;
}
type TBaseResponseMetaPage = {
pageNum: number | null;
rowPerPage: number | null;
totalRow: number;
};
export type TGetListDataTableDn = {
id: number;
idBupot: null | string;
noBupot: null | string;
revNo: string;
fgTransaction: null | string;
npwpPemotong: string;
idTku: string;
masaPajakAwal: string;
masaPajakAkhir: string;
tahunPajak: string;
fgNpwpNik: string;
npwp: string;
nik: string;
nama: string;
alamat: string;
foreignEmployee: null | string;
passportNo: null | string;
countryCode: null | string;
jnsKelamin: string;
statusPtkp: string;
jmlPtkp: null | string;
kodeObjekPajak: string;
pasalPPh: string;
blnPenghasilanDisetahunkan: string;
fgKaryawanAsing: string;
passport: null | string;
kdNegara: string;
posisiJabatan: string;
gajiPensiun: number;
tunjanganPPh: number;
tunjanganPPhGrossUp: string;
tunjanganLainnyaLembur: number;
honorarium: number;
premiAsuransi: number;
natura: number;
tantiemBonus: number;
totalPenghasilanBruto: number;
biayaJabatan: number;
iuranPensiun: number;
zakat: number;
totalPengurang: number;
totalPenghasilanNeto: number;
fgStatusPemotonganPph: number;
nominalPtkp: number;
pkpSetahunDisetahunkan: number;
pph21SetahunDisetahunkan: number;
totalPenghasilanNetoDariBupotSebelumnya: number;
pph21Terutang: number;
totalPenghasilanNetoPph21: number;
noBupotSebelumnya: null | string;
pph21DariBupotSebelumnya: null | string;
pph21DapatDikreditkan: number;
pph21WithheldDtp: null | string;
pph21KurangLebihBayar: number;
kap: string;
kjs: string;
fgFasilitas: string;
email: null | string;
noDokFasilitas: string;
tglPemotongan: string;
tglPembatalan: null | string;
userId: string;
created_at: string;
updated_at: string;
created: string;
updated: string;
internal_id: string;
fgStatus: string;
npwpNikPenandatangan: null | string;
namaPenandatangan: null | string;
link: null | string;
glAccount: string;
errorMsg: null | string;
fgkirimemail: string;
keterangan1: null | string;
keterangan2: null | string;
keterangan3: null | string;
keterangan4: null | string;
keterangan5: null | string;
glName: null | string;
namaNegara: string;
};
export type TGetListDataTableDnResult = TGetListDataTableDn[];
export type TGetListDataKOPDn = {
dtp: number;
kap: string;
kjs: string;
kode: string;
nama: string;
noCertificate: number;
normanetto: string;
otherCert: number;
pasal: string;
skbBungaTabungan: number;
skbPHTB: number;
skbPasal22: number;
skbPasal23: number;
statuspph: string;
suket: number;
tarif: string;
};
export type TGetListDataKOPDnResult = TGetListDataKOPDn[];
export type ActionItem = {
title: string;
icon: React.ReactNode;
func?: () => void;
disabled?: boolean;
};
export type TPostTahunanA1Request = {
id?: number | null;
npwpPemotong: string;
idTku: string;
revNo: number;
masaPajakAwal: string;
masaPajakAkhir: string;
tahunPajak: string;
fgNpwpNik: boolean;
npwp: string;
nik: string;
nama: string;
alamat: string;
jnsKelamin: 'M' | 'F';
statusPtkp: string;
jmlPtkp: number;
nominalPtkp: number;
kodeObjekPajak: string;
pasalPPh: string;
kap: string;
kjs: string;
fgStatusPemotonganPph: number;
blnPenghasilanDisetahunkan: string;
dataDetilA0: {
fgKaryawanAsing: boolean;
passport: string;
kdNegara: string | null;
posisiJabatan: string;
gajiPensiun: number;
tunjanganPPh: number;
tunjanganPPhGrossUp: string;
tunjanganLainnyaLembur: number;
honorarium: number;
premiAsuransi: number;
natura: number;
tantiemBonus: number;
biayaJabatan: number;
iuranPensiun: number;
zakat: number;
fgFasilitas: number;
noDokFasilitas: string;
};
totalPenghasilanBruto: number;
totalPengurang: number;
totalPenghasilanNeto: number; // point 13
noBupotSebelumnya: number; // di isi jika point 14 juga diisi
totalPenghasilanNetoDariBupotSebelumnya: number; // point 14
totalPenghasilanNetoPph21: number; // point 20
pkpSetahunDisetahunkan: number; // point 17
pph21SetahunDisetahunkan: number; // point 18
pph21Terutang: number; // point 21
pph21DariBupotSebelumnya: number; // point 19
pph21DapatDikreditkan: number; // point 22
pph21WithheldDtp: number;
pph21KurangLebihBayar: number; // point 23
tglPemotongan: string;
noBupot?: string;
idBupot?: string;
};
export type TPortBulananRequest = {
id: string;
};
export type TPortBulananCenceledRequest = {
tglPembatalan: string;
} & TPortBulananRequest;
import { fetcher, endpoints } from 'src/lib/axios-ctas-box';
import type {
BupotRecord,
TBaseResponseAPI,
TGetListDataTableDnResult,
TPortBulananCenceledRequest,
TPortBulananRequest,
TPostTahunanA1Request,
} from '../types/types';
const {list, canceled, delete: deleteAPI, upload} = endpoints.pph21.tahunanA1
const tahunanA1Api = () => {};
// API untuk get list table
tahunanA1Api.getList = async (config: any) => {
const response = await fetcher<TBaseResponseAPI<TGetListDataTableDnResult>>([
list,
{
method: 'GET',
...config,
},
]);
if (!response || response.status !== 'success') {
throw new Error(response?.message || 'Failed to fetch bulanan data');
}
const { metaPage, data } = response;
return { total: metaPage ? Number(metaPage.totalRow) : 0, data };
};
tahunanA1Api.save = async (config: TPostTahunanA1Request) => {
const response = await fetcher<TBaseResponseAPI<BupotRecord[]>>([
list,
{
method: 'POST',
data: config,
},
]);
if (!response || response.status !== 'success') {
throw new Error(response?.message || 'Failed to save bulanan data');
}
return response.data;
};
tahunanA1Api.upload = async (config: TPortBulananRequest) => {
const response = await fetcher<TBaseResponseAPI<BupotRecord>>([
upload,
{
method: 'POST',
data: config,
},
]);
if (!response || response.status !== 'success') {
throw new Error(response?.message || 'Failed to upload bulanan data');
}
return response.data;
};
tahunanA1Api.delete = async (config: TPortBulananRequest) => {
const response = await fetcher<TBaseResponseAPI<BupotRecord>>([
deleteAPI,
{
method: 'POST',
data: config,
},
]);
if (!response || response.status !== 'success') {
throw new Error(response?.message || 'Failed to delete bulanan data');
}
return response.data;
};
tahunanA1Api.batal = async (config: TPortBulananCenceledRequest) => {
const response = await fetcher<TBaseResponseAPI<BupotRecord>>([
canceled,
{
method: 'POST',
data: config,
},
]);
if (!response || response.status !== 'success') {
throw new Error(response?.message || 'Failed to delete bulanan data');
}
return response.data;
};
export default tahunanA1Api;
import dayjs from 'dayjs';
/**
* Format date utility function using Day.js
* @param {string | Date | dayjs.Dayjs} date - Date to format
* @param {string} formatStr - Format string (e.g., 'YYYY-MM', 'YYYY')
* @returns {string} Formatted date string
*/
const formatDate = (date: string | Date, formatStr: string) => {
if (!date) return '';
try {
const dayjsObj = dayjs(date);
if (!dayjsObj.isValid()) {
console.error('Invalid date:', date);
return '';
}
return dayjsObj.format(formatStr);
} catch (error) {
console.error('Error formatting date:', error);
return '';
}
};
/**
* Helper function to get year from date
* @param {string | Date} date - Date
* @returns {number} Year
*/
const getTahunPajak = (date: string | Date) => {
if (!date) return 0;
return dayjs(date).year();
};
export {
formatDate, getTahunPajak
}
\ No newline at end of file
export * from './tahunan-a1-list-view';
export * from './tahunan-a1-rekam-view';
This diff is collapsed.
This diff is collapsed.
...@@ -7,7 +7,7 @@ type Props = { value?: string; revNo?: number }; ...@@ -7,7 +7,7 @@ type Props = { value?: string; revNo?: number };
const StatusChip: React.FC<Props> = ({ value, revNo }) => { const StatusChip: React.FC<Props> = ({ value, revNo }) => {
if (!value) return <Chip label="" size="small" />; if (!value) return <Chip label="" size="small" />;
if (value === 'NORMAL-Done' && revNo !== 0) { if (value === 'NORMAL-Done' && `${revNo}` !== "0") {
return ( return (
<Box <Box
sx={{ sx={{
......
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