400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Box,
|
|
TextField,
|
|
MenuItem,
|
|
Button,
|
|
Drawer,
|
|
FormControl,
|
|
Select,
|
|
Typography,
|
|
Stack,
|
|
debounce,
|
|
} from "@mui/material";
|
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
|
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|
import SearchIcon from "@mui/icons-material/Search";
|
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
|
import { useDispatch, useSelector } from "react-redux";
|
|
import { useState, useEffect, useMemo } from "react";
|
|
import { ISearchLabel } from "../DataTable/types";
|
|
import { AppDispatch } from "@/app/redux/store";
|
|
import {
|
|
updateFilter,
|
|
clearFilters,
|
|
FilterValue,
|
|
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
|
import { selectFilters } from "@/app/redux/advanedSearch/selectors";
|
|
import { normalizeValue, defaultOperatorForField } from "./utils/utils";
|
|
import { selectConditionOperators } from "@/app/redux/metadata/selectors";
|
|
|
|
// -----------------------------------------------------
|
|
// COMPONENT
|
|
// -----------------------------------------------------
|
|
|
|
export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|
const dispatch = useDispatch<AppDispatch>();
|
|
const filters = useSelector(selectFilters);
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
// Local form state for UI (synced with Redux)
|
|
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
|
const [operators, setOperators] = useState<Record<string, string>>({});
|
|
const conditionOperators = useSelector(selectConditionOperators);
|
|
|
|
console.log("[conditionOperators]", conditionOperators);
|
|
// -----------------------------------------------------
|
|
// SYNC REDUX FILTERS TO LOCAL STATE ON LOAD
|
|
// -----------------------------------------------------
|
|
useEffect(() => {
|
|
const values: Record<string, string> = {};
|
|
const ops: Record<string, string> = {};
|
|
|
|
labels.forEach(({ field, type }) => {
|
|
const filter = filters[field];
|
|
|
|
if (filter) {
|
|
if (typeof filter === "string") {
|
|
// Simple string filter
|
|
values[field] = filter;
|
|
ops[field] = defaultOperatorForField(field, type);
|
|
} else {
|
|
// FilterValue object with operator and value
|
|
values[field] = filter.value;
|
|
ops[field] = filter.operator;
|
|
}
|
|
}
|
|
|
|
// Handle date ranges
|
|
const startKey = `${field}_start`;
|
|
const endKey = `${field}_end`;
|
|
const startFilter = filters[startKey];
|
|
const endFilter = filters[endKey];
|
|
|
|
if (startFilter && typeof startFilter === "string") {
|
|
values[startKey] = startFilter;
|
|
}
|
|
if (endFilter && typeof endFilter === "string") {
|
|
values[endKey] = endFilter;
|
|
}
|
|
});
|
|
|
|
setFormValues(values);
|
|
setOperators(ops);
|
|
}, [filters, labels]);
|
|
|
|
// -----------------------------------------------------
|
|
// DEBOUNCED FILTER UPDATE
|
|
// -----------------------------------------------------
|
|
const debouncedUpdateFilter = useMemo(
|
|
() =>
|
|
debounce(
|
|
(field: string, value: string | undefined, operator?: string) => {
|
|
if (!value || value === "") {
|
|
dispatch(updateFilter({ field, value: undefined }));
|
|
return;
|
|
}
|
|
|
|
const safeValue = normalizeValue(value);
|
|
if (!safeValue) {
|
|
dispatch(updateFilter({ field, value: undefined }));
|
|
return;
|
|
}
|
|
|
|
// For text/select fields, use FilterValue with operator
|
|
const filterValue: FilterValue = {
|
|
operator: operator ?? defaultOperatorForField(field, "text"),
|
|
value: safeValue,
|
|
};
|
|
|
|
dispatch(updateFilter({ field, value: filterValue }));
|
|
},
|
|
300
|
|
),
|
|
[dispatch]
|
|
);
|
|
|
|
// -----------------------------------------------------
|
|
// handlers
|
|
// -----------------------------------------------------
|
|
|
|
const updateField = (field: string, value: string) => {
|
|
setFormValues(prev => ({ ...prev, [field]: value }));
|
|
const operator = operators[field] ?? defaultOperatorForField(field, "text");
|
|
debouncedUpdateFilter(field, value, operator);
|
|
};
|
|
|
|
const updateOperator = (field: string, op: string) => {
|
|
setOperators(prev => ({ ...prev, [field]: op }));
|
|
|
|
// If value exists, update filter immediately with new operator
|
|
const currentValue = formValues[field];
|
|
if (currentValue) {
|
|
const safeValue = normalizeValue(currentValue);
|
|
if (safeValue) {
|
|
dispatch(
|
|
updateFilter({
|
|
field,
|
|
value: { operator: op, value: safeValue },
|
|
})
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const updateDateRange = (
|
|
field: string,
|
|
start: string | undefined,
|
|
end: string | undefined
|
|
) => {
|
|
if (start) {
|
|
dispatch(updateFilter({ field: `${field}_start`, value: start }));
|
|
} else {
|
|
dispatch(updateFilter({ field: `${field}_start`, value: undefined }));
|
|
}
|
|
|
|
if (end) {
|
|
dispatch(updateFilter({ field: `${field}_end`, value: end }));
|
|
} else {
|
|
dispatch(updateFilter({ field: `${field}_end`, value: undefined }));
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setFormValues({});
|
|
setOperators({});
|
|
dispatch(clearFilters());
|
|
};
|
|
|
|
// -----------------------------------------------------
|
|
// render
|
|
// -----------------------------------------------------
|
|
return (
|
|
<Box sx={{ width: "185px" }}>
|
|
<Button
|
|
sx={{
|
|
borderRadius: "8px",
|
|
textTransform: "none",
|
|
backgroundColor: "#f5f5f5",
|
|
color: "#555",
|
|
padding: "6px 12px",
|
|
boxShadow: "inset 0 0 0 1px #ddd",
|
|
fontWeight: 400,
|
|
fontSize: "16px",
|
|
"&:hover": { backgroundColor: "#e0e0e0" },
|
|
}}
|
|
startIcon={<SearchIcon />}
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
Advanced Search
|
|
</Button>
|
|
|
|
<Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
|
|
<Box sx={{ width: 400 }} p={2}>
|
|
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
|
<Box
|
|
sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}
|
|
>
|
|
<Typography variant="h6">Search</Typography>
|
|
|
|
<Box display="flex" gap={2}>
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<SearchIcon />}
|
|
onClick={() => {
|
|
// Apply all current form values to Redux
|
|
labels.forEach(({ field, type }) => {
|
|
const val = formValues[field];
|
|
if (!val) return;
|
|
|
|
if (type === "select" || type === "text") {
|
|
const operator =
|
|
operators[field] ??
|
|
defaultOperatorForField(field, type);
|
|
const safeValue = normalizeValue(val);
|
|
if (safeValue) {
|
|
dispatch(
|
|
updateFilter({
|
|
field,
|
|
value: { operator, value: safeValue },
|
|
})
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}}
|
|
>
|
|
Apply
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<RefreshIcon />}
|
|
onClick={resetForm}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Stack spacing={2}>
|
|
{labels.map(({ label, field, type, options }) => (
|
|
<Box key={field}>
|
|
<Typography variant="body2" fontWeight={600} mb={0.5}>
|
|
{label}
|
|
</Typography>
|
|
|
|
{/* TEXT FIELDS */}
|
|
{type === "text" && (
|
|
<>
|
|
{/* AMOUNT WITH OPERATOR */}
|
|
{field === "Amount" ? (
|
|
<Box sx={{ display: "flex", gap: 1 }}>
|
|
<FormControl size="small" sx={{ minWidth: 130 }}>
|
|
<Select
|
|
value={operators[field] ?? ">="}
|
|
onChange={e =>
|
|
updateOperator(field, e.target.value)
|
|
}
|
|
>
|
|
{Object.entries(conditionOperators ?? {}).map(
|
|
([key, value]) => (
|
|
<MenuItem key={key} value={value}>
|
|
{key.replace(/_/g, " ")}{" "}
|
|
{/* Optional: make it readable */}
|
|
</MenuItem>
|
|
)
|
|
)}
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
value={formValues[field] ?? ""}
|
|
onChange={e => updateField(field, e.target.value)}
|
|
placeholder="Enter amount"
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
value={formValues[field] ?? ""}
|
|
onChange={e => updateField(field, e.target.value)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* SELECTS */}
|
|
{type === "select" && (
|
|
<FormControl fullWidth size="small">
|
|
<Select
|
|
value={formValues[field] ?? ""}
|
|
onChange={e => updateField(field, e.target.value)}
|
|
displayEmpty
|
|
>
|
|
<MenuItem value="">
|
|
<em>{label}</em>
|
|
</MenuItem>
|
|
{options?.map(opt => (
|
|
<MenuItem key={opt} value={opt}>
|
|
{opt}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
)}
|
|
|
|
{/* DATE RANGE */}
|
|
{type === "date" && (
|
|
<Stack spacing={2}>
|
|
<DatePicker
|
|
label="Start Date"
|
|
value={
|
|
formValues[`${field}_start`]
|
|
? new Date(formValues[`${field}_start`])
|
|
: null
|
|
}
|
|
onChange={value => {
|
|
if (!value) {
|
|
updateDateRange(
|
|
field,
|
|
undefined,
|
|
formValues[`${field}_end`]
|
|
);
|
|
setFormValues(prev => ({
|
|
...prev,
|
|
[`${field}_start`]: "",
|
|
}));
|
|
return;
|
|
}
|
|
const v = new Date(value);
|
|
v.setHours(0, 0, 0, 0);
|
|
const isoString = v.toISOString();
|
|
setFormValues(prev => ({
|
|
...prev,
|
|
[`${field}_start`]: isoString,
|
|
}));
|
|
updateDateRange(
|
|
field,
|
|
isoString,
|
|
formValues[`${field}_end`]
|
|
);
|
|
}}
|
|
slotProps={{
|
|
textField: { fullWidth: true, size: "small" },
|
|
}}
|
|
/>
|
|
|
|
<DatePicker
|
|
label="End Date"
|
|
value={
|
|
formValues[`${field}_end`]
|
|
? new Date(formValues[`${field}_end`])
|
|
: null
|
|
}
|
|
onChange={value => {
|
|
if (!value) {
|
|
updateDateRange(
|
|
field,
|
|
formValues[`${field}_start`],
|
|
undefined
|
|
);
|
|
setFormValues(prev => ({
|
|
...prev,
|
|
[`${field}_end`]: "",
|
|
}));
|
|
return;
|
|
}
|
|
const v = new Date(value);
|
|
v.setHours(23, 59, 59, 999);
|
|
const isoString = v.toISOString();
|
|
setFormValues(prev => ({
|
|
...prev,
|
|
[`${field}_end`]: isoString,
|
|
}));
|
|
updateDateRange(
|
|
field,
|
|
formValues[`${field}_start`],
|
|
isoString
|
|
);
|
|
}}
|
|
slotProps={{
|
|
textField: { fullWidth: true, size: "small" },
|
|
}}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</LocalizationProvider>
|
|
</Box>
|
|
</Drawer>
|
|
</Box>
|
|
);
|
|
}
|