feat: add dual range slider component and integrate it into dynamic filter for device management
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 4m49s

This commit is contained in:
2025-06-29 20:46:34 +05:00
parent 4b116df3c0
commit 0157eccd57
4 changed files with 173 additions and 12 deletions

View File

@ -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

View File

@ -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"
}
]}
/>

View File

@ -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<TKey extends string> =
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<K, TInputs> extends { type: "radio-group" }
? string
: GetConfigForKey<K, TInputs> 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<TFilterKeys, TInputs>)[input.name] =
[parsedMin, parsedMax] as FilterValues<
TFilterKeys,
TInputs
>[typeof input.name];
} else {
(initialState as FilterValues<TFilterKeys, TInputs>)[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<TFilterKeys, TInputs>)[
input.name as TFilterKeys
] = [] as FilterValues<TFilterKeys, TInputs>[typeof input.name];
} else if (input.type === "dual-range-slider") {
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
input.name as TFilterKeys
] = [input.min, input.max] as FilterValues<TFilterKeys, TInputs>[typeof input.name];
} else if (input.type === "radio-group" || input.type === "string" || input.type === "number") {
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
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<TFilterKeys>;
}> = [];
@ -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<TFilterKeys>,
) => {
if (config.type === "checkbox-group") {
@ -312,6 +371,18 @@ export default function DynamicFilter<
</p>
);
}
if (config.type === "dual-range-slider") {
const rangeValues = value as number[];
const formatLabel = config.formatLabel || ((val: number | undefined) => val?.toString() || "");
return (
<p>
{config.label}:{" "}
<span className="text-muted-foreground">
{formatLabel(rangeValues[0])} - {formatLabel(rangeValues[1])}
</span>
</p>
);
}
return (
<p>
{config.label}: <span className="text-muted-foreground">{value}</span>
@ -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 (
<div
key={input.name}
className="px-2 w-full flex flex-col gap-2 p-2 border rounded-md"
>
<Label className="font-semibold text-sm">
{input.label}
</Label>
<div className="px-3 py-2 mt-5">
<DualRangeSlider
label={(value) => <span className="text-xs">{value}{input.sliderLabel}</span>}
value={inputValues[input.name] as number[]}
onValueChange={handleDualRangeChange(input.name)}
min={input.min}
max={input.max}
step={input.step || 1}
/>
</div>
</div>
);
case "radio-group":
return (
<div

View File

@ -0,0 +1,50 @@
'use client';
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import { cn } from '@/lib/utils';
interface DualRangeSliderProps extends React.ComponentProps<typeof SliderPrimitive.Root> {
labelPosition?: 'top' | 'bottom';
label?: (value: number | undefined) => React.ReactNode;
}
const DualRangeSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
DualRangeSliderProps
>(({ className, label, labelPosition = 'top', ...props }, ref) => {
const initialValue = Array.isArray(props.value) ? props.value : [props.min, props.max];
return (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
{initialValue.map((value, index) => (
<React.Fragment key={`${index + 1}`}>
<SliderPrimitive.Thumb className="relative block h-4 w-4 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50">
{label && (
<span
className={cn(
'absolute flex w-full justify-center',
labelPosition === 'top' && '-top-7',
labelPosition === 'bottom' && 'top-4',
)}
>
{label(value)}
</span>
)}
</SliderPrimitive.Thumb>
</React.Fragment>
))}
</SliderPrimitive.Root>
);
});
DualRangeSlider.displayName = 'DualRangeSlider';
export { DualRangeSlider };