mirror of
https://github.com/i701/sarlink-portal.git
synced 2025-07-01 21:28:23 +00:00
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
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 4m49s
This commit is contained in:
@ -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 all the filters for devices table (mobile responsive)
|
||||||
- [x] Add cancel feature to selected devices floating button
|
- [x] Add cancel feature to selected devices floating button
|
||||||
|
|
||||||
### Payments
|
### Payments1
|
||||||
- [x] Show payments table
|
- [x] Show payments table
|
||||||
- [x] Add all the filters for payment table (mobile responsive)
|
- [x] Add all the filters for payment table (mobile responsive)
|
||||||
|
- [x] add slider range filter
|
||||||
- [ ] Fix bill formula linking for generated payments
|
- [ ] Fix bill formula linking for generated payments
|
||||||
|
|
||||||
### Parental Control
|
### Parental Control
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { authOptions } from "@/app/auth";
|
import { authOptions } from "@/app/auth";
|
||||||
import { DevicesTable } from "@/components/devices-table";
|
import { DevicesTable } from "@/components/devices-table";
|
||||||
import DynamicFilter from "@/components/generic-filter";
|
import DynamicFilter from "@/components/generic-filter";
|
||||||
import AddDeviceDialogForm from "@/components/user/add-device-dialog";
|
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";
|
import DevicesTableSkeleton from "./device-table-skeleton";
|
||||||
|
|
||||||
export default async function Devices({
|
export default async function Devices({
|
||||||
@ -51,6 +51,13 @@ export default async function Devices({
|
|||||||
label: "Vendor",
|
label: "Vendor",
|
||||||
type: "string",
|
type: "string",
|
||||||
placeholder: "Enter vendor name",
|
placeholder: "Enter vendor name",
|
||||||
|
}, {
|
||||||
|
label: "Amount of Devices",
|
||||||
|
name: "amount",
|
||||||
|
type: "dual-range-slider",
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
sliderLabel: "MVR"
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
|
import { DualRangeSlider } from "@/components/ui/dual-range-slider";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -89,6 +90,16 @@ type FilterInputConfig<TKey extends string> =
|
|||||||
label: string;
|
label: string;
|
||||||
type: "radio-group";
|
type: "radio-group";
|
||||||
options: FilterOption[];
|
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
|
// Utility type to extract the config for a specific key from the array
|
||||||
@ -112,6 +123,8 @@ type FilterValues<
|
|||||||
? string[]
|
? string[]
|
||||||
: GetConfigForKey<K, TInputs> extends { type: "radio-group" }
|
: GetConfigForKey<K, TInputs> extends { type: "radio-group" }
|
||||||
? string
|
? string
|
||||||
|
: GetConfigForKey<K, TInputs> extends { type: "dual-range-slider" }
|
||||||
|
? number[]
|
||||||
: string;
|
: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -163,6 +176,16 @@ export default function DynamicFilter<
|
|||||||
TFilterKeys,
|
TFilterKeys,
|
||||||
TInputs
|
TInputs
|
||||||
>[typeof input.name];
|
>[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 {
|
} else {
|
||||||
(initialState as FilterValues<TFilterKeys, TInputs>)[input.name] =
|
(initialState as FilterValues<TFilterKeys, TInputs>)[input.name] =
|
||||||
(urlValue || "") as FilterValues<
|
(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
|
// Handles applying all filters
|
||||||
const handleApplyFilters = () => {
|
const handleApplyFilters = () => {
|
||||||
const newParams = new URLSearchParams(currentParams.toString()); // Start fresh with current params
|
const newParams = new URLSearchParams(currentParams.toString()); // Start fresh with current params
|
||||||
@ -218,6 +250,16 @@ export default function DynamicFilter<
|
|||||||
} else {
|
} else {
|
||||||
newParams.delete(input.name);
|
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 {
|
} else {
|
||||||
// String/Number inputs
|
// String/Number inputs
|
||||||
if (value) {
|
if (value) {
|
||||||
@ -245,6 +287,10 @@ export default function DynamicFilter<
|
|||||||
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
|
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
|
||||||
input.name as TFilterKeys
|
input.name as TFilterKeys
|
||||||
] = [] as FilterValues<TFilterKeys, TInputs>[typeof input.name];
|
] = [] 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") {
|
} else if (input.type === "radio-group" || input.type === "string" || input.type === "number") {
|
||||||
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
|
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
|
||||||
input.name as TFilterKeys
|
input.name as TFilterKeys
|
||||||
@ -263,7 +309,7 @@ export default function DynamicFilter<
|
|||||||
const appliedFilters = useMemo(() => {
|
const appliedFilters = useMemo(() => {
|
||||||
const filters: Array<{
|
const filters: Array<{
|
||||||
key: TFilterKeys;
|
key: TFilterKeys;
|
||||||
value: string | string[];
|
value: string | string[] | number[];
|
||||||
label: string;
|
label: string;
|
||||||
config: FilterInputConfig<TFilterKeys>;
|
config: FilterInputConfig<TFilterKeys>;
|
||||||
}> = [];
|
}> = [];
|
||||||
@ -282,6 +328,19 @@ export default function DynamicFilter<
|
|||||||
config: input,
|
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 {
|
} else {
|
||||||
filters.push({
|
filters.push({
|
||||||
key: input.name,
|
key: input.name,
|
||||||
@ -298,7 +357,7 @@ export default function DynamicFilter<
|
|||||||
// Dynamic `prettyPrintFilter` for badges
|
// Dynamic `prettyPrintFilter` for badges
|
||||||
const prettyPrintFilter = (
|
const prettyPrintFilter = (
|
||||||
_key: TFilterKeys,
|
_key: TFilterKeys,
|
||||||
value: string | string[],
|
value: string | string[] | number[],
|
||||||
config: FilterInputConfig<TFilterKeys>,
|
config: FilterInputConfig<TFilterKeys>,
|
||||||
) => {
|
) => {
|
||||||
if (config.type === "checkbox-group") {
|
if (config.type === "checkbox-group") {
|
||||||
@ -312,6 +371,18 @@ export default function DynamicFilter<
|
|||||||
</p>
|
</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 (
|
return (
|
||||||
<p>
|
<p>
|
||||||
{config.label}: <span className="text-muted-foreground">{value}</span>
|
{config.label}: <span className="text-muted-foreground">{value}</span>
|
||||||
@ -322,15 +393,26 @@ export default function DynamicFilter<
|
|||||||
// Handles removing an individual filter
|
// Handles removing an individual filter
|
||||||
const handleRemoveFilter = (keyToRemove: TFilterKeys) => {
|
const handleRemoveFilter = (keyToRemove: TFilterKeys) => {
|
||||||
const newParams = new URLSearchParams(currentParams.toString());
|
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
|
// Clear the specific input's local state
|
||||||
const inputConfig = inputs.find((input) => input.name === keyToRemove);
|
const inputConfig = inputs.find((input) => input.name === keyToRemove);
|
||||||
|
|
||||||
|
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) => ({
|
setInputValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[keyToRemove]: inputConfig?.type === "checkbox-group" ? [] : "",
|
[keyToRemove]: inputConfig?.type === "checkbox-group" ? [] : "",
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
newParams.set("page", "1"); // Reset page after removing a filter
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
replace(`${pathname}?${newParams.toString()}`);
|
replace(`${pathname}?${newParams.toString()}`);
|
||||||
@ -384,6 +466,27 @@ export default function DynamicFilter<
|
|||||||
onChange={handleCheckboxGroupChange(input.name)}
|
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":
|
case "radio-group":
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
50
components/ui/dual-range-slider.tsx
Normal file
50
components/ui/dual-range-slider.tsx
Normal 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 };
|
Reference in New Issue
Block a user