Commit 4a474f6c authored by Rais Aryaguna's avatar Rais Aryaguna

feat: bulanan

parent 9ffe9ff5
......@@ -140,8 +140,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 React from 'react';
// import {
// IconButton,
// Popover,
// Stack,
// TextField,
// MenuItem,
// Button,
// Badge,
// Divider,
// IconButton as MuiIconButton,
// Autocomplete,
// Chip,
// } from '@mui/material';
// import { FilterList as FilterListIcon, Close as CloseIcon } from '@mui/icons-material';
// import { GridFilterModel, GridColDef, GridFilterItem } from '@mui/x-data-grid-premium';
// interface StatusOption {
// value: string;
// label: string;
// }
// interface Props {
// columns: GridColDef[];
// filterModel: GridFilterModel;
// setFilterModel: (model: GridFilterModel) => void;
// statusOptions?: StatusOption[]; // for fgStatus label lookup & options
// debounceMs?: number;
// }
// // helper operator lists
// const TEXT_OPS = [
// { value: 'contains', label: 'contains' },
// { value: 'equals', label: 'equals' },
// { value: 'is empty', label: 'is empty' },
// { value: 'is not empty', label: 'is not empty' },
// { value: 'is any of', label: 'is any of' },
// ];
// const NUMERIC_OPS = [
// { value: '=', label: '=' },
// { value: '>=', label: '>=' },
// { value: '<=', label: '<=' },
// ];
// const STATUS_OPS = [
// { value: 'is', label: 'is' },
// { value: 'is not', label: 'is not' },
// { value: 'is any of', label: 'is any of' },
// ];
// const DATE_OPS = [
// { value: 'is', label: 'is' },
// { value: 'is on or after', label: 'is on or after' },
// { value: 'is on or before', label: 'is on or before' },
// ];
// const numericFields = new Set(['masaPajak', 'tahunPajak', 'dpp', 'pphDipotong']);
// const dateFields = new Set(['created_at', 'updated_at']);
// function getOperatorOptionsForField(field?: string) {
// if (!field) return TEXT_OPS;
// if (field === 'fgStatus') return STATUS_OPS;
// if (numericFields.has(field)) return NUMERIC_OPS;
// if (dateFields.has(field)) return DATE_OPS;
// // default text-like
// return TEXT_OPS;
// }
// function defaultOperatorForField(field?: string) {
// if (!field) return 'contains';
// if (field === 'fgStatus') return 'is';
// if (numericFields.has(field)) return '=';
// if (dateFields.has(field)) return 'is';
// return 'contains';
// }
// // Memoized component
// export const CustomFilterButton: React.FC<Props> = React.memo(
// ({ columns, filterModel, setFilterModel, statusOptions = [], debounceMs = 400 }) => {
// const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
// // Local copy for editing inside popover (do not flush to parent on open)
// const [localItems, setLocalItems] = React.useState<GridFilterModel['items']>(
// () => filterModel?.items ?? []
// );
// // Keep local items in sync when parent filterModel changes externally
// React.useEffect(() => {
// setLocalItems(filterModel?.items ?? []);
// }, [filterModel?.items]);
// const debounceRef = React.useRef<number | null>(null);
// const clearDebounce = React.useCallback(() => {
// if (debounceRef.current !== null) {
// window.clearTimeout(debounceRef.current);
// debounceRef.current = null;
// }
// }, []);
// const applyImmediate = React.useCallback(
// (items: GridFilterModel['items']) => {
// clearDebounce();
// setFilterModel({ items });
// },
// [clearDebounce, setFilterModel]
// );
// const applyDebounced = React.useCallback(
// (items: GridFilterModel['items']) => {
// clearDebounce();
// debounceRef.current = window.setTimeout(() => {
// setFilterModel({ items });
// debounceRef.current = null;
// }, debounceMs) as unknown as number;
// },
// [clearDebounce, debounceMs, setFilterModel]
// );
// React.useEffect(() => clearDebounce, [clearDebounce]);
// // active filters count badge
// const activeCount =
// Number(
// (filterModel?.items || []).filter((i) => {
// if (!i || !i.field) return false;
// // count only if operator does not require no-value OR (value present) OR operator is 'is any of' with some values
// const op = String(i.operator ?? '').toLowerCase();
// if (op === 'is empty' || op === 'is not empty') return true;
// if (op === 'is any of')
// return Array.isArray(i.value) ? (i.value as any[]).length > 0 : !!i.value;
// return i.value !== undefined && i.value !== '';
// }).length
// ) || 0;
// const getCol = React.useCallback(
// (field?: string) => columns.find((c) => c.field === field),
// [columns]
// );
// // stable ref for the button so anchor stays valid across parent re-renders
// const buttonRef = React.useRef<HTMLButtonElement | null>(null);
// // OPEN: do NOT update parent state here (to avoid parent re-render before popover opens)
// const handleClick = (e: React.MouseEvent<HTMLElement>) => {
// e.stopPropagation();
// if (!filterModel?.items?.length) {
// const init = [
// {
// field: columns[0]?.field ?? '',
// operator: defaultOperatorForField(columns[0]?.field),
// value: '',
// },
// ];
// setLocalItems(init);
// } else {
// setLocalItems(filterModel.items ?? []);
// }
// setAnchorEl(buttonRef.current);
// };
// const handleClose = () => {
// setAnchorEl(null);
// };
// // add/remove operate only on localItems now (no immediate parent update)
// const addFilter = React.useCallback(() => {
// setLocalItems((prev) => {
// const next = [
// ...(prev || []),
// {
// field: columns[0]?.field ?? '',
// operator: defaultOperatorForField(columns[0]?.field),
// value: '',
// },
// ];
// return next;
// });
// }, [columns]);
// const removeFilter = React.useCallback((index: number) => {
// setLocalItems((prev) => (prev || []).filter((_, i) => i !== index));
// }, []);
// const handleClearAll = React.useCallback(() => {
// clearDebounce();
// setLocalItems([]);
// setFilterModel({ items: [] });
// }, [clearDebounce, setFilterModel]);
// // updateFilter: when user changes field/operator/value inside popover
// const updateFilter = React.useCallback(
// (index: number, key: keyof GridFilterModel['items'][0], value: any) => {
// setLocalItems((prev) => {
// const next = Array.isArray(prev) ? [...prev] : [];
// const old = next[index] ?? {
// field: columns[0]?.field ?? '',
// operator: defaultOperatorForField(columns[0]?.field),
// value: '',
// };
// let updated = { ...old, [key]: value };
// // if field changed -> reset operator/value sensibly
// if (key === 'field') {
// const newField = value as string;
// const defOp = defaultOperatorForField(newField) as any;
// updated = { ...updated, field: newField, operator: defOp, value: '' };
// next[index] = updated;
// return next;
// }
// // if operator changed -> reset value if operator is an empty-kind or multi-kind
// if (key === 'operator') {
// const op = String(value).toLowerCase();
// if (op === 'is empty' || op === 'is not empty') {
// updated = { ...updated, operator: value, value: '' };
// next[index] = updated;
// // persist operator-only change? we'll debounce on value changes; operator change itself doesn't require immediate apply
// return next;
// }
// if (op === 'is any of') {
// // set value to array if not already
// if (!Array.isArray(updated.value)) updated.value = [];
// updated = { ...updated, operator: value };
// next[index] = updated;
// return next;
// }
// // other operators: keep value but ensure type (string)
// updated = { ...updated, operator: value, value: String(updated.value ?? '') };
// next[index] = updated;
// return next;
// }
// // if value changed -> decide immediate vs debounce depending on column type
// next[index] = updated;
// const items = next;
// const col = getCol(items[index].field as string);
// // detect singleSelect
// const isSingleSelect =
// !!col && (col.type === 'singleSelect' || Array.isArray((col as any).valueOptions));
// if (isSingleSelect) {
// applyImmediate(items);
// } else {
// applyDebounced(items);
// }
// return items;
// });
// },
// [applyDebounced, applyImmediate, columns, getCol]
// );
// // APPLY button: persist localItems to parent and close
// const handleApply = React.useCallback(() => {
// clearDebounce();
// setFilterModel({ items: localItems || [] });
// setAnchorEl(null);
// }, [localItems, clearDebounce, setFilterModel]);
// return (
// <>
// <Badge badgeContent={activeCount} color="primary" overlap="circular">
// <IconButton
// size="small"
// ref={buttonRef}
// onMouseDown={(e) => e.stopPropagation()}
// onClick={handleClick}
// sx={{ color: '#123375' }}
// aria-label="filter"
// >
// <FilterListIcon />
// </IconButton>
// </Badge>
// <Popover
// open={Boolean(anchorEl)}
// anchorEl={anchorEl}
// onClose={handleClose}
// keepMounted
// anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
// transformOrigin={{ vertical: 'top', horizontal: 'right' }}
// PaperProps={{ sx: { p: 2, minWidth: 540, maxWidth: '95vw' } }}
// >
// <Stack gap={2}>
// {(localItems || []).map((item, idx) => {
// const colDef = getCol(item.field as string);
// const operatorOptions = getOperatorOptionsForField(item.field);
// const op = String(item.operator ?? defaultOperatorForField(item.field));
// // value options for select fields
// const valueOptions: string[] =
// (colDef && (colDef as any).valueOptions) ||
// (colDef && colDef.type === 'singleSelect'
// ? ((colDef as any).valueOptions ?? [])
// : []);
// // if fgStatus, source options from statusOptions prop
// const statusVals = statusOptions.map((s) => s.value);
// const isNoValueOp = op === 'is empty' || op === 'is not empty';
// const isAnyOfOp = op === 'is any of';
// const isDateField = dateFields.has(item.field as string);
// const isNumericField = numericFields.has(item.field as string);
// const isStatusField = item.field === 'fgStatus';
// return (
// <Stack
// key={idx}
// direction="row"
// spacing={1}
// alignItems="center"
// sx={{ width: '100%' }}
// >
// {/* Column */}
// <TextField
// select
// size="small"
// label="Column"
// value={item.field ?? ''}
// onChange={(e) => updateFilter(idx, 'field', e.target.value)}
// InputLabelProps={{ shrink: true }}
// sx={{ minWidth: 180 }}
// >
// {columns.map((col) => (
// <MenuItem key={String(col.field)} value={col.field}>
// {col.headerName ?? String(col.field)}
// </MenuItem>
// ))}
// </TextField>
// {/* Operator */}
// <TextField
// select
// size="small"
// label="Operator"
// value={op}
// onChange={(e) => updateFilter(idx, 'operator', e.target.value)}
// InputLabelProps={{ shrink: true }}
// sx={{ minWidth: 160 }}
// >
// {operatorOptions.map((o) => (
// <MenuItem key={o.value} value={o.value}>
// {o.label}
// </MenuItem>
// ))}
// </TextField>
// {/* Value */}
// {isNoValueOp ? (
// // show disabled placeholder when operator does not require a value
// <TextField
// size="small"
// label="Value"
// placeholder="No value required"
// value=""
// disabled
// InputLabelProps={{ shrink: true }}
// sx={{ flex: 1, minWidth: 160 }}
// />
// ) : isAnyOfOp ? (
// // multi-value input (free text or from options)
// <Autocomplete
// multiple
// freeSolo
// size="small"
// options={
// isStatusField ? statusVals : ((valueOptions as string[] | undefined) ?? [])
// }
// value={
// Array.isArray(item.value) ? item.value : item.value ? [item.value] : []
// }
// onChange={(_, values) => {
// updateFilter(idx, 'value', values);
// }}
// renderTags={(value: any[], getTagProps) =>
// value.map((option: any, index: number) => (
// <Chip
// variant="outlined"
// label={String(option)}
// {...getTagProps({ index })}
// />
// ))
// }
// renderInput={(params) => (
// <TextField
// {...params}
// label="Value(s)"
// placeholder="Select values..."
// InputLabelProps={{ shrink: true }}
// sx={{ flex: 1, minWidth: 160 }}
// />
// )}
// sx={{ flex: 1, minWidth: 160 }}
// />
// ) : isDateField ? (
// // date input (yyyy-mm-dd)
// <TextField
// size="small"
// label="Value"
// type="date"
// value={item.value ? String(item.value).slice(0, 10) : ''}
// onChange={(e) => updateFilter(idx, 'value', e.target.value)}
// InputLabelProps={{ shrink: true }}
// sx={{ flex: 1, minWidth: 160 }}
// />
// ) : colDef &&
// (colDef.type === 'singleSelect' ||
// Array.isArray((colDef as any).valueOptions)) ? (
// // single select (enum)
// <TextField
// select
// size="small"
// label="Value"
// value={item.value ?? ''}
// onChange={(e) => updateFilter(idx, 'value', e.target.value)}
// InputLabelProps={{ shrink: true }}
// sx={{ flex: 1, minWidth: 160 }}
// >
// {((colDef as any).valueOptions ?? (isStatusField ? statusVals : [])).map(
// (opt: any) => (
// <MenuItem key={String(opt)} value={opt}>
// {isStatusField
// ? (statusOptions.find((s) => s.value === opt)?.label ?? String(opt))
// : String(opt)}
// </MenuItem>
// )
// )}
// </TextField>
// ) : (
// // normal text / numeric input
// <TextField
// size="small"
// label="Value"
// placeholder="Filter Value"
// value={item.value ?? ''}
// onChange={(e) => {
// const v = e.target.value;
// if (isNumericField) {
// // allow only digits
// const digits = v.replace(/[^0-9]/g, '');
// updateFilter(idx, 'value', digits);
// } else {
// updateFilter(idx, 'value', v);
// }
// }}
// InputLabelProps={{ shrink: true }}
// inputProps={isNumericField ? { inputMode: 'numeric', pattern: '[0-9]*' } : {}}
// sx={{ flex: 1, minWidth: 160 }}
// />
// )}
// <MuiIconButton
// size="small"
// onClick={(e) => {
// e.stopPropagation();
// removeFilter(idx);
// }}
// >
// <CloseIcon fontSize="small" />
// </MuiIconButton>
// </Stack>
// );
// })}
// <Divider />
// <Stack direction="row" justifyContent="space-between" alignItems="center">
// <Button
// size="small"
// variant="outlined"
// onClick={(e) => {
// e.stopPropagation();
// addFilter();
// }}
// >
// + Add filter
// </Button>
// <Stack direction="row" spacing={1}>
// <Button
// size="small"
// onClick={(e) => {
// e.stopPropagation();
// handleClearAll();
// }}
// >
// Clear
// </Button>
// <Button
// size="small"
// variant="contained"
// onClick={(e) => {
// e.stopPropagation();
// handleApply();
// }}
// >
// Apply
// </Button>
// </Stack>
// </Stack>
// </Stack>
// </Popover>
// </>
// );
// }
// );
// export default CustomFilterButton;
import React from 'react';
import {
IconButton,
Popover,
Stack,
TextField,
MenuItem,
Button,
Badge,
Divider,
IconButton as MuiIconButton,
Autocomplete,
Chip,
} from '@mui/material';
import { FilterList as FilterListIcon, Close as CloseIcon } from '@mui/icons-material';
import type { GridFilterModel, GridColDef } from '@mui/x-data-grid-premium';
interface StatusOption {
value: string;
label: string;
}
interface Props {
columns: GridColDef[];
filterModel: GridFilterModel;
setFilterModel: (model: GridFilterModel) => void;
statusOptions?: StatusOption[]; // for fgStatus label lookup & options
debounceMs?: number;
}
type LocalFilterItem = {
field: string;
operator: string;
value?: any;
join?: 'AND' | 'OR'; // new: join operator (applies before this item, except first)
};
// helper operator lists
const TEXT_OPS = [
{ value: 'contains', label: 'contains' },
{ value: 'equals', label: 'equals' },
{ value: 'is empty', label: 'is empty' },
{ value: 'is not empty', label: 'is not empty' },
{ value: 'is any of', label: 'is any of' },
];
const NUMERIC_OPS = [
{ value: '=', label: '=' },
{ value: '>=', label: '>=' },
{ value: '<=', label: '<=' },
];
const STATUS_OPS = [
{ value: 'is', label: 'is' },
{ value: 'is not', label: 'is not' },
{ value: 'is any of', label: 'is any of' },
];
const DATE_OPS = [
{ value: 'is', label: 'is' },
{ value: 'is on or after', label: 'is on or after' },
{ value: 'is on or before', label: 'is on or before' },
];
const numericFields = new Set(['masaPajak', 'tahunPajak', 'dpp', 'pphDipotong']);
const dateFields = new Set(['created_at', 'updated_at']);
function getOperatorOptionsForField(field?: string) {
if (!field) return TEXT_OPS;
if (field === 'fgStatus') return STATUS_OPS;
if (numericFields.has(field)) return NUMERIC_OPS;
if (dateFields.has(field)) return DATE_OPS;
return TEXT_OPS;
}
function defaultOperatorForField(field?: string) {
if (!field) return 'contains';
if (field === 'fgStatus') return 'is';
if (numericFields.has(field)) return '=';
if (dateFields.has(field)) return 'is';
return 'contains';
}
// Memoized component
export const CustomFilterButton: React.FC<Props> = React.memo(
({ columns, filterModel, setFilterModel, statusOptions = [], debounceMs = 400 }) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const [localItems, setLocalItems] = React.useState<LocalFilterItem[]>(
() => (filterModel?.items as any) ?? []
);
React.useEffect(() => {
setLocalItems((filterModel?.items as any) ?? []);
}, [filterModel?.items]);
const debounceRef = React.useRef<number | null>(null);
const clearDebounce = React.useCallback(() => {
if (debounceRef.current !== null) {
window.clearTimeout(debounceRef.current);
debounceRef.current = null;
}
}, []);
const applyImmediate = React.useCallback(
(items: LocalFilterItem[]) => {
clearDebounce();
setFilterModel({ items: items as any });
},
[clearDebounce, setFilterModel]
);
const applyDebounced = React.useCallback(
(items: LocalFilterItem[]) => {
clearDebounce();
debounceRef.current = window.setTimeout(() => {
setFilterModel({ items: items as any });
debounceRef.current = null;
}, debounceMs) as unknown as number;
},
[clearDebounce, debounceMs, setFilterModel]
);
React.useEffect(() => clearDebounce, [clearDebounce]);
const activeCount =
Number(
((filterModel?.items as any) || []).filter((i: any) => {
if (!i || !i.field) return false;
const op = String(i.operator ?? '').toLowerCase();
if (op === 'is empty' || op === 'is not empty') return true;
if (op === 'is any of')
return Array.isArray(i.value) ? (i.value as any[]).length > 0 : !!i.value;
return i.value !== undefined && i.value !== '';
}).length
) || 0;
const getCol = React.useCallback(
(field?: string) => columns.find((c) => c.field === field),
[columns]
);
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
if (!filterModel?.items?.length) {
const init = [
{
field: columns[0]?.field ?? '',
operator: defaultOperatorForField(columns[0]?.field),
value: '',
join: undefined,
} as LocalFilterItem,
];
setLocalItems(init);
} else {
setLocalItems((filterModel?.items as any) ?? []);
}
setAnchorEl(buttonRef.current);
};
const handleClose = () => {
setAnchorEl(null);
};
const addFilter = React.useCallback(() => {
setLocalItems((prev) => {
const next = [
...(prev || []),
{
field: columns[0]?.field ?? '',
operator: defaultOperatorForField(columns[0]?.field),
value: '',
join: 'AND', // default join for newly added filters
} as LocalFilterItem,
];
return next;
});
}, [columns]);
const removeFilter = React.useCallback((index: number) => {
setLocalItems((prev) => (prev || []).filter((_, i) => i !== index));
}, []);
const handleClearAll = React.useCallback(() => {
clearDebounce();
setLocalItems([]);
setFilterModel({ items: [] });
}, [clearDebounce, setFilterModel]);
const updateFilter = React.useCallback(
(index: number, key: keyof LocalFilterItem, value: any) => {
setLocalItems((prev) => {
const next = Array.isArray(prev) ? [...prev] : [];
const old = next[index] ?? {
field: columns[0]?.field ?? '',
operator: defaultOperatorForField(columns[0]?.field),
value: '',
join: index === 0 ? undefined : 'AND',
};
let updated = { ...old, [key]: value };
if (key === 'field') {
const newField = value as string;
const defOp = defaultOperatorForField(newField) as any;
updated = { ...updated, field: newField, operator: defOp, value: '' };
next[index] = updated;
return next;
}
if (key === 'operator') {
const op = String(value).toLowerCase();
if (op === 'is empty' || op === 'is not empty') {
updated = { ...updated, operator: value, value: '' };
next[index] = updated;
return next;
}
if (op === 'is any of') {
if (!Array.isArray(updated.value)) updated.value = [];
updated = { ...updated, operator: value };
next[index] = updated;
return next;
}
updated = { ...updated, operator: value, value: String(updated.value ?? '') };
next[index] = updated;
return next;
}
next[index] = updated;
const items = next;
const col = getCol(items[index].field as string);
const isSingleSelect =
!!col && (col.type === 'singleSelect' || Array.isArray((col as any).valueOptions));
if (isSingleSelect) {
applyImmediate(items);
} else {
applyDebounced(items);
}
return items;
});
},
[applyDebounced, applyImmediate, columns, getCol]
);
const handleApply = React.useCallback(() => {
clearDebounce();
// Persist localItems to parent; keep join property so hook can use it
setFilterModel({ items: localItems as any });
setAnchorEl(null);
}, [localItems, clearDebounce, setFilterModel]);
return (
<>
<Badge badgeContent={activeCount} color="primary" overlap="circular">
<IconButton
size="small"
ref={buttonRef}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleClick}
sx={{ color: '#123375' }}
aria-label="filter"
>
<FilterListIcon />
</IconButton>
</Badge>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={handleClose}
keepMounted
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{ sx: { p: 2, minWidth: 640, maxWidth: '95vw' } }}
>
<Stack gap={2}>
{(localItems || []).map((item, idx) => {
const colDef = getCol(item.field as string);
const operatorOptions = getOperatorOptionsForField(item.field);
const op = String(item.operator ?? defaultOperatorForField(item.field));
const valueOptions: string[] =
(colDef && (colDef as any).valueOptions) ||
(colDef && colDef.type === 'singleSelect'
? ((colDef as any).valueOptions ?? [])
: []);
const statusVals = statusOptions.map((s) => s.value);
const isNoValueOp = op === 'is empty' || op === 'is not empty';
const isAnyOfOp = op === 'is any of';
const isDateField = dateFields.has(item.field as string);
const isNumericField = numericFields.has(item.field as string);
const isStatusField = item.field === 'fgStatus';
return (
<Stack
key={idx}
direction="row"
spacing={1}
alignItems="center"
sx={{ width: '100%' }}
>
{/* Join select (show for idx > 0) */}
{idx > 0 ? (
<TextField
select
size="small"
label=""
value={item.join ?? 'AND'}
onChange={(e) => updateFilter(idx, 'join', e.target.value as 'AND' | 'OR')}
sx={{ width: 72 }}
InputLabelProps={{ shrink: true }}
>
<MenuItem value="AND">AND</MenuItem>
<MenuItem value="OR">OR</MenuItem>
</TextField>
) : (
<div style={{ width: 72 }} />
)}
{/* Column */}
<TextField
select
size="small"
label="Column"
value={item.field ?? ''}
onChange={(e) => updateFilter(idx, 'field', e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 220 }}
>
{columns.map((col) => (
<MenuItem key={String(col.field)} value={col.field}>
{col.headerName ?? String(col.field)}
</MenuItem>
))}
</TextField>
{/* Operator */}
<TextField
select
size="small"
label="Operator"
value={op}
onChange={(e) => updateFilter(idx, 'operator', e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 200 }}
>
{operatorOptions.map((o) => (
<MenuItem key={o.value} value={o.value}>
{o.label}
</MenuItem>
))}
</TextField>
{/* Value */}
{isNoValueOp ? (
<TextField
size="small"
label="Value"
placeholder="No value required"
value=""
disabled
InputLabelProps={{ shrink: true }}
sx={{ flex: 1, minWidth: 160 }}
/>
) : isAnyOfOp ? (
<Autocomplete
multiple
freeSolo
size="small"
options={
isStatusField ? statusVals : ((valueOptions as string[] | undefined) ?? [])
}
value={
Array.isArray(item.value) ? item.value : item.value ? [item.value] : []
}
onChange={(_, values) => {
updateFilter(idx, 'value', values);
}}
renderTags={(value: any[], getTagProps) =>
value.map((option: any, index: number) => (
<Chip
variant="outlined"
label={String(option)}
{...getTagProps({ index })}
/>
))
}
renderInput={(params) => (
<TextField
{...params}
label="Value(s)"
placeholder="Select values..."
InputLabelProps={{ shrink: true }}
sx={{ flex: 1, minWidth: 160 }}
/>
)}
sx={{ flex: 1, minWidth: 160 }}
/>
) : isDateField ? (
<TextField
size="small"
label="Value"
type="date"
value={item.value ? String(item.value).slice(0, 10) : ''}
onChange={(e) => updateFilter(idx, 'value', e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ flex: 1, minWidth: 160 }}
/>
) : colDef &&
(colDef.type === 'singleSelect' ||
Array.isArray((colDef as any).valueOptions)) ? (
<TextField
select
size="small"
label="Value"
value={item.value ?? ''}
onChange={(e) => updateFilter(idx, 'value', e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ flex: 1, minWidth: 160 }}
>
{((colDef as any).valueOptions ?? (isStatusField ? statusVals : [])).map(
(opt: any) => (
<MenuItem key={String(opt)} value={opt}>
{isStatusField
? (statusOptions.find((s) => s.value === opt)?.label ?? String(opt))
: String(opt)}
</MenuItem>
)
)}
</TextField>
) : (
<TextField
size="small"
label="Value"
placeholder="Filter Value"
value={item.value ?? ''}
onChange={(e) => {
const v = e.target.value;
if (isNumericField) {
const digits = v.replace(/[^0-9]/g, '');
updateFilter(idx, 'value', digits);
} else {
updateFilter(idx, 'value', v);
}
}}
InputLabelProps={{ shrink: true }}
inputProps={isNumericField ? { inputMode: 'numeric', pattern: '[0-9]*' } : {}}
sx={{ flex: 1, minWidth: 160 }}
/>
)}
<MuiIconButton
size="small"
onClick={(e) => {
e.stopPropagation();
removeFilter(idx);
}}
>
<CloseIcon fontSize="small" />
</MuiIconButton>
</Stack>
);
})}
<Divider />
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.stopPropagation();
addFilter();
}}
>
+ Add filter
</Button>
<Stack direction="row" spacing={1}>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
handleClearAll();
}}
>
Clear
</Button>
<Button
size="small"
variant="contained"
onClick={(e) => {
e.stopPropagation();
handleApply();
}}
>
Apply
</Button>
</Stack>
</Stack>
</Stack>
</Popover>
</>
);
}
);
export default CustomFilterButton;
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],
......
// /* eslint-disable no-lonely-if */
// import {
// escapeForPostgres,
// filterModelByOperator,
// transformModelOperatorToSqlOperator,
// } from '@pjap/shared/data-grid-premium/util';
// import { isArray } from 'lodash';
// /**
// * Configuration object for customizing how individual fields are processed during search
// *
// * @typedef {Object} FieldConfig
// * @property {string} [type] - Data type of the field ('string', 'date', 'number', etc.)
// * Used for applying appropriate transformations and SQL operations
// *
// * @property {function} [transformValue] - Custom function to transform field values before SQL generation
// * Signature: (value, modelOperator) => transformedValue
// * Example: Convert date formats, escape special characters
// *
// * @property {function} [transformField] - Custom function to transform field names
// * Signature: (fieldName, value) => transformedFieldName
// * Example: Map UI field names to database column names
// *
// * @property {string|string[]} [fieldMapping] - Direct mapping to different database field name(s)
// * String: Maps to single field
// * Array: Maps to multiple fields (OR condition)
// *
// * @property {function|string} [operator] - Custom operator transformation
// * Function: (sqlOperator, originalOperator, value) => newOperator
// * String: Fixed operator to use (e.g., 'LIKE', 'IN')
// *
// * @property {function} [additionalQuery] - Generates additional SQL conditions for complex searches
// * Signature: (value) => additionalSqlCondition
// * Example: Add related table joins or complex WHERE clauses
// *
// * @property {string} [additionalQueryLogicOperator='AND'] - Logic operator ('AND' or 'OR') to combine
// * main query with additional query
// */
// /**
// * Configuration options for the useAdvancedSearch hook
// *
// * @typedef {Object} UseAdvancedSearchOptions
// * @property {Object.<string, FieldConfig>} [fieldConfigs] - Field-specific configurations
// * Key: field name, Value: FieldConfig object
// *
// * @property {function} [globalValueTransform] - Global transformation applied to all field values
// * Signature: (fieldName, value) => transformedValue
// * Applied when no field-specific transform exists
// *
// * @property {function} [globalFieldTransform] - Global transformation applied to all field names
// * Signature: (fieldName) => transformedFieldName
// * Applied when no field-specific transform exists
// *
// * @property {Object.<string, string>} [defaultFieldTypes] - Default data types for fields
// * Key: field name, Value: type string
// * Used when field type not specified in fieldConfigs
// */
// /**
// * Advanced Search Hook
// *
// * Provides functionality to convert DataGrid Premium filter models into SQL WHERE clauses
// * with extensive customization options for field transformations, value processing, and
// * complex query generation.
// *
// * MAIN USE CASES:
// * 1. Convert frontend filter UI state to backend SQL queries
// * 2. Handle complex field mappings (UI field → database column)
// * 3. Apply custom value transformations (dates, case sensitivity, escaping)
// * 4. Generate complex queries with joins and additional conditions
// *
// * @param {UseAdvancedSearchOptions} options - Configuration options for customizing search behavior
// * @returns {Object} Object containing the main query generation function
// */
// const useAdvancedSearch = (options = {}) => {
// // Extract configuration options with defaults
// const {
// fieldConfigs = {}, // Field-specific configurations
// globalValueTransform, // Global value transformation function
// globalFieldTransform, // Global field name transformation function
// defaultFieldTypes = {}, // Default field type mappings
// } = options;
// /**
// * Transform Field Values
// *
// * Applies transformations to field values based on priority:
// * 1. Field-specific transformValue function (highest priority)
// * 2. Global value transformation function
// * 3. Built-in type-based transformations (lowest priority)
// *
// * BUILT-IN TRANSFORMATIONS:
// * - 'date': Removes hyphens and underscores (2023-01-01 → 20230101)
// * - 'string': Converts to lowercase and escapes for PostgreSQL
// * - default: Returns value unchanged
// *
// * @param {string} field - The field name being processed
// * @param {any} value - The original field value from the filter
// * @param {string} modelOperator - The operator from the DataGrid model
// * @returns {any} The transformed value ready for SQL generation
// */
// const transformValue = (field, value = '', modelOperator) => {
// // Skip transformation for null/undefined values
// // if (!value) return value;
// // Priority 1: Field-specific transformation
// if (fieldConfigs[field]?.transformValue) {
// return fieldConfigs[field].transformValue(value, modelOperator);
// }
// // Priority 2: Global transformation
// if (globalValueTransform) {
// return globalValueTransform(field, value);
// }
// // Priority 3: Built-in type-based transformations
// const fieldType = fieldConfigs[field]?.type || defaultFieldTypes[field] || 'string';
// switch (fieldType) {
// case 'date':
// // Remove date separators for database storage format
// return value.replace(/-/g, '').replace(/_/g, '');
// case 'string':
// // Convert to lowercase and escape special PostgreSQL characters
// return escapeForPostgres(typeof value === 'string' ? value.toLowerCase() : value);
// default:
// // No transformation for other types (numbers, booleans, etc.)
// return value;
// }
// };
// /**
// * Transform Field Names
// *
// * Maps UI field names to database column names with priority:
// * 1. Direct fieldMapping configuration (highest priority)
// * 2. Field-specific transformField function
// * 3. Global field transformation function
// * 4. Original field name (lowest priority)
// *
// * FIELD MAPPING EXAMPLES:
// * - String mapping: "userName" → "user_name"
// * - Array mapping: "fullName" → ["first_name", "last_name"] (creates OR condition)
// *
// * @param {string} field - The original field name from the UI
// * @param {any} value - The field value (may influence transformation)
// * @returns {string|string[]} The transformed field name(s) for database queries
// */
// const transformField = (field, value) => {
// // Priority 1: Direct field mapping
// if (fieldConfigs[field]?.fieldMapping) {
// return fieldConfigs[field].fieldMapping;
// }
// // Priority 2: Field-specific transformation function
// if (fieldConfigs[field]?.transformField) {
// return fieldConfigs[field].transformField(field, value);
// }
// // Priority 3: Global field transformation
// if (globalFieldTransform) {
// return globalFieldTransform(field);
// }
// // Priority 4: Use original field name
// return field;
// };
// /**
// * Transform SQL Operators
// *
// * Converts DataGrid operators to appropriate SQL operators with custom logic support.
// * Allows field-specific operator overrides for special cases.
// *
// * OPERATOR EXAMPLES:
// * - DataGrid "contains" → SQL "LIKE"
// * - DataGrid "equals" → SQL "="
// * - Custom: Force "ILIKE" for case-insensitive searches
// *
// * @param {string} field - The field name being processed
// * @param {string} operator - The original operator from DataGrid
// * @param {any} value - The field value (may influence operator choice)
// * @returns {string} The SQL operator to use in the query
// */
// const transformOperator = (field, operator, value) => {
// // Check for field-specific operator configuration
// if (fieldConfigs[field]?.operator) {
// // If operator config is a function, call it with context
// if (typeof fieldConfigs[field].operator === 'function') {
// console.log('ini kesini lagi yah');
// return fieldConfigs[field].operator(
// transformModelOperatorToSqlOperator(operator), // Converted SQL operator
// operator, // Original DataGrid operator
// value // Field value for context
// );
// }
// console.log('ini keskip berarti');
// // If operator config is a string, use it directly
// return fieldConfigs[field].operator;
// }
// // Use default operator transformation
// return operator;
// };
// const buildQuery = (item, transformedField, fieldValue) => {
// // Transform the field value with all configured transformations
// const value = transformValue(item.columnField, fieldValue, item.operatorValue);
// // Transform the operator for this field
// const operator = transformOperator(item.columnField, item.operatorValue, fieldValue);
// console.log('ini operator datagrid', operator);
// // Get the field type for proper SQL generation
// const fieldType =
// fieldConfigs[item.columnField]?.type || defaultFieldTypes[item.columnField] || 'string';
// let baseQuery;
// // Handle multiple field mappings (when field is an array)
// if (Array.isArray(transformedField)) {
// // Check if we have multiple values for multiple fields
// if (typeof value === 'object') {
// // Map each field to its corresponding value (parallel arrays)
// // Example: fields=["first_name", "last_name"], values={0: "John", 1: "Doe"}
// // Result: first_name = 'John' AND last_name = 'Doe'
// baseQuery = transformedField
// .map((f, index) => {
// const newValue = Object.values(value)[index];
// return filterModelByOperator(f, operator, newValue, fieldType);
// })
// .join(' AND ');
// } else {
// // Same value applied to all fields (OR condition)
// // Example: fields=["first_name", "last_name"], value="John"
// // Result: first_name LIKE '%John%' OR last_name LIKE '%John%'
// baseQuery = transformedField
// .map((f) => filterModelByOperator(f, operator, value, fieldType))
// .join(' OR ');
// }
// } else {
// // Single field mapping
// // Handle custom operators that need special SQL construction
// if (fieldConfigs[item.columnField]?.operator) {
// baseQuery = `LOWER("${transformedField}") ${operator} ${value}`;
// } else {
// baseQuery = filterModelByOperator(transformedField, operator, value, fieldType);
// }
// }
// // Handle additional query conditions (for complex search scenarios)
// if (fieldConfigs[item.columnField]?.additionalQuery) {
// // Generate additional SQL condition
// const additionalQuery = fieldConfigs[item.columnField].additionalQuery(fieldValue);
// // Get the logic operator for combining base and additional queries
// const logicOperator = fieldConfigs[item.columnField]?.additionalQueryLogicOperator || 'AND';
// // Combine base and additional queries if additional query exists
// if (additionalQuery) {
// return `(${[baseQuery, additionalQuery].filter(Boolean).join(` ${logicOperator} `)})`;
// }
// }
// return baseQuery;
// };
// /**
// * Generate SQL Query from DataGrid Filter Model
// *
// * Main function that converts a DataGrid Premium filter model into a SQL WHERE clause.
// * Handles complex scenarios including:
// * - Multiple field mappings (OR conditions)
// * - Custom operators and value transformations
// * - Additional query conditions with configurable logic operators
// * - Proper SQL escaping and type handling
// *
// * FILTER MODEL STRUCTURE:
// * {
// * items: [
// * {
// * columnField: "fieldName",
// * operatorValue: "contains",
// * value: "searchTerm"
// * }
// * ],
// * linkOperator: "AND" // or "OR"
// * }
// *
// * @param {Object} model - The filter model from DataGrid Premium
// * @param {Array} model.items - Array of individual filter conditions
// * @param {string} model.linkOperator - Logic operator to combine multiple conditions ("AND" or "OR")
// * @returns {string} Generated SQL WHERE clause (without the "WHERE" keyword)
// */
// const generateSqlQueryByDatagridPremiumModel = (model) => {
// // Process each filter item in the model
// const advanced = model.items
// ?.map((item) => {
// // Transform the field name (UI → database column)
// const field = transformField(item.columnField, item.value);
// if (item.operatorValue === 'isAnyOf' && isArray(item.value)) {
// if (item.value.length > 0) {
// const query = item.value.map((v) => `${buildQuery(item, field, v)}`).join(' OR ');
// return `(${query})`;
// }
// return '';
// }
// return buildQuery(item, field, item.value);
// })
// // Remove any empty/null queries
// .filter(Boolean)
// // Join all conditions with the model's link operator (AND/OR)
// .join(` ${model.linkOperator} `);
// return advanced;
// };
// // Return the main query generation function
// return {
// generateSqlQueryByDatagridPremiumModel,
// };
// };
// export default useAdvancedSearch;
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,
......
import {
ArticleTwoTone,
AutorenewTwoTone,
DeleteSweepTwoTone,
EditNoteTwoTone,
FileOpenTwoTone,
HighlightOffTwoTone,
UploadFileTwoTone,
} from '@mui/icons-material';
import Button from '@mui/material/Button';
import type {
GridColDef,
GridFilterModel,
GridPaginationModel,
GridRowSelectionModel,
GridSortModel,
GridToolbarProps,
} from '@mui/x-data-grid-premium';
import { DataGridPremium, useGridApiRef } from '@mui/x-data-grid-premium';
import dayjs from 'dayjs';
import { isEmpty } from 'lodash';
import { enqueueSnackbar } from 'notistack';
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { unstable_batchedUpdates } from 'react-dom';
import { useNavigate } from 'react-router';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { DashboardContent } from 'src/layouts/dashboard';
import { RouterLink } from 'src/routes/components';
import { paths } from 'src/routes/paths';
import type { GridColDef, GridFilterModel, GridSortModel } from '@mui/x-data-grid-premium';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useMemo, useState } from 'react';
import StatusChip from 'src/sections/bupot-unifikasi/bupot-dn/components/StatusChip';
import TableHeaderLabel from 'src/shared/components/TableHeaderLabel';
import { formatRupiah } from 'src/shared/FormatRupiah/FormatRupiah';
import { useDebounce, useThrottle } from 'src/shared/hooks/useDebounceThrottle';
import { createTableKey, useTablePagination } from '../../paginationStore';
import { CustomToolbar } from '../components/CustomToolbar';
import { FG_STATUS_BUPOT } from '../constant';
import { appRootKey, bulanan } from '../constant/queryKey';
import { useAdvancedFilter } from '../hooks/useAdvancedFilter';
import useGetBulanan from '../hooks/useGetBulanan';
import { isEmpty } from 'lodash';
// import CustomToolbarDn from '../components/customToolbarDn';
// import CustomToolbar, { CustomFilterButton } from '../components/customToolbarDn2';
const numberIDR = (number: string) =>
new Intl.NumberFormat('id-ID', {
currency: 'IDR',
// style: 'currency'
}).format(Number(number));
export type IColumnGrid = GridColDef & {
field:
......@@ -48,95 +67,21 @@ export type IColumnGrid = GridColDef & {
valueOptions?: string[];
};
export function BulananListView() {
// const [tabs1, setTabs1] = useState<number>(1);
// const [tabs2, setTabs2] = useState<number>(0);
const [paginationModel, setPaginationModel] = useState({
page: 0, // 0-based index
pageSize: 10,
});
const [filterModel, setFilterModel] = useState<GridFilterModel>({ items: [] });
const [sortModel, setSortModel] = useState<GridSortModel>([]);
// const [rowSelectionModel, setRowSelectionModel] = useState<any>([]);
// const [rowSelectionModel, setRowSelectionModel] =
// useState<GridRowSelectionModel>(new Set<GridRowId>());
// const navigate = useNavigate();
// Enhance tabs dengan navigate
// const enhancedTabsTop = TABS_TOP_UNIFIKASI.map((tab) =>
// tab.value ? { ...tab, onClick: () => navigate(tab.value) } : tab
// );
// const enhancedTabsChoice = TABS_CHOICE.map((tab) => ({
// ...tab,
// onClick: () => navigate(tab.value),
// }));
const buildAdvancedFilter = (filters?: GridFilterModel['items']) => {
if (!filters || filters.length === 0) return '';
return filters
.map((f) => {
if (!f.value || !f.field) return null;
const field = `LOWER("${f.field}")`;
const val = String(f.value).toLowerCase();
switch (f.operator) {
case 'contains':
return `${field} LIKE '%${val}%'`;
case 'equals':
case 'is':
return `${field} = '${val}'`;
case 'isNot':
return `${field} <> '${val}'`;
default:
return null;
}
})
.filter(Boolean)
.join(' AND ');
};
const { data, isLoading } = useGetBulanan({
params: {
Page: paginationModel.page + 1, // API biasanya 1-based
Limit: paginationModel.pageSize,
advanced: buildAdvancedFilter(filterModel?.items),
sortingMode: sortModel[0]?.field,
sortingMethod: sortModel[0]?.sort,
},
refetchOnWindowFocus: false,
});
const totalRows = data?.total || 0;
const rows = useMemo(() => data?.data || [], [data?.data]);
// const handleChange = (event: React.SyntheticEvent, newValue: number) => {
// setTabs1(newValue);
// };
// const handleChange2 = (event: React.SyntheticEvent, newValue: number) => {
// setTabs2(newValue);
// };
type Status = 'draft' | 'normal' | 'cancelled' | 'amendment';
// type aman
type Status = 'draft' | 'normal' | 'cancelled' | 'amendment';
type StatusOption = {
type StatusOption = {
value: Status;
label: string;
};
};
const statusOptions: StatusOption[] = [
const statusOptions: StatusOption[] = [
{ value: 'draft', label: 'Draft' },
{ value: 'normal', label: 'Normal' },
{ value: 'cancelled', label: 'Dibatalkan' },
{ value: 'amendment', label: 'Normal Pengganti' },
];
];
const columns: IColumnGrid[] = [
const columns: IColumnGrid[] = [
{
field: 'fgStatus',
headerName: 'Status',
......@@ -149,6 +94,7 @@ export function BulananListView() {
const option = statusOptions.find((opt) => opt.value === params.value);
return option ? option.label : (params.value as string);
},
renderCell: ({ value, row }) => <StatusChip value={value} revNo={row.revNo} />,
},
{
field: 'noBupot',
......@@ -230,7 +176,7 @@ export function BulananListView() {
if (params == null) {
return '0';
}
return numberIDR(params);
return formatRupiah(params);
},
},
{
......@@ -244,7 +190,7 @@ export function BulananListView() {
if (params == null) {
return '0';
}
return numberIDR(params);
return formatRupiah(params);
},
},
{
......@@ -257,7 +203,7 @@ export function BulananListView() {
if (params == null) {
return '0';
}
return numberIDR(params);
return formatRupiah(params);
},
},
{
......@@ -273,40 +219,322 @@ export function BulananListView() {
headerAlign: 'center',
minWidth: 150,
},
{ field: 'created_at', headerName: 'Created At', width: 150 },
{ field: 'updated', headerName: 'Updated', width: 150 },
{ field: 'updated_at', headerName: 'Update At', width: 150 },
{
field: 'created_at',
headerName: 'Created At',
minWidth: 150,
valueFormatter: (params) => dayjs(params).format('DD/MM/YYYY h:mm'),
},
{
field: 'updated',
headerName: 'Updated',
minWidth: 150,
},
{
field: 'updated_at',
headerName: 'Update At',
minWidth: 150,
valueFormatter: (params) => dayjs(params).format('DD/MM/YYYY h:mm'),
},
{
field: 'keterangan1',
headerName: 'Keterangan 1',
minWidth: 200,
flex: 1,
minWidth: 150,
},
{
field: 'keterangan2',
headerName: 'Keterangan 2',
minWidth: 200,
flex: 1,
minWidth: 150,
},
{
field: 'keterangan3',
headerName: 'Keterangan 3',
minWidth: 200,
flex: 1,
minWidth: 150,
},
{
field: 'keterangan4',
headerName: 'Keterangan 4',
minWidth: 200,
flex: 1,
minWidth: 150,
},
{
field: 'keterangan5',
headerName: 'Keterangan 5',
minWidth: 200,
flex: 1,
minWidth: 150,
},
];
export function BulananListView() {
const apiRef = useGridApiRef();
const dataSelectedRef = useRef<any[]>([]);
const navigate = useNavigate();
const TABLE_KEY = useMemo(() => createTableKey(appRootKey, bulanan), []);
const [paginationState, setPaginationState, resetPaginationState] = useTablePagination(TABLE_KEY);
const { page, pageSize } = paginationState;
// State management
const [filterModel, setFilterModel] = useState<GridFilterModel>({ items: [] });
const [sortModel, setSortModel] = useState<GridSortModel>([]);
const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel | undefined>(
undefined
);
const [selectionVersion, setSelectionVersion] = useState(0);
const [modals, setModals] = useState({
delete: false,
upload: false,
cancel: false,
preview: false,
});
const [previewPayload, setPreviewPayload] = useState<Record<string, any> | undefined>(undefined);
const { buildAdvancedFilter, buildRequestParams } = useAdvancedFilter();
const params = useMemo(() => {
const advanced = buildAdvancedFilter(filterModel.items);
const baseParams = {
page: page + 1,
limit: pageSize,
sortingMode: sortModel[0]?.field,
sortingMethod: sortModel[0]?.sort,
};
return buildRequestParams(baseParams, advanced);
}, [page, pageSize, sortModel, filterModel.items, buildAdvancedFilter, buildRequestParams]);
// Data fetching
const { data, isLoading, refetch } = useGetBulanan({
params,
refetchOnWindowFocus: false,
});
const rows = useMemo(
() => (Array.isArray(data?.data) ? data.data.filter(Boolean) : []),
[data?.data]
);
const totalRows = Number(data?.total ?? 0);
const idStatusMapRef = useRef<Map<string | number, string>>(new Map());
useEffect(() => {
if (!rows.length) {
idStatusMapRef.current.clear();
return;
}
const newMap = new Map<string | number, string>();
rows.forEach((r: any) => {
const id = String(r.id ?? r.internal_id ?? '');
if (id) newMap.set(id, r?.fgStatus ?? '');
});
idStatusMapRef.current = newMap;
}, [rows]);
const handlePaginationChange = useCallback(
(model: GridPaginationModel) => {
if (model.pageSize !== pageSize) {
// Reset to first page when page size changes
setPaginationState({ page: 0, pageSize: model.pageSize });
} else {
setPaginationState({ page: model.page });
}
},
];
[pageSize, setPaginationState]
);
const handleFilterChange = useCallback(
(model: GridFilterModel) => {
setFilterModel(model);
resetPaginationState(); // Reset ke page 0 saat filter berubah
},
[resetPaginationState]
);
const handleSortChange = useCallback(
(model: GridSortModel) => {
setSortModel(model);
resetPaginationState(); // Reset ke page 0 saat sort berubah
},
[resetPaginationState]
);
const throttledPaginationChange = useThrottle(handlePaginationChange, 250);
const debouncedFilterChange = useDebounce(handleFilterChange, 400);
const debouncedSortChange = useDebounce(handleSortChange, 400);
const toggleModal = useCallback((modalName: keyof typeof modals, value: boolean) => {
setModals((prev) => ({ ...prev, [modalName]: value }));
}, []);
const handleEditData = useCallback(
(type = 'ubah') => {
const selectedRow = dataSelectedRef.current[0];
if (!selectedRow) {
enqueueSnackbar('Pilih data yang akan diubah', { variant: 'warning' });
return;
}
navigate(`/pph21/bulanan/${selectedRow.id}/${type}`);
},
[navigate]
);
const handleOpenPreview = useCallback(() => {
const selectedRow = dataSelectedRef.current?.[0];
if (!selectedRow) {
enqueueSnackbar('Pilih 1 baris untuk melihat detail', { variant: 'warning' });
return;
}
setPreviewPayload(selectedRow);
toggleModal('preview', true);
}, [toggleModal]);
const throttledSelectionChange = useThrottle((newSelection: any) => {
if (!apiRef.current) return;
const ids =
newSelection?.ids instanceof Set ? Array.from(newSelection.ids) : newSelection || [];
const selectedData = ids.map((id: any) => apiRef.current!.getRow(id)).filter(Boolean);
// Batch updates
unstable_batchedUpdates(() => {
dataSelectedRef.current = selectedData;
setRowSelectionModel(newSelection);
setSelectionVersion((v) => v + 1);
});
}, 150);
useEffect(() => {
const api = apiRef.current;
if (!api) return;
const handleRowsSet = () => {
const exec = () => {
const ids =
api.state?.rowSelection?.ids instanceof Set ? Array.from(api.state.rowSelection.ids) : [];
const updatedSelected = ids.map((id) => api.getRow(id)).filter(Boolean);
dataSelectedRef.current = updatedSelected;
setSelectionVersion((v) => v + 1);
};
if ((window as any).requestIdleCallback) {
(window as any).requestIdleCallback(exec);
} else {
setTimeout(exec, 0);
}
};
const unsubscribe = api.subscribeEvent('rowsSet', handleRowsSet);
// eslint-disable-next-line consistent-return
return () => unsubscribe();
}, [apiRef]);
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_BUPOT.DRAFT);
const allNormal = dataSelected.every((d) => d.fgStatus === FG_STATUS_BUPOT.NORMAL_DONE);
const firstItem = dataSelected[0];
return {
canDetail: count === 1,
canEdit: count === 1 && allDraft,
canDelete: hasSelection && allDraft,
canUpload: hasSelection && allDraft,
canReplacement: count === 1 && firstItem?.fgStatus === FG_STATUS_BUPOT.NORMAL_DONE,
canCancel: hasSelection && allNormal,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSelectedRef.current]);
const actions = useMemo(
() => [
[
{
title: 'Refresh List',
icon: <AutorenewTwoTone sx={{ width: 26, height: 26 }} />,
func: () => startTransition(() => void refetch()),
},
{
title: 'Edit',
icon: <EditNoteTwoTone sx={{ width: 26, height: 26 }} />,
func: () => handleEditData('ubah'),
disabled: !validatedActions.canEdit,
},
{
title: 'Detail',
icon: <ArticleTwoTone sx={{ width: 26, height: 26 }} />,
func: handleOpenPreview,
disabled: !validatedActions.canDetail,
},
{
title: 'Hapus',
icon: <DeleteSweepTwoTone sx={{ width: 26, height: 26 }} />,
func: () => toggleModal('delete', true),
disabled: !validatedActions.canDelete,
},
],
[
{
title: 'Upload',
icon: <UploadFileTwoTone sx={{ width: 26, height: 26 }} />,
func: () => toggleModal('upload', true),
disabled: !validatedActions.canUpload,
},
{
title: 'Pengganti',
icon: <FileOpenTwoTone sx={{ width: 26, height: 26 }} />,
func: () => handleEditData('pengganti'),
disabled: !validatedActions.canReplacement,
},
{
title: 'Batal',
icon: <HighlightOffTwoTone sx={{ width: 26, height: 26 }} />,
func: () => toggleModal('cancel', true),
disabled: !validatedActions.canCancel,
},
],
],
[validatedActions, refetch, handleEditData, handleOpenPreview, toggleModal]
);
const pinnedColumns = useMemo(() => ({ left: ['__check__', 'fgStatus', 'noBupot'] }), []);
const ToolbarWrapper: React.FC<GridToolbarProps> = useCallback(
(gridToolbarProps) => (
<CustomToolbar
actions={actions}
columns={columns}
filterModel={filterModel}
setFilterModel={setFilterModel}
statusOptions={statusOptions}
{...gridToolbarProps}
/>
),
[actions, filterModel]
);
const paginationModel: GridPaginationModel = useMemo(
() => ({ page, pageSize }),
[page, pageSize]
);
return (
<DashboardContent>
......@@ -326,91 +554,45 @@ export function BulananListView() {
<TableHeaderLabel label="Daftar Bupot Bulanan" />
<DataGridPremium
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 2,
'& .MuiDataGrid-cell': {
borderColor: 'divider',
},
'& .MuiDataGrid-columnHeaders': {
borderColor: 'divider',
},
}}
apiRef={apiRef}
checkboxSelection
rows={rows}
columns={columns}
loading={isLoading}
rowCount={totalRows}
initialState={{
pagination: { paginationModel: { pageSize: 10, page: 0 } },
}}
disableVirtualization
pagination
pageSizeOptions={[5, 10, 15, 25, 50, 100, 250, 500, 750, 100]}
paginationMode="server"
onPaginationModelChange={setPaginationModel}
paginationModel={paginationModel}
onPaginationModelChange={throttledPaginationChange}
pageSizeOptions={[5, 10, 15, 25, 50, 100]}
filterMode="server"
onFilterModelChange={setFilterModel}
onFilterModelChange={debouncedFilterChange}
sortingMode="server"
onSortModelChange={setSortModel}
pinnedColumns={{
left: ['__check__', 'fgStatus', 'noBupot'],
}}
onSortModelChange={debouncedSortChange}
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={throttledSelectionChange}
pinnedColumns={pinnedColumns}
cellSelection
// slots={{
// toolbar: () => (
// <CustomToolbar
// actions={[
// [
// {
// title: 'Edit',
// icon: <EditNoteTwoTone sx={{ width: 26, height: 26 }} />,
// func: () => {},
// disabled: true,
// },
// {
// title: 'Detail',
// icon: <ArticleTwoTone sx={{ width: 26, height: 26 }} />,
// func: () => {},
// disabled: true,
// },
// {
// title: 'Hapus',
// icon: <DeleteSweepTwoTone sx={{ width: 26, height: 26 }} />,
// func: () => {},
// disabled: false,
// },
// ],
// [
// {
// title: 'Upload',
// icon: <UploadFileTwoTone sx={{ width: 26, height: 26 }} />,
// func: () => {},
// disabled: false,
// },
// {
// title: 'Pengganti',
// icon: <FileOpenTwoTone sx={{ width: 26, height: 26 }} />,
// func: () => {},
// disabled: false,
// },
// {
// title: 'Batal',
// icon: <HighlightOffTwoTone sx={{ width: 26, height: 26 }} />,
// func: () => {},
// disabled: true,
// },
// ],
// ]}
// columns={columns}
// filterModel={filterModel}
// setFilterModel={setFilterModel}
// statusOptions={statusOptions.map((s) => ({ value: s.value, label: s.label }))}
// />
// ),
// }}
slots={{ toolbar: ToolbarWrapper }}
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 2,
mt: 3,
'& .MuiDataGrid-cell': {
borderColor: 'divider',
userSelect: 'text',
cursor: 'text',
},
'& .MuiDataGrid-columnHeaders': { borderColor: 'divider' },
}}
/>
{modals.delete && <div>Modatal delete</div>}
{modals.upload && <div>Modatal upload</div>}
{modals.cancel && <div>Modatal cancel</div>}
{modals.preview && previewPayload && <div>Modatal preview</div>}
</DashboardContent>
);
}
......@@ -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) => {
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>
{isOpenDialogPenandatangan && (
<DialogPenandatangan
isOpen={isOpenDialogPenandatangan}
onClose={() => {
setIsOpenDialogPenandatangan(false);
}}
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 };
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