From 0157eccd57ea6b28be488a448a070fe76663e744 Mon Sep 17 00:00:00 2001 From: i701 Date: Sun, 29 Jun 2025 20:46:34 +0500 Subject: [PATCH] feat: add dual range slider component and integrate it into dynamic filter for device management --- README.md | 3 +- app/(dashboard)/devices/page.tsx | 13 ++- components/generic-filter.tsx | 119 ++++++++++++++++++++++++++-- components/ui/dual-range-slider.tsx | 50 ++++++++++++ 4 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 components/ui/dual-range-slider.tsx diff --git a/README.md b/README.md index 23e4045..8dcd0ac 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ This is a web portal for SAR Link customers. - [x] Add all the filters for devices table (mobile responsive) - [x] Add cancel feature to selected devices floating button - ### Payments + ### Payments1 - [x] Show payments table - [x] Add all the filters for payment table (mobile responsive) + - [x] add slider range filter - [ ] Fix bill formula linking for generated payments ### Parental Control diff --git a/app/(dashboard)/devices/page.tsx b/app/(dashboard)/devices/page.tsx index 7da670d..f640915 100644 --- a/app/(dashboard)/devices/page.tsx +++ b/app/(dashboard)/devices/page.tsx @@ -1,10 +1,10 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { Suspense } from "react"; import { authOptions } from "@/app/auth"; import { DevicesTable } from "@/components/devices-table"; import DynamicFilter from "@/components/generic-filter"; import AddDeviceDialogForm from "@/components/user/add-device-dialog"; -import { getServerSession } from "next-auth"; -import { redirect } from "next/navigation"; -import { Suspense } from "react"; import DevicesTableSkeleton from "./device-table-skeleton"; export default async function Devices({ @@ -51,6 +51,13 @@ export default async function Devices({ label: "Vendor", type: "string", placeholder: "Enter vendor name", + }, { + label: "Amount of Devices", + name: "amount", + type: "dual-range-slider", + min: 1, + max: 100, + sliderLabel: "MVR" } ]} /> diff --git a/components/generic-filter.tsx b/components/generic-filter.tsx index 16132d2..6c00b4e 100644 --- a/components/generic-filter.tsx +++ b/components/generic-filter.tsx @@ -15,6 +15,7 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; +import { DualRangeSlider } from "@/components/ui/dual-range-slider"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; @@ -89,6 +90,16 @@ type FilterInputConfig = label: string; type: "radio-group"; options: FilterOption[]; + } + | { + name: TKey; + label: string; + type: "dual-range-slider"; + min: number; + max: number; + step?: number; + sliderLabel?: string; + formatLabel?: (value: number | undefined) => React.ReactNode; }; // Utility type to extract the config for a specific key from the array @@ -112,6 +123,8 @@ type FilterValues< ? string[] : GetConfigForKey extends { type: "radio-group" } ? string + : GetConfigForKey extends { type: "dual-range-slider" } + ? number[] : string; }; @@ -163,6 +176,16 @@ export default function DynamicFilter< TFilterKeys, TInputs >[typeof input.name]; + } else if (input.type === "dual-range-slider") { + const minValue = searchParams.get(`${input.name}_min`); + const maxValue = searchParams.get(`${input.name}_max`); + const parsedMin = minValue ? Number(minValue) : input.min; + const parsedMax = maxValue ? Number(maxValue) : input.max; + (initialState as FilterValues)[input.name] = + [parsedMin, parsedMax] as FilterValues< + TFilterKeys, + TInputs + >[typeof input.name]; } else { (initialState as FilterValues)[input.name] = (urlValue || "") as FilterValues< @@ -203,6 +226,15 @@ export default function DynamicFilter< })); }; + // Handler for dual range slider changes + const handleDualRangeChange = + (name: TFilterKeys) => (values: number[]) => { + setInputValues((prev) => ({ + ...prev, + [name]: values, + })); + }; + // Handles applying all filters const handleApplyFilters = () => { const newParams = new URLSearchParams(currentParams.toString()); // Start fresh with current params @@ -218,6 +250,16 @@ export default function DynamicFilter< } else { newParams.delete(input.name); } + } else if (input.type === "dual-range-slider") { + const rangeValues = value as number[]; + // Only set params if values are different from the default min/max + if (rangeValues.length === 2 && (rangeValues[0] !== input.min || rangeValues[1] !== input.max)) { + newParams.set(`${input.name}_min`, rangeValues[0].toString()); + newParams.set(`${input.name}_max`, rangeValues[1].toString()); + } else { + newParams.delete(`${input.name}_min`); + newParams.delete(`${input.name}_max`); + } } else { // String/Number inputs if (value) { @@ -245,6 +287,10 @@ export default function DynamicFilter< (clearedInputState as FilterValues)[ input.name as TFilterKeys ] = [] as FilterValues[typeof input.name]; + } else if (input.type === "dual-range-slider") { + (clearedInputState as FilterValues)[ + input.name as TFilterKeys + ] = [input.min, input.max] as FilterValues[typeof input.name]; } else if (input.type === "radio-group" || input.type === "string" || input.type === "number") { (clearedInputState as FilterValues)[ input.name as TFilterKeys @@ -263,7 +309,7 @@ export default function DynamicFilter< const appliedFilters = useMemo(() => { const filters: Array<{ key: TFilterKeys; - value: string | string[]; + value: string | string[] | number[]; label: string; config: FilterInputConfig; }> = []; @@ -282,6 +328,19 @@ export default function DynamicFilter< config: input, }); } + } else if (input.type === "dual-range-slider") { + const minValue = searchParams.get(`${input.name}_min`); + const maxValue = searchParams.get(`${input.name}_max`); + if (minValue && maxValue) { + const parsedMin = Number(minValue); + const parsedMax = Number(maxValue); + filters.push({ + key: input.name, + value: [parsedMin, parsedMax], + label: input.label, + config: input, + }); + } } else { filters.push({ key: input.name, @@ -298,7 +357,7 @@ export default function DynamicFilter< // Dynamic `prettyPrintFilter` for badges const prettyPrintFilter = ( _key: TFilterKeys, - value: string | string[], + value: string | string[] | number[], config: FilterInputConfig, ) => { if (config.type === "checkbox-group") { @@ -312,6 +371,18 @@ export default function DynamicFilter<

); } + if (config.type === "dual-range-slider") { + const rangeValues = value as number[]; + const formatLabel = config.formatLabel || ((val: number | undefined) => val?.toString() || ""); + return ( +

+ {config.label}:{" "} + + {formatLabel(rangeValues[0])} - {formatLabel(rangeValues[1])} + +

+ ); + } return (

{config.label}: {value} @@ -322,15 +393,26 @@ export default function DynamicFilter< // Handles removing an individual filter const handleRemoveFilter = (keyToRemove: TFilterKeys) => { const newParams = new URLSearchParams(currentParams.toString()); - newParams.delete(keyToRemove); - newParams.set("page", "1"); // Reset page after removing a filter // Clear the specific input's local state const inputConfig = inputs.find((input) => input.name === keyToRemove); - setInputValues((prev) => ({ - ...prev, - [keyToRemove]: inputConfig?.type === "checkbox-group" ? [] : "", - })); + + if (inputConfig?.type === "dual-range-slider") { + newParams.delete(`${keyToRemove}_min`); + newParams.delete(`${keyToRemove}_max`); + setInputValues((prev) => ({ + ...prev, + [keyToRemove]: [inputConfig.min, inputConfig.max], + })); + } else { + newParams.delete(keyToRemove); + setInputValues((prev) => ({ + ...prev, + [keyToRemove]: inputConfig?.type === "checkbox-group" ? [] : "", + })); + } + + newParams.set("page", "1"); // Reset page after removing a filter startTransition(() => { replace(`${pathname}?${newParams.toString()}`); @@ -384,6 +466,27 @@ export default function DynamicFilter< onChange={handleCheckboxGroupChange(input.name)} /> ); + case "dual-range-slider": + return ( +

+ +
+ {value}{input.sliderLabel}} + value={inputValues[input.name] as number[]} + onValueChange={handleDualRangeChange(input.name)} + min={input.min} + max={input.max} + step={input.step || 1} + /> +
+
+ ); case "radio-group": return (
{ + labelPosition?: 'top' | 'bottom'; + label?: (value: number | undefined) => React.ReactNode; +} + +const DualRangeSlider = React.forwardRef< + React.ElementRef, + DualRangeSliderProps +>(({ className, label, labelPosition = 'top', ...props }, ref) => { + const initialValue = Array.isArray(props.value) ? props.value : [props.min, props.max]; + + return ( + + + + + {initialValue.map((value, index) => ( + + + {label && ( + + {label(value)} + + )} + + + ))} + + ); +}); +DualRangeSlider.displayName = 'DualRangeSlider'; + +export { DualRangeSlider };