From 027d05941191b057493f68f32cc8b2af87c9d35c Mon Sep 17 00:00:00 2001 From: Julia Dai Date: Wed, 27 Aug 2025 18:09:28 +0200 Subject: [PATCH 01/14] vf-286 Lag utlegg --- app/components/data-table-filter.tsx | 1763 ++++++++++++++++++++++++++ app/components/data-table-utlegg.tsx | 149 +++ app/components/ui/accordion.tsx | 57 + app/components/ui/calendar.tsx | 75 ++ app/components/ui/calender.tsx | 74 ++ app/components/ui/combobox.tsx | 94 ++ app/components/ui/slider.tsx | 63 + app/lib/array.ts | 132 ++ app/lib/filters.ts | 969 ++++++++++++++ app/mock/api/data-utlegg.ts | 89 ++ app/routes/dashboard.tsx | 4 +- app/routes/dashboard.utlegg.tsx | 371 ++++++ package.json | 5 + pnpm-lock.yaml | 296 +++++ 14 files changed, 4139 insertions(+), 2 deletions(-) create mode 100644 app/components/data-table-filter.tsx create mode 100644 app/components/data-table-utlegg.tsx create mode 100644 app/components/ui/accordion.tsx create mode 100644 app/components/ui/calendar.tsx create mode 100644 app/components/ui/calender.tsx create mode 100644 app/components/ui/combobox.tsx create mode 100644 app/components/ui/slider.tsx create mode 100644 app/lib/array.ts create mode 100644 app/lib/filters.ts create mode 100644 app/mock/api/data-utlegg.ts create mode 100644 app/routes/dashboard.utlegg.tsx diff --git a/app/components/data-table-filter.tsx b/app/components/data-table-filter.tsx new file mode 100644 index 0000000..1d80aa8 --- /dev/null +++ b/app/components/data-table-filter.tsx @@ -0,0 +1,1763 @@ +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { take, uniq } from "@/lib/array"; +import { cn } from "@/lib/utils"; +import type { Column, ColumnMeta, RowData, Table } from "@tanstack/react-table"; +import { format, isEqual } from "date-fns"; +import { ArrowRight, Ellipsis, Filter, FilterXIcon, X } from "lucide-react"; +import { + cloneElement, + isValidElement, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { DateRange } from "react-day-picker"; +import type { ColumnOption, ElementType } from "../lib/filters"; +import { + type ColumnDataType, + type FilterModel, + createNumberRange, + dateFilterDetails, + determineNewOperator, + filterTypeOperatorDetails, + getColumn, + getColumnMeta, + isColumnOptionArray, + isFilterableColumn, + multiOptionFilterDetails, + numberFilterDetails, + optionFilterDetails, + textFilterDetails, +} from "../lib/filters"; + +export function DataTableFilter({ + table, +}: { + table: Table; +}) { + const isMobile = useIsMobile(); + + if (isMobile) { + return ( +
+
+ + +
+ + + +
+ ); + } + + return ( +
+
+ + +
+ +
+ ); +} + +export function ActiveFiltersMobileContainer({ + children, +}: { + children: React.ReactNode; +}) { + const scrollContainerRef = useRef(null); + const [showLeftBlur, setShowLeftBlur] = useState(false); + const [showRightBlur, setShowRightBlur] = useState(true); + + // Check if there's content to scroll and update blur states + const checkScroll = () => { + if (scrollContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = + scrollContainerRef.current; + + // Show left blur if scrolled to the right + setShowLeftBlur(scrollLeft > 0); + + // Show right blur if there's more content to scroll to the right + // Add a small buffer (1px) to account for rounding errors + setShowRightBlur(scrollLeft + clientWidth < scrollWidth - 1); + } + }; + + // Log blur states for debugging + // useEffect(() => { + // console.log('left:', showLeftBlur, ' right:', showRightBlur) + // }, [showLeftBlur, showRightBlur]) + + // Set up ResizeObserver to monitor container size + useEffect(() => { + if (scrollContainerRef.current) { + const resizeObserver = new ResizeObserver(() => { + checkScroll(); + }); + resizeObserver.observe(scrollContainerRef.current); + return () => { + resizeObserver.disconnect(); + }; + } + }, []); + + // Update blur states when children change + useEffect(() => { + checkScroll(); + }, [children]); + + return ( +
+ {/* Left blur effect */} + {showLeftBlur && ( +
+ )} + + {/* Scrollable container */} +
+ {children} +
+ + {/* Right blur effect */} + {showRightBlur && ( +
+ )} +
+ ); +} + +export function FilterActions({ table }: { table: Table }) { + const hasFilters = table.getState().columnFilters.length > 0; + + function clearFilters() { + table.setColumnFilters([]); + table.setGlobalFilter(""); + } + + return ( + + ); +} + +export function FilterSelector({ table }: { table: Table }) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + const [property, setProperty] = useState(undefined); + const inputRef = useRef(null); + + const column = property ? getColumn(table, property) : undefined; + const columnMeta = property ? getColumnMeta(table, property) : undefined; + + const properties = table.getAllColumns().filter(isFilterableColumn); + + const hasFilters = table.getState().columnFilters.length > 0; + + useEffect(() => { + if (property && inputRef) { + inputRef.current?.focus(); + setValue(""); + } + }, [property]); + + useEffect(() => { + if (!open) setTimeout(() => setValue(""), 150); + }, [open]); + + const content = useMemo( + () => + property && column && columnMeta ? ( + + ) : ( + + + No results. + + + {properties.map((column) => ( + + ))} + + + + ), + [property, column, columnMeta, value, table, properties] + ); + + return ( + { + setOpen(value); + if (!value) setTimeout(() => setProperty(undefined), 100); + }} + > + + + + + {content} + + + ); +} + +export function FilterableColumn({ + column, + setProperty, +}: { + column: Column; + table: Table; + setProperty: (value: string) => void; +}) { + const Icon = column.columnDef.meta?.icon!; + return ( + setProperty(column.id)} className="group"> +
+
+ {} + {column.columnDef.meta?.displayName} +
+ +
+
+ ); +} + +export function DebouncedInput({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number; + onChange: (value: string | number) => void; + debounce?: number; +} & Omit, "onChange">) { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value); + }, debounce); + + return () => clearTimeout(timeout); + }, [value, onChange, debounce]); + + return ( + setValue(e.target.value)} + /> + ); +} + +export function ActiveFilters({ table }: { table: Table }) { + const filters = table.getState().columnFilters; + + return ( + <> + {filters.map((filter) => { + const { id } = filter; + + const column = getColumn(table, id); + const meta = getColumnMeta(table, id); + + // Skip if no filter value + if (!filter.value) return null; + + // Narrow the type based on meta.type and cast filter accordingly + switch (meta.type) { + case "text": + return renderFilter( + filter as { id: string; value: FilterModel<"text", TData> }, + column, + meta as ColumnMeta & { type: "text" }, + table + ); + case "number": + return renderFilter( + filter as { id: string; value: FilterModel<"number", TData> }, + column, + meta as ColumnMeta & { type: "number" }, + table + ); + case "date": + return renderFilter( + filter as { id: string; value: FilterModel<"date", TData> }, + column, + meta as ColumnMeta & { type: "date" }, + table + ); + case "option": + return renderFilter( + filter as { id: string; value: FilterModel<"option", TData> }, + column, + meta as ColumnMeta & { type: "option" }, + table + ); + case "multiOption": + return renderFilter( + filter as { + id: string; + value: FilterModel<"multiOption", TData>; + }, + column, + meta as ColumnMeta & { + type: "multiOption"; + }, + table + ); + default: + return null; // Handle unknown types gracefully + } + })} + + ); +} + +// Generic render function for a filter with type-safe value +function renderFilter( + filter: { id: string; value: FilterModel }, + column: Column, + meta: ColumnMeta & { type: T }, + table: Table +) { + const { value } = filter; + + return ( +
+ + + + /> + + + + +
+ ); +} + +/****** Property Filter Subject ******/ + +export function FilterSubject({ + meta, +}: { + meta: ColumnMeta; +}) { + const hasIcon = !!meta?.icon; + return ( + + {hasIcon && } + {meta.displayName} + + ); +} + +/****** Property Filter Operator ******/ + +// Renders the filter operator display and menu for a given column filter +// The filter operator display is the label and icon for the filter operator +// The filter operator menu is the dropdown menu for the filter operator +export function FilterOperator({ + column, + columnMeta, + filter, +}: { + column: Column; + columnMeta: ColumnMeta; + filter: FilterModel; +}) { + const [open, setOpen] = useState(false); + + const close = () => setOpen(false); + + return ( + + + + + + + + No results. + + + + + + + ); +} + +export function FilterOperatorDisplay({ + filter, + filterType, +}: { + filter: FilterModel; + filterType: T; +}) { + const details = filterTypeOperatorDetails[filterType][filter.operator]; + + return {details.label}; +} + +interface FilterOperatorControllerProps { + column: Column; + closeController: () => void; +} + +export function FilterOperatorController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const { type } = column.columnDef.meta!; + + switch (type) { + case "option": + return ( + + ); + case "multiOption": + return ( + + ); + case "date": + return ( + + ); + case "text": + return ( + + ); + case "number": + return ( + + ); + default: + return null; + } +} + +function FilterOperatorOptionController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"option", TData>; + const filterDetails = optionFilterDetails[filter.operator]; + + const relatedFilters = Object.values(optionFilterDetails).filter( + (o) => o.target === filterDetails.target + ); + + const changeOperator = (value: string) => { + column.setFilterValue((old: typeof filter) => ({ + ...old, + operator: value, + })); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {r.label} + + ); + })} + + ); +} + +function FilterOperatorMultiOptionController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"multiOption", TData>; + const filterDetails = multiOptionFilterDetails[filter.operator]; + + const relatedFilters = Object.values(multiOptionFilterDetails).filter( + (o) => o.target === filterDetails.target + ); + + const changeOperator = (value: string) => { + column.setFilterValue((old: typeof filter) => ({ + ...old, + operator: value, + })); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {r.label} + + ); + })} + + ); +} + +function FilterOperatorDateController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"date", TData>; + const filterDetails = dateFilterDetails[filter.operator]; + + const relatedFilters = Object.values(dateFilterDetails).filter( + (o) => o.target === filterDetails.target + ); + + const changeOperator = (value: string) => { + column.setFilterValue((old: typeof filter) => ({ + ...old, + operator: value, + })); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {r.label} + + ); + })} + + ); +} + +export function FilterOperatorTextController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"text", TData>; + const filterDetails = textFilterDetails[filter.operator]; + + const relatedFilters = Object.values(textFilterDetails).filter( + (o) => o.target === filterDetails.target + ); + + const changeOperator = (value: string) => { + column.setFilterValue((old: typeof filter) => ({ + ...old, + operator: value, + })); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {r.label} + + ); + })} + + ); +} + +function FilterOperatorNumberController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"number", TData>; + + // Show all related operators + const relatedFilters = Object.values(numberFilterDetails); + const relatedFilterOperators = relatedFilters.map((r) => r.value); + + const changeOperator = (value: (typeof relatedFilterOperators)[number]) => { + column.setFilterValue((old: typeof filter) => { + // Clear out the second value when switching to single-input operators + const target = numberFilterDetails[value].target; + + const newValues = + target === "single" ? [old.values[0]] : createNumberRange(old.values); + + return { ...old, operator: value, values: newValues }; + }); + closeController(); + }; + + return ( +
+ + {relatedFilters.map((r) => ( + changeOperator(r.value)} + value={r.value} + key={r.value} + > + {r.label} {/**/} + + ))} + +
+ ); +} + +/****** Property Filter Value ******/ + +export function FilterValue({ + id, + column, + columnMeta, + table, +}: { + id: string; + column: Column; + columnMeta: ColumnMeta; + table: Table; +}) { + return ( + + + + + + + + + + ); +} + +interface FilterValueDisplayProps { + id: string; + column: Column; + columnMeta: ColumnMeta; + table: Table; +} + +export function FilterValueDisplay({ + id, + column, + columnMeta, + table, +}: FilterValueDisplayProps) { + switch (columnMeta.type) { + case "option": + return ( + + ); + case "multiOption": + return ( + + ); + case "date": + return ( + + ); + case "text": + return ( + + ); + case "number": + return ( + + ); + default: + return null; + } +} + +export function FilterValueOptionDisplay({ + id, + column, + columnMeta, + table, +}: FilterValueDisplayProps) { + let options: ColumnOption[]; + const columnVals = table + .getCoreRowModel() + .rows.flatMap((r) => r.getValue(id)) + .filter((v): v is NonNullable => v !== undefined && v !== null); + const uniqueVals = uniq(columnVals); + + // If static options are provided, use them + if (columnMeta.options) { + options = columnMeta.options; + } + + // No static options provided, + // We should dynamically generate them based on the column data + else if (columnMeta.transformOptionFn) { + const transformOptionFn = columnMeta.transformOptionFn; + + options = uniqueVals.map((v) => + transformOptionFn(v as ElementType>) + ); + } + + // Make sure the column data conforms to ColumnOption type + else if (isColumnOptionArray(uniqueVals)) { + options = uniqueVals; + } + + // Invalid configuration + else { + throw new Error( + `[data-table-filter] [${id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type` + ); + } + + const filter = column.getFilterValue() as FilterModel<"option", TData>; + const selected = options.filter((o) => filter?.values.includes(o.value)); + + // We display the selected options based on how many are selected + // + // If there is only one option selected, we display its icon and label + // + // If there are multiple options selected, we display: + // 1) up to 3 icons of the selected options + // 2) the number of selected options + if (selected.length === 1) { + const { label, icon: Icon } = selected[0]; + const hasIcon = !!Icon; + return ( + + {hasIcon && + (isValidElement(Icon) ? ( + Icon + ) : ( + + ))} + {label} + + ); + } + const name = columnMeta.displayName.toLowerCase(); + const pluralName = name.endsWith("s") ? `${name}es` : `${name}s`; + + const hasOptionIcons = !options?.some((o) => !o.icon); + + return ( +
+ {hasOptionIcons && + take(selected, 3).map(({ value, icon }) => { + const Icon = icon!; + return isValidElement(Icon) ? ( + Icon + ) : ( + + ); + })} + + {selected.length} {pluralName} + +
+ ); +} + +export function FilterValueMultiOptionDisplay({ + id, + column, + columnMeta, + table, +}: FilterValueDisplayProps) { + let options: ColumnOption[]; + const columnVals = table + .getCoreRowModel() + .rows.flatMap((r) => r.getValue(id)) + .filter((v): v is NonNullable => v !== undefined && v !== null); + const uniqueVals = uniq(columnVals); + + // If static options are provided, use them + if (columnMeta.options) { + options = columnMeta.options; + } + + // No static options provided, + // We should dynamically generate them based on the column data + else if (columnMeta.transformOptionFn) { + const transformOptionFn = columnMeta.transformOptionFn; + + options = uniqueVals.map((v) => + transformOptionFn(v as ElementType>) + ); + } + + // Make sure the column data conforms to ColumnOption type + else if (isColumnOptionArray(uniqueVals)) { + options = uniqueVals; + } + + // Invalid configuration + else { + throw new Error( + `[data-table-filter] [${id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type` + ); + } + + const filter = column.getFilterValue() as FilterModel<"multiOption", TData>; + const selected = options.filter((o) => filter?.values[0].includes(o.value)); + + if (selected.length === 1) { + const { label, icon: Icon } = selected[0]; + const hasIcon = !!Icon; + return ( + + {hasIcon && + (isValidElement(Icon) ? ( + Icon + ) : ( + + ))} + + {label} + + ); + } + + const name = columnMeta.displayName.toLowerCase(); + + const hasOptionIcons = !columnMeta.options?.some((o) => !o.icon); + + return ( +
+ {hasOptionIcons && ( +
+ {take(selected, 3).map(({ value, icon }) => { + const Icon = icon!; + return isValidElement(Icon) ? ( + cloneElement(Icon, { key: value }) + ) : ( + + ); + })} +
+ )} + + {selected.length} {name} + +
+ ); +} + +function formatDateRange(start: Date, end: Date) { + const sameMonth = start.getMonth() === end.getMonth(); + const sameYear = start.getFullYear() === end.getFullYear(); + + if (sameMonth && sameYear) { + return `${format(start, "MMM d")} - ${format(end, "d, yyyy")}`; + } + + if (sameYear) { + return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`; + } + + return `${format(start, "MMM d, yyyy")} - ${format(end, "MMM d, yyyy")}`; +} + +export function FilterValueDateDisplay({ + column, +}: FilterValueDisplayProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"date", TData>) + : undefined; + + if (!filter) return null; + if (filter.values.length === 0) return ; + if (filter.values.length === 1) { + const value = filter.values[0]; + + const formattedDateStr = format(value, "MMM d, yyyy"); + + return {formattedDateStr}; + } + + const formattedRangeStr = formatDateRange(filter.values[0], filter.values[1]); + + return {formattedRangeStr}; +} + +export function FilterValueTextDisplay({ + column, +}: FilterValueDisplayProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"text", TData>) + : undefined; + + if (!filter) return null; + if (filter.values.length === 0 || filter.values[0].trim() === "") + return ; + + const value = filter.values[0]; + + return {value}; +} + +export function FilterValueNumberDisplay({ + column, + columnMeta, +}: FilterValueDisplayProps) { + const maxFromMeta = columnMeta.max; + const cappedMax = maxFromMeta ?? 20000; + + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"number", TData>) + : undefined; + + if (!filter) return null; + + if ( + filter.operator === "is between" || + filter.operator === "is not between" + ) { + const minValue = filter.values[0]; + const maxValue = + filter.values[1] === Number.POSITIVE_INFINITY || + filter.values[1] >= cappedMax + ? `${cappedMax}+` + : filter.values[1]; + + return ( + + {minValue} and {maxValue} + + ); + } + + if (!filter.values || filter.values.length === 0) { + return null; + } + + const value = filter.values[0]; + return {value}; +} + +export function FitlerValueController({ + id, + column, + columnMeta, + table, +}: { + id: string; + column: Column; + columnMeta: ColumnMeta; + table: Table; +}) { + switch (columnMeta.type) { + case "option": + return ( + + ); + case "multiOption": + return ( + + ); + case "date": + return ( + + ); + case "text": + return ( + + ); + case "number": + return ( + + ); + default: + return null; + } +} + +interface ProperFilterValueMenuProps { + id: string; + column: Column; + columnMeta: ColumnMeta; + table: Table; +} + +export function FilterValueOptionController({ + id, + column, + columnMeta, + table, +}: ProperFilterValueMenuProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"option", TData>) + : undefined; + + let options: ColumnOption[]; + const columnVals = table + .getCoreRowModel() + .rows.flatMap((r) => r.getValue(id)) + .filter((v): v is NonNullable => v !== undefined && v !== null); + const uniqueVals = uniq(columnVals); + + // If static options are provided, use them + if (columnMeta.options) { + options = columnMeta.options; + } + + // No static options provided, + // We should dynamically generate them based on the column data + else if (columnMeta.transformOptionFn) { + const transformOptionFn = columnMeta.transformOptionFn; + + options = uniqueVals.map((v) => + transformOptionFn(v as ElementType>) + ); + } + + // Make sure the column data conforms to ColumnOption type + else if (isColumnOptionArray(uniqueVals)) { + options = uniqueVals; + } + + // Invalid configuration + else { + throw new Error( + `[data-table-filter] [${id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type` + ); + } + + const optionsCount: Record = columnVals.reduce( + (acc, curr) => { + const { value } = columnMeta.transformOptionFn + ? columnMeta.transformOptionFn(curr as ElementType>) + : { value: curr as string }; + + acc[value] = (acc[value] ?? 0) + 1; + return acc; + }, + {} as Record + ); + + function handleOptionSelect(value: string, check: boolean) { + if (check) + column?.setFilterValue( + (old: undefined | FilterModel<"option", TData>) => { + if (!old || old.values.length === 0) + return { + operator: "is", + values: [value], + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"option", TData>; + + const newValues = [...old.values, value]; + + return { + operator: "is any of", + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"option", TData>; + } + ); + else + column?.setFilterValue( + (old: undefined | FilterModel<"option", TData>) => { + if (!old || old.values.length <= 1) return undefined; + + const newValues = old.values.filter((v) => v !== value); + return { + operator: newValues.length > 1 ? "is any of" : "is", + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"option", TData>; + } + ); + } + + return ( + + + No results. + + + {options.map((v) => { + const checked = Boolean(filter?.values.includes(v.value)); + const count = optionsCount[v.value] ?? 0; + + return ( + { + handleOptionSelect(v.value, !checked); + }} + className="group flex items-center justify-between gap-1.5" + > +
+ + {v.icon && + (isValidElement(v.icon) ? ( + v.icon + ) : ( + + ))} + + {v.label} + + {count < 100 ? count : "100+"} + + +
+
+ ); + })} +
+
+
+ ); +} + +export function FilterValueMultiOptionController< + TData extends RowData, + TValue +>({ + id, + column, + columnMeta, + table, +}: ProperFilterValueMenuProps) { + const filter = column.getFilterValue() as + | FilterModel<"multiOption", TData> + | undefined; + + let options: ColumnOption[]; + const columnVals = table + .getCoreRowModel() + .rows.flatMap((r) => r.getValue(id)) + .filter((v): v is NonNullable => v !== undefined && v !== null); + const uniqueVals = uniq(columnVals); + + // If static options are provided, use them + if (columnMeta.options) { + options = columnMeta.options; + } + + // No static options provided, + // We should dynamically generate them based on the column data + else if (columnMeta.transformOptionFn) { + const transformOptionFn = columnMeta.transformOptionFn; + + options = uniqueVals.map((v) => + transformOptionFn(v as ElementType>) + ); + } + + // Make sure the column data conforms to ColumnOption type + else if (isColumnOptionArray(uniqueVals)) { + options = uniqueVals; + } + + // Invalid configuration + else { + throw new Error( + `[data-table-filter] [${id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type` + ); + } + + const optionsCount: Record = columnVals.reduce( + (acc, curr) => { + const value = columnMeta.options + ? (curr as string) + : columnMeta.transformOptionFn!( + curr as ElementType> + ).value; + + acc[value] = (acc[value] ?? 0) + 1; + return acc; + }, + {} as Record + ); + + // Handles the selection/deselection of an option + function handleOptionSelect(value: string, check: boolean) { + if (check) { + column.setFilterValue( + (old: undefined | FilterModel<"multiOption", TData>) => { + if ( + !old || + old.values.length === 0 || + !old.values[0] || + old.values[0].length === 0 + ) + return { + operator: "include", + values: [[value]], + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"multiOption", TData>; + + const newValues = [uniq([...old.values[0], value])]; + + return { + operator: determineNewOperator( + "multiOption", + old.values, + newValues, + old.operator + ), + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"multiOption", TData>; + } + ); + } else + column.setFilterValue( + (old: undefined | FilterModel<"multiOption", TData>) => { + if (!old?.values[0] || old.values[0].length <= 1) return undefined; + + const newValues = [ + uniq([...old.values[0], value]).filter((v) => v !== value), + ]; + + return { + operator: determineNewOperator( + "multiOption", + old.values, + newValues, + old.operator + ), + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"multiOption", TData>; + } + ); + } + + return ( + + + No results. + + + {options.map((v) => { + const checked = Boolean(filter?.values[0]?.includes(v.value)); + const count = optionsCount[v.value] ?? 0; + + return ( + { + handleOptionSelect(v.value, !checked); + }} + className="group flex items-center justify-between gap-1.5" + > +
+ + {v.icon && + (isValidElement(v.icon) ? ( + v.icon + ) : ( + + ))} + + {v.label} + + {count < 100 ? count : "100+"} + + +
+
+ ); + })} +
+
+
+ ); +} + +export function FilterValueDateController({ + column, +}: ProperFilterValueMenuProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"date", TData>) + : undefined; + + const [date, setDate] = useState({ + from: filter?.values[0] ?? new Date(), + to: filter?.values[1] ?? undefined, + }); + + function changeDateRange(value: DateRange | undefined) { + const start = value?.from; + const end = + start && value && value.to && !isEqual(start, value.to) + ? value.to + : undefined; + + setDate({ from: start, to: end }); + + const isRange = start && end; + + const newValues = isRange ? [start, end] : start ? [start] : []; + + column.setFilterValue((old: undefined | FilterModel<"date", TData>) => { + if (!old || old.values.length === 0) + return { + operator: newValues.length > 1 ? "is between" : "is", + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"date", TData>; + + return { + operator: + old.values.length < newValues.length + ? "is between" + : old.values.length > newValues.length + ? "is" + : old.operator, + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"date", TData>; + }); + } + + return ( + + {/* */} + {/* No results. */} + + +
+ +
+
+
+
+ ); +} + +export function FilterValueTextController({ + column, +}: ProperFilterValueMenuProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"text", TData>) + : undefined; + + const changeText = (value: string | number) => { + column.setFilterValue((old: undefined | FilterModel<"text", TData>) => { + if (!old || old.values.length === 0) + return { + operator: "contains", + values: [String(value)], + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"text", TData>; + return { operator: old.operator, values: [String(value)] }; + }); + }; + + return ( + + + + + + + + + + ); +} + +export function FilterValueNumberController({ + table, + column, + columnMeta, +}: ProperFilterValueMenuProps) { + const maxFromMeta = columnMeta.max; + /* const cappedMax = maxFromMeta ?? Number.MAX_SAFE_INTEGER; + */ + const cappedMax = maxFromMeta ?? 30000; + + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"number", TData>) + : undefined; + + const isNumberRange = + !!filter && numberFilterDetails[filter.operator].target === "multiple"; + + const [datasetMin] = column.getFacetedMinMaxValues() ?? [0, 0]; + + const initialValues = () => { + if (filter?.values) { + return filter.values.map((val) => + val >= cappedMax ? `${cappedMax}+` : val.toString() + ); + } + return [datasetMin.toString()]; + }; + + const [inputValues, setInputValues] = useState(initialValues); + + const changeNumber = (value: number[]) => { + const sortedValues = [...value].sort((a, b) => a - b); + + column.setFilterValue((old: undefined | FilterModel<"number", TData>) => { + if (!old || old.values.length === 0) { + return { + operator: "is", + values: sortedValues, + }; + } + + const operator = numberFilterDetails[old.operator]; + let newValues: number[]; + + if (operator.target === "single") { + newValues = [sortedValues[0]]; + } else { + newValues = [ + sortedValues[0] >= cappedMax ? cappedMax : sortedValues[0], + sortedValues[1] >= cappedMax + ? Number.POSITIVE_INFINITY + : sortedValues[1], + ]; + } + + return { + operator: old.operator, + values: newValues, + }; + }); + }; + + const handleInputChange = (index: number, value: string) => { + const newValues = [...inputValues]; + if (isNumberRange && Number.parseInt(value, 10) >= cappedMax) { + newValues[index] = `${cappedMax}+`; + } else { + newValues[index] = value; + } + + setInputValues(newValues); + + const parsedValues = newValues.map((val) => { + if (val.trim() === "") return 0; + if (val === `${cappedMax}+`) return cappedMax; + return Number.parseInt(val, 10); + }); + + changeNumber(parsedValues); + }; + + const changeType = (type: "single" | "range") => { + column.setFilterValue((old: undefined | FilterModel<"number", TData>) => { + if (type === "single") { + return { + operator: "is", + values: [old?.values[0] ?? 0], + }; + } + const newMaxValue = old?.values[0] ?? cappedMax; + return { + operator: "is between", + values: [0, newMaxValue], + }; + }); + + if (type === "single") { + setInputValues([inputValues[0]]); + } else { + const maxValue = inputValues[0] || cappedMax.toString(); + setInputValues(["0", maxValue]); + } + }; + + const slider = { + value: inputValues.map((val) => + val === "" || val === `${cappedMax}+` + ? cappedMax + : Number.parseInt(val, 10) + ), + onValueChange: (value: number[]) => { + const values = value.map((val) => (val >= cappedMax ? cappedMax : val)); + setInputValues( + values.map((v) => (v >= cappedMax ? `${cappedMax}+` : v.toString())) + ); + changeNumber(values); + }, + }; + + return ( + + + +
+ + changeType(v === "range" ? "range" : "single") + } + > + + Single + Range + + + { + handleInputChange(0, value[0].toString()); + }} + min={datasetMin} + max={cappedMax} + step={1} + aria-orientation="horizontal" + /> +
+ Value + handleInputChange(0, e.target.value)} + max={cappedMax} + /> +
+
+ + +
+
+ Min + handleInputChange(0, e.target.value)} + max={cappedMax} + /> +
+
+ Max + handleInputChange(1, e.target.value)} + max={cappedMax} + /> +
+
+
+
+
+
+
+
+ ); +} diff --git a/app/components/data-table-utlegg.tsx b/app/components/data-table-utlegg.tsx new file mode 100644 index 0000000..ebe916f --- /dev/null +++ b/app/components/data-table-utlegg.tsx @@ -0,0 +1,149 @@ +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { + ColumnDef, + ColumnFiltersState, + VisibilityState, +} from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useState } from "react"; +import { DataTableFilter } from "./data-table-filter"; +import { DataTablePagination } from "./data-table-pagination"; + +interface DataTableProps { + columns: Array>; + data: Array; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [columnFilters, setColumnFilters] = useState([]); + + const [columnVisibility, setColumnVisibility] = useState({ + id: false, + region: false, + }); + const [rowSelection, setRowSelection] = useState({}); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + columnFilters, + columnVisibility, + rowSelection, + }, + }); + + return ( +
+
+ + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ); +} diff --git a/app/components/ui/accordion.tsx b/app/components/ui/accordion.tsx new file mode 100644 index 0000000..d360b34 --- /dev/null +++ b/app/components/ui/accordion.tsx @@ -0,0 +1,57 @@ +"use client"; + +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/app/components/ui/calendar.tsx b/app/components/ui/calendar.tsx new file mode 100644 index 0000000..b8df044 --- /dev/null +++ b/app/components/ui/calendar.tsx @@ -0,0 +1,75 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: React.ComponentProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "size-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: + "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground", + day_range_end: + "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ) +} + +export { Calendar } diff --git a/app/components/ui/calender.tsx b/app/components/ui/calender.tsx new file mode 100644 index 0000000..21e2dcf --- /dev/null +++ b/app/components/ui/calender.tsx @@ -0,0 +1,74 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import * as React from "react"; +import { DayPicker } from "react-day-picker"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/app/components/ui/combobox.tsx b/app/components/ui/combobox.tsx new file mode 100644 index 0000000..a28ddc2 --- /dev/null +++ b/app/components/ui/combobox.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Check, ChevronsUpDown } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +const frameworks = [ + { + value: "next.js", + label: "Next.js", + }, + { + value: "sveltekit", + label: "SvelteKit", + }, + { + value: "nuxt.js", + label: "Nuxt.js", + }, + { + value: "remix", + label: "Remix", + }, + { + value: "astro", + label: "Astro", + }, +]; + +export function ComboboxDemo() { + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(""); + + return ( + + + + + + + + + No framework found. + + {frameworks.map((framework) => ( + { + setValue(currentValue === value ? "" : currentValue); + setOpen(false); + }} + > + {framework.label} + + + ))} + + + + + + ); +} diff --git a/app/components/ui/slider.tsx b/app/components/ui/slider.tsx new file mode 100644 index 0000000..09391e8 --- /dev/null +++ b/app/components/ui/slider.tsx @@ -0,0 +1,63 @@ +"use client" + +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider } diff --git a/app/lib/array.ts b/app/lib/array.ts new file mode 100644 index 0000000..2478f99 --- /dev/null +++ b/app/lib/array.ts @@ -0,0 +1,132 @@ +export function intersection(a: T[], b: T[]): T[] { + return a.filter((x) => b.includes(x)) +} + +/** + * Computes a stable hash string for any value using deep inspection. + * This function recursively builds a string for primitives, arrays, and objects. + * It uses a cache (WeakMap) to avoid rehashing the same object twice, which is + * particularly beneficial if an object appears in multiple places. + */ +function deepHash(value: any, cache = new WeakMap()): string { + // Handle primitives and null/undefined. + if (value === null) return 'null' + if (value === undefined) return 'undefined' + const type = typeof value + if (type === 'number' || type === 'boolean' || type === 'string') { + return `${type}:${value.toString()}` + } + if (type === 'function') { + // Note: using toString for functions. + return `function:${value.toString()}` + } + + // For objects and arrays, use caching to avoid repeated work. + if (type === 'object') { + // If we’ve seen this object before, return the cached hash. + if (cache.has(value)) { + return cache.get(value)! + } + let hash: string + if (Array.isArray(value)) { + // Compute hash for each element in order. + hash = `array:[${value.map((v) => deepHash(v, cache)).join(',')}]` + } else { + // For objects, sort keys to ensure the representation is stable. + const keys = Object.keys(value).sort() + const props = keys + .map((k) => `${k}:${deepHash(value[k], cache)}`) + .join(',') + hash = `object:{${props}}` + } + cache.set(value, hash) + return hash + } + + // Fallback if no case matched. + return `${type}:${value.toString()}` +} + +/** + * Performs deep equality check for any two values. + * This recursively checks primitives, arrays, and plain objects. + */ +function deepEqual(a: any, b: any): boolean { + // Check strict equality first. + if (a === b) return true + // If types differ, they’re not equal. + if (typeof a !== typeof b) return false + if (a === null || b === null || a === undefined || b === undefined) + return false + + // Check arrays. + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false + } + return true + } + + // Check objects. + if (typeof a === 'object') { + if (typeof b !== 'object') return false + const aKeys = Object.keys(a).sort() + const bKeys = Object.keys(b).sort() + if (aKeys.length !== bKeys.length) return false + for (let i = 0; i < aKeys.length; i++) { + if (aKeys[i] !== bKeys[i]) return false + if (!deepEqual(a[aKeys[i]], b[bKeys[i]])) return false + } + return true + } + + // For any other types (should be primitives by now), use strict equality. + return false +} + +/** + * Returns a new array containing only the unique values from the input array. + * Uniqueness is determined by deep equality. + * + * @param arr - The array of values to be filtered. + * @returns A new array with duplicates removed. + */ +export function uniq(arr: T[]): T[] { + // Use a Map where key is the deep hash and value is an array of items sharing the same hash. + const seen = new Map() + const result: T[] = [] + + for (const item of arr) { + const hash = deepHash(item) + if (seen.has(hash)) { + // There is a potential duplicate; check the stored items with the same hash. + const itemsWithHash = seen.get(hash)! + let duplicateFound = false + for (const existing of itemsWithHash) { + if (deepEqual(existing, item)) { + duplicateFound = true + break + } + } + if (!duplicateFound) { + itemsWithHash.push(item) + result.push(item) + } + } else { + // First time this hash appears. + seen.set(hash, [item]) + result.push(item) + } + } + + return result +} + +export function take(a: T[], n: number): T[] { + return a.slice(0, n) +} + +export function flatten(a: T[][]): T[] { + return a.flat() +} diff --git a/app/lib/filters.ts b/app/lib/filters.ts new file mode 100644 index 0000000..5d473cb --- /dev/null +++ b/app/lib/filters.ts @@ -0,0 +1,969 @@ +import '@tanstack/table-core' +import type { AccessorFn, Column, Row, RowData } from '@tanstack/react-table' +import type { ColumnMeta, Table } from '@tanstack/react-table' +import { + endOfDay, + isAfter, + isBefore, + isSameDay, + isWithinInterval, + startOfDay, +} from 'date-fns' +import type { LucideIcon } from 'lucide-react' +import { intersection, uniq } from './array' + +export type ElementType = T extends (infer U)[] ? U : T + +declare module '@tanstack/react-table' { + interface ColumnMeta { + /* The display name of the column. */ + displayName: string + + /* The column icon. */ + icon: LucideIcon + + /* The data type of the column. */ + type: ColumnDataType + + /* An optional list of options for the column. */ + /* This is used for columns with type 'option' or 'multiOption'. */ + /* If the options are known ahead of time, they can be defined here. */ + /* Otherwise, they will be dynamically generated based on the data. */ + options?: ColumnOption[] + + /* An optional function to transform columns with type 'option' or 'multiOption'. */ + /* This is used to convert each raw option into a ColumnOption. */ + transformOptionFn?: ( + value: ElementType>, + ) => ColumnOption + + /* An optional "soft" max for the number range slider. */ + /* This is used for columns with type 'number'. */ + max?: number + } +} + +/* TODO: Allow both accessorFn and accessorKey */ +export function defineMeta< + TData, + /* Only accessorFn - WORKS */ + TAccessor extends AccessorFn, + TVal extends ReturnType, + /* Only accessorKey - WORKS */ + // TAccessor extends DeepKeys, + // TVal extends DeepValue, + + /* Both accessorKey and accessorFn - BROKEN */ + /* ISSUE: Won't infer transformOptionFn input type correctly. */ + // TAccessor extends AccessorFn | DeepKeys, + // TVal extends TAccessor extends AccessorFn + // ? ReturnType + // : TAccessor extends DeepKeys + // ? DeepValue + // : never, + TType extends ColumnDataType, +>( + accessor: TAccessor, + meta: Omit, 'type'> & { + type: TType + }, +): ColumnMeta { + return meta +} + +/* + * Represents a possible value for a column property of type 'option' or 'multiOption'. + */ +export interface ColumnOption { + /* The label to display for the option. */ + label: string + /* The internal value of the option. */ + value: string + /* An optional icon to display next to the label. */ + icon?: React.ReactElement | React.ElementType +} + +/* + * Represents the data type of a column. + */ +export type ColumnDataType = + /* The column value is a string that should be searchable. */ + | 'text' + | 'number' + | 'date' + /* The column value can be a single value from a list of options. */ + | 'option' + /* The column value can be zero or more values from a list of options. */ + | 'multiOption' + +/* Operators for text data */ +export type TextFilterOperator = 'contains' | 'does not contain' + +/* Operators for number data */ +export type NumberFilterOperator = + | 'is' + | 'is not' + | 'is less than' + | 'is greater than or equal to' + | 'is greater than' + | 'is less than or equal to' + | 'is between' + | 'is not between' + +/* Operators for date data */ +export type DateFilterOperator = + | 'is' + | 'is not' + | 'is before' + | 'is on or after' + | 'is after' + | 'is on or before' + | 'is between' + | 'is not between' + +/* Operators for option data */ +export type OptionFilterOperator = 'is' | 'is not' | 'is any of' | 'is none of' + +/* Operators for multi-option data */ +export type MultiOptionFilterOperator = + | 'include' + | 'exclude' + | 'include any of' + | 'include all of' + | 'exclude if any of' + | 'exclude if all' + +/* Maps filter operators to their respective data types */ +type FilterOperators = { + text: TextFilterOperator + number: NumberFilterOperator + date: DateFilterOperator + option: OptionFilterOperator + multiOption: MultiOptionFilterOperator +} + +/* Maps filter values to their respective data types */ +export type FilterTypes = { + text: string + number: number + date: Date + option: string + multiOption: string[] +} + +/* + * + * FilterValue is a type that represents a filter value for a specific column. + * + * It consists of: + * - Operator: The operator to be used for the filter. + * - Values: An array of values to be used for the filter. + * + */ +export type FilterModel = { + operator: FilterOperators[T] + values: Array + columnMeta: Column['columnDef']['meta'] +} + +/* + * FilterDetails is a type that represents the details of all the filter operators for a specific column data type. + */ +export type FilterDetails = { + [key in FilterOperators[T]]: FilterOperatorDetails +} + +type FilterOperatorDetailsBase = { + /* The operator value. Usually the string representation of the operator. */ + value: OperatorValue + /* The label for the operator, to show in the UI. */ + label: string + /* How much data the operator applies to. */ + target: 'single' | 'multiple' + /* The plural form of the operator, if applicable. */ + singularOf?: FilterOperators[T] + /* The singular form of the operator, if applicable. */ + pluralOf?: FilterOperators[T] + /* All related operators. Normally, all the operators which share the same target. */ + relativeOf: FilterOperators[T] | Array + /* Whether the operator is negated. */ + isNegated: boolean + /* If the operator is not negated, this provides the negated equivalent. */ + negation?: FilterOperators[T] + /* If the operator is negated, this provides the positive equivalent. */ + negationOf?: FilterOperators[T] +} + +/* + * + * FilterOperatorDetails is a type that provides details about a filter operator for a specific column data type. + * It extends FilterOperatorDetailsBase with additional logic and contraints on the defined properties. + * + */ +export type FilterOperatorDetails< + OperatorValue, + T extends ColumnDataType, +> = FilterOperatorDetailsBase & + ( + | { singularOf?: never; pluralOf?: never } + | { target: 'single'; singularOf: FilterOperators[T]; pluralOf?: never } + | { target: 'multiple'; singularOf?: never; pluralOf: FilterOperators[T] } + ) & + ( + | { isNegated: false; negation: FilterOperators[T]; negationOf?: never } + | { isNegated: true; negation?: never; negationOf: FilterOperators[T] } + ) + +/* Details for all the filter operators for option data type */ +export const optionFilterDetails = { + is: { + label: 'is', + value: 'is', + target: 'single', + singularOf: 'is not', + relativeOf: 'is any of', + isNegated: false, + negation: 'is not', + }, + 'is not': { + label: 'is not', + value: 'is not', + target: 'single', + singularOf: 'is', + relativeOf: 'is none of', + isNegated: true, + negationOf: 'is', + }, + 'is any of': { + label: 'is any of', + value: 'is any of', + target: 'multiple', + pluralOf: 'is', + relativeOf: 'is', + isNegated: false, + negation: 'is none of', + }, + 'is none of': { + label: 'is none of', + value: 'is none of', + target: 'multiple', + pluralOf: 'is not', + relativeOf: 'is not', + isNegated: true, + negationOf: 'is any of', + }, +} as const satisfies FilterDetails<'option'> + +/* Details for all the filter operators for multi-option data type */ +export const multiOptionFilterDetails = { + include: { + label: 'include', + value: 'include', + target: 'single', + singularOf: 'include any of', + relativeOf: 'exclude', + isNegated: false, + negation: 'exclude', + }, + exclude: { + label: 'exclude', + value: 'exclude', + target: 'single', + singularOf: 'exclude if any of', + relativeOf: 'include', + isNegated: true, + negationOf: 'include', + }, + 'include any of': { + label: 'include any of', + value: 'include any of', + target: 'multiple', + pluralOf: 'include', + relativeOf: ['exclude if all', 'include all of', 'exclude if any of'], + isNegated: false, + negation: 'exclude if all', + }, + 'exclude if all': { + label: 'exclude if all', + value: 'exclude if all', + target: 'multiple', + pluralOf: 'exclude', + relativeOf: ['include any of', 'include all of', 'exclude if any of'], + isNegated: true, + negationOf: 'include any of', + }, + 'include all of': { + label: 'include all of', + value: 'include all of', + target: 'multiple', + pluralOf: 'include', + relativeOf: ['include any of', 'exclude if all', 'exclude if any of'], + isNegated: false, + negation: 'exclude if any of', + }, + 'exclude if any of': { + label: 'exclude if any of', + value: 'exclude if any of', + target: 'multiple', + pluralOf: 'exclude', + relativeOf: ['include any of', 'exclude if all', 'include all of'], + isNegated: true, + negationOf: 'include all of', + }, +} as const satisfies FilterDetails<'multiOption'> + +/* Details for all the filter operators for date data type */ +export const dateFilterDetails = { + is: { + label: 'is', + value: 'is', + target: 'single', + singularOf: 'is between', + relativeOf: 'is after', + isNegated: false, + negation: 'is before', + }, + 'is not': { + label: 'is not', + value: 'is not', + target: 'single', + singularOf: 'is not between', + relativeOf: [ + 'is', + 'is before', + 'is on or after', + 'is after', + 'is on or before', + ], + isNegated: true, + negationOf: 'is', + }, + 'is before': { + label: 'is before', + value: 'is before', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is on or after', + 'is after', + 'is on or before', + ], + isNegated: false, + negation: 'is on or after', + }, + 'is on or after': { + label: 'is on or after', + value: 'is on or after', + target: 'single', + singularOf: 'is between', + relativeOf: ['is', 'is not', 'is before', 'is after', 'is on or before'], + isNegated: false, + negation: 'is before', + }, + 'is after': { + label: 'is after', + value: 'is after', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is before', + 'is on or after', + 'is on or before', + ], + isNegated: false, + negation: 'is on or before', + }, + 'is on or before': { + label: 'is on or before', + value: 'is on or before', + target: 'single', + singularOf: 'is between', + relativeOf: ['is', 'is not', 'is after', 'is on or after', 'is before'], + isNegated: false, + negation: 'is after', + }, + 'is between': { + label: 'is between', + value: 'is between', + target: 'multiple', + pluralOf: 'is', + relativeOf: 'is not between', + isNegated: false, + negation: 'is not between', + }, + 'is not between': { + label: 'is not between', + value: 'is not between', + target: 'multiple', + pluralOf: 'is not', + relativeOf: 'is between', + isNegated: true, + negationOf: 'is between', + }, +} as const satisfies FilterDetails<'date'> + +/* Details for all the filter operators for text data type */ +export const textFilterDetails = { + contains: { + label: 'contains', + value: 'contains', + target: 'single', + relativeOf: 'does not contain', + isNegated: false, + negation: 'does not contain', + }, + 'does not contain': { + label: 'does not contain', + value: 'does not contain', + target: 'single', + relativeOf: 'contains', + isNegated: true, + negationOf: 'contains', + }, +} as const satisfies FilterDetails<'text'> + +/* Details for all the filter operators for number data type */ +export const numberFilterDetails = { + is: { + label: 'is', + value: 'is', + target: 'single', + relativeOf: [ + 'is not', + 'is greater than', + 'is less than or equal to', + 'is less than', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is not', + }, + 'is not': { + label: 'is not', + value: 'is not', + target: 'single', + relativeOf: [ + 'is', + 'is greater than', + 'is less than or equal to', + 'is less than', + 'is greater than or equal to', + ], + isNegated: true, + negationOf: 'is', + }, + 'is greater than': { + label: '>', + value: 'is greater than', + target: 'single', + relativeOf: [ + 'is', + 'is not', + 'is less than or equal to', + 'is less than', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is less than or equal to', + }, + 'is greater than or equal to': { + label: '>=', + value: 'is greater than or equal to', + target: 'single', + relativeOf: [ + 'is', + 'is not', + 'is greater than', + 'is less than or equal to', + 'is less than', + ], + isNegated: false, + negation: 'is less than or equal to', + }, + 'is less than': { + label: '<', + value: 'is less than', + target: 'single', + relativeOf: [ + 'is', + 'is not', + 'is greater than', + 'is less than or equal to', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is greater than', + }, + 'is less than or equal to': { + label: '<=', + value: 'is less than or equal to', + target: 'single', + relativeOf: [ + 'is', + 'is not', + 'is greater than', + 'is less than', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is greater than or equal to', + }, + 'is between': { + label: 'is between', + value: 'is between', + target: 'multiple', + relativeOf: 'is not between', + isNegated: false, + negation: 'is not between', + }, + 'is not between': { + label: 'is not between', + value: 'is not between', + target: 'multiple', + relativeOf: 'is between', + isNegated: true, + negationOf: 'is between', + }, +} as const satisfies FilterDetails<'number'> + +/* Maps column data types to their respective filter operator details */ +type FilterTypeOperatorDetails = { + [key in ColumnDataType]: FilterDetails +} + +export const filterTypeOperatorDetails: FilterTypeOperatorDetails = { + text: textFilterDetails, + number: numberFilterDetails, + date: dateFilterDetails, + option: optionFilterDetails, + multiOption: multiOptionFilterDetails, +} + +/* + * + * Determines the new operator for a filter based on the current operator, old and new filter values. + * + * This handles cases where the filter values have transitioned from a single value to multiple values (or vice versa), + * and the current operator needs to be transitioned to its plural form (or singular form). + * + * For example, if the current operator is 'is', and the new filter values have a length of 2, the + * new operator would be 'is any of'. + * + */ +export function determineNewOperator( + type: T, + oldVals: Array, + nextVals: Array, + currentOperator: FilterOperators[T], +): FilterOperators[T] { + const a = + Array.isArray(oldVals) && Array.isArray(oldVals[0]) + ? oldVals[0].length + : oldVals.length + const b = + Array.isArray(nextVals) && Array.isArray(nextVals[0]) + ? nextVals[0].length + : nextVals.length + + // If filter size has not transitioned from single to multiple (or vice versa) + // or is unchanged, return the current operator. + if (a === b || (a >= 2 && b >= 2) || (a <= 1 && b <= 1)) + return currentOperator + + const opDetails = filterTypeOperatorDetails[type][currentOperator] + + // Handle transition from single to multiple filter values. + if (a < b && b >= 2) return opDetails.singularOf ?? currentOperator + // Handle transition from multiple to single filter values. + if (a > b && b <= 1) return opDetails.pluralOf ?? currentOperator + return currentOperator +} + +/********************************************************************************************************** + ***** Filter Functions ****** + ********************************************************************************************************** + * These are functions that filter data based on the current filter values, column data type, and operator. + * There exists a separate filter function for each column data type. + * + * Two variants of the filter functions are provided - as an example, we will take the optionFilterFn: + * 1. optionFilterFn: takes in a row, columnId, and filterValue. + * 2. __optionFilterFn: takes in an inputData and filterValue. + * + * __optionFilterFn is a private function that is used by filterFn to perform the actual filtering. + * *********************************************************************************************************/ + +/* + * Returns a filter function for a given column data type. + * This function is used to determine the appropriate filter function to use based on the column data type. + */ +export function filterFn(dataType: ColumnDataType) { + switch (dataType) { + case 'option': + return optionFilterFn + case 'multiOption': + return multiOptionFilterFn + case 'date': + return dateFilterFn + case 'text': + return textFilterFn + case 'number': + return numberFilterFn + default: + throw new Error('Invalid column data type') + } +} + +export function optionFilterFn( + row: Row, + columnId: string, + filterValue: FilterModel<'option', TData>, +) { + const value = row.getValue(columnId) + + if (!value) return false + + const columnMeta = filterValue.columnMeta! + + if (typeof value === 'string') { + return __optionFilterFn(value, filterValue) + } + + if (isColumnOption(value)) { + return __optionFilterFn(value.value, filterValue) + } + + const sanitizedValue = columnMeta.transformOptionFn!(value as never) + return __optionFilterFn(sanitizedValue.value, filterValue) +} + +export function __optionFilterFn( + inputData: string, + filterValue: FilterModel<'option', TData>, +) { + if (!inputData) return false + if (filterValue.values.length === 0) return true + + const value = inputData.toString().toLowerCase() + + const found = !!filterValue.values.find((v) => v.toLowerCase() === value) + + switch (filterValue.operator) { + case 'is': + case 'is any of': + return found + case 'is not': + case 'is none of': + return !found + } +} + +export function isColumnOption(value: unknown): value is ColumnOption { + return ( + typeof value === 'object' && + value !== null && + 'value' in value && + 'label' in value + ) +} + +export function isColumnOptionArray(value: unknown): value is ColumnOption[] { + return Array.isArray(value) && value.every(isColumnOption) +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === 'string') +} + +export function multiOptionFilterFn( + row: Row, + columnId: string, + filterValue: FilterModel<'multiOption', TData>, +) { + const value = row.getValue(columnId) + + if (!value) return false + + const columnMeta = filterValue.columnMeta! + + if (isStringArray(value)) { + return __multiOptionFilterFn(value, filterValue) + } + + if (isColumnOptionArray(value)) { + return __multiOptionFilterFn( + value.map((v) => v.value), + filterValue, + ) + } + + const sanitizedValue = (value as never[]).map((v) => + columnMeta.transformOptionFn!(v), + ) + + return __multiOptionFilterFn( + sanitizedValue.map((v) => v.value), + filterValue, + ) +} + +export function __multiOptionFilterFn( + inputData: string[], + filterValue: FilterModel<'multiOption', TData>, +) { + if (!inputData) return false + + if ( + filterValue.values.length === 0 || + !filterValue.values[0] || + filterValue.values[0].length === 0 + ) + return true + + const values = uniq(inputData) + const filterValues = uniq(filterValue.values[0]) + + switch (filterValue.operator) { + case 'include': + case 'include any of': + return intersection(values, filterValues).length > 0 + case 'exclude': + return intersection(values, filterValues).length === 0 + case 'exclude if any of': + return !(intersection(values, filterValues).length > 0) + case 'include all of': + return intersection(values, filterValues).length === filterValues.length + case 'exclude if all': + return !( + intersection(values, filterValues).length === filterValues.length + ) + } +} + +export function dateFilterFn( + row: Row, + columnId: string, + filterValue: FilterModel<'date', TData>, +) { + const valueStr = row.getValue(columnId) + + return __dateFilterFn(valueStr, filterValue) +} + +export function __dateFilterFn( + inputData: Date, + filterValue: FilterModel<'date', TData>, +) { + if (!filterValue || filterValue.values.length === 0) return true + + if ( + dateFilterDetails[filterValue.operator].target === 'single' && + filterValue.values.length > 1 + ) + throw new Error('Singular operators require at most one filter value') + + if ( + filterValue.operator in ['is between', 'is not between'] && + filterValue.values.length !== 2 + ) + throw new Error('Plural operators require two filter values') + + const filterVals = filterValue.values + const d1 = filterVals[0] + const d2 = filterVals[1] + + const value = inputData + + switch (filterValue.operator) { + case 'is': + return isSameDay(value, d1) + case 'is not': + return !isSameDay(value, d1) + case 'is before': + return isBefore(value, startOfDay(d1)) + case 'is on or after': + return isSameDay(value, d1) || isAfter(value, startOfDay(d1)) + case 'is after': + return isAfter(value, startOfDay(d1)) + case 'is on or before': + return isSameDay(value, d1) || isBefore(value, startOfDay(d1)) + case 'is between': + return isWithinInterval(value, { + start: startOfDay(d1), + end: endOfDay(d2), + }) + case 'is not between': + return !isWithinInterval(value, { + start: startOfDay(filterValue.values[0]), + end: endOfDay(filterValue.values[1]), + }) + } +} + +export function textFilterFn( + row: Row, + columnId: string, + filterValue: FilterModel<'text', TData>, +) { + const value = row.getValue(columnId) ?? '' + + return __textFilterFn(value, filterValue) +} + +export function __textFilterFn( + inputData: string, + filterValue: FilterModel<'text', TData>, +) { + if (!filterValue || filterValue.values.length === 0) return true + + const value = inputData.toLowerCase().trim() + const filterStr = filterValue.values[0].toLowerCase().trim() + + if (filterStr === '') return true + + const found = value.includes(filterStr) + + switch (filterValue.operator) { + case 'contains': + return found + case 'does not contain': + return !found + } +} + +export function numberFilterFn( + row: Row, + columnId: string, + filterValue: FilterModel<'number', TData>, +) { + const value = row.getValue(columnId) + + return __numberFilterFn(value, filterValue) +} + +export function __numberFilterFn( + inputData: number, + filterValue: FilterModel<'number', TData>, +) { + if (!filterValue || !filterValue.values || filterValue.values.length === 0) { + return true + } + + const value = inputData + const filterVal = filterValue.values[0] + + switch (filterValue.operator) { + case 'is': + return value === filterVal + case 'is not': + return value !== filterVal + case 'is greater than': + return value > filterVal + case 'is greater than or equal to': + return value >= filterVal + case 'is less than': + return value < filterVal + case 'is less than or equal to': + return value <= filterVal + case 'is between': { + const lowerBound = filterValue.values[0] + const upperBound = filterValue.values[1] + return value >= lowerBound && value <= upperBound + } + case 'is not between': { + const lowerBound = filterValue.values[0] + const upperBound = filterValue.values[1] + return value < lowerBound || value > upperBound + } + default: + return true + } +} + +export function createNumberRange(values: number[] | undefined) { + let a = 0 + let b = 0 + + if (!values || values.length === 0) return [a, b] + if (values.length === 1) { + a = values[0] + } else { + a = values[0] + b = values[1] + } + + const [min, max] = a < b ? [a, b] : [b, a] + + return [min, max] +} + +/*** Table helpers ***/ + +export function getColumn(table: Table, id: string) { + const column = table.getColumn(id) + + if (!column) { + throw new Error(`Column with id ${id} not found`) + } + + return column +} + +export function getColumnMeta(table: Table, id: string) { + const column = getColumn(table, id) + + if (!column.columnDef.meta) { + throw new Error(`Column meta not found for column ${id}`) + } + + return column.columnDef.meta +} + +/*** Table Filter Helpers ***/ + +export function isFilterableColumn(column: Column) { + // 'auto' filterFn doesn't count! + const hasFilterFn = + column.columnDef.filterFn && column.columnDef.filterFn !== 'auto' + + if ( + column.getCanFilter() && + column.accessorFn && + hasFilterFn && + column.columnDef.meta + ) + return true + + if (!column.accessorFn || !column.columnDef.meta) { + // 1) Column has no accessor function + // We assume this is a display column and thus has no filterable data + // 2) Column has no meta + // We assume this column is not intended to be filtered using this component + return false + } + + if (!column.accessorFn) { + warn(`Column "${column.id}" ignored - no accessor function`) + } + + if (!column.getCanFilter()) { + warn(`Column "${column.id}" ignored - not filterable`) + } + + if (!hasFilterFn) { + warn( + `Column "${column.id}" ignored - no filter function. use the provided filterFn() helper function`, + ) + } + + return false +} + +function warn(...messages: string[]) { + if (process.env.NODE_ENV !== 'production') { + console.warn('[◐] [filters]', ...messages) + } +} diff --git a/app/mock/api/data-utlegg.ts b/app/mock/api/data-utlegg.ts new file mode 100644 index 0000000..1b3dc1c --- /dev/null +++ b/app/mock/api/data-utlegg.ts @@ -0,0 +1,89 @@ +type Utlegg = { + id: string, + region: string, + date: string, + category: string, + sum: number, + receipt: string, + status: string +} + +export function getUtlegg(): Array { + return ([ + { + id: "1", + region: "Trondheim", + date: "12.02.2025", + category: "Pizzabudsjett", + sum: 200, + receipt: "https://vektorprogrammet.no/images/receipts/645e8fa099011.jpeg?v=1683918752", + status: "Refundert" + }, + { + id: "2", + region: "Ås", + date: "28.02.2025", + category: "Pizzabudsjett", + sum: 300, + receipt: "https://vektorprogrammet.no/images/receipts/645e8fa099011.jpeg?v=1683918752", + status: "Refundert" + }, + { + id: "3", + region: "Trondheim", + date: "20.03.2025", + category: "Pizzabudsjett", + sum: 300, + receipt: "", + status: "Under behandling" + }, + { + id: "4", + region: "Trondheim", + date: "20.03.2025", + category: "Pizzabudsjett", + sum: 300, + receipt: "", + status: "Under behandling" + }, + { + id: "5", + region: "Trondheim", + date: "20.03.2025", + category: "Pizzabudsjett", + sum: 300, + receipt: "", + status: "Under behandling" + }, + { + id: "6", + region: "Trondheim", + date: "20.03.2025", + category: "Pizzabudsjett", + sum: 300, + receipt: "", + status: "Under behandling" + }, + { + id: "7", + region: "Trondheim", + date: "20.03.2025", + category: "Pizzabudsjett", + sum: 300, + receipt: "", + status: "Under behandling" + } + ]); +} + +export function getCategories(): Array { + return ( + ["Pizzabudsjett", "Teamsosial"] + ) +} + +export function getRegions(): Array { + return ( + ["Ås", "Bergen", "Trondheim"] + ) +} \ No newline at end of file diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index 25a0a42..f8b14c7 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -171,7 +171,7 @@ const mainLinks = [ }, { title: "Utlegg", - url: "#", + url: "/dashboard/utlegg", }, ], }, @@ -518,7 +518,7 @@ function Breadcrumbs() { to={`/${fullPath}`} className={cn( isEnd ? "text-black" : "text-gray-500", - "hover:text-black", + "hover:text-black" )} prefetch="intent" > diff --git a/app/routes/dashboard.utlegg.tsx b/app/routes/dashboard.utlegg.tsx new file mode 100644 index 0000000..c511683 --- /dev/null +++ b/app/routes/dashboard.utlegg.tsx @@ -0,0 +1,371 @@ +import { ComboBoxResponsive } from "@/components/combobox"; +import { DataTable } from "@/components/data-table-utlegg"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calender"; +import { Dialog } from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { defineMeta, filterFn } from "@/lib/filters"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { DialogContent, DialogTrigger } from "@radix-ui/react-dialog"; +import { createColumnHelper } from "@tanstack/react-table"; +import { format } from "date-fns"; +import { CalendarIcon, CircleDotDashedIcon } from "lucide-react"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; +import { getCategories, getRegions, getUtlegg } from "../mock/api/data-utlegg"; + +export type Utlegg = { + id: string; + region: string; + date: string; + category: string; + sum: number; + receipt: string; + status: string; +}; + +const columnHelper = createColumnHelper(); + +export const columns = [ + columnHelper.accessor("id", { + header: "Id", + }), + columnHelper.accessor("date", { + header: "Utleggsdato", + filterFn: filterFn("date"), + meta: defineMeta((row) => row.date, { + displayName: "Utleggsdato", + type: "date", + icon: CircleDotDashedIcon, + }), + }), + columnHelper.accessor("category", { + header: "Kategori", + filterFn: filterFn("option"), + meta: defineMeta((row) => row.category, { + displayName: "Kategori", + type: "option", + options: getCategories().map((category) => ({ + value: category, + label: category, + })), + icon: CircleDotDashedIcon, + }), + }), + columnHelper.accessor("region", { + header: "Region", + filterFn: filterFn("option"), + meta: defineMeta((row) => row.region, { + displayName: "Region", + type: "option", + options: getRegions().map((region) => ({ + value: region, + label: region, + })), + icon: CircleDotDashedIcon, + }), + }), + columnHelper.accessor("sum", { + header: "Sum", + filterFn: filterFn("number"), + meta: defineMeta((row) => row.sum, { + displayName: "Sum", + type: "number", + icon: CircleDotDashedIcon, + }), + }), + columnHelper.accessor("receipt", { + header: "Kvittering", + cell: ({ row }) => { + const url = row.original.receipt; + return ( + url.length > 0 && ( + + + + + + Kvittering + + + ) + ); + }, + }), + columnHelper.accessor("status", { + header: "Status", + filterFn: filterFn("option"), + meta: defineMeta((row) => row.status, { + displayName: "Status", + type: "option", + icon: CircleDotDashedIcon, + options: [ + { label: "Refundert", value: "Refundert" }, + { label: "Under behandling", value: "Under behandling" }, + ], + }), + }), +]; + +const formSchema = z.object({ + date: z.date(), + category: z.string(), + region: z.string(), + sum: z.coerce.number(), + receipt: z.instanceof(File).optional(), + accountNumber: z + .string() + .min(1, { message: "Kontonummer er påkrevd" }) + .regex(/^(\d{4}[ .]?\d{2}[ .]?\d{5}|\d{11})$/, { + message: "Ugyldig kontonummer-format", + }), +}); + +export default function Utlegg() { + const utlegg = getUtlegg(); + const [openForm, setOpenForm] = useState(false); + const regions = getRegions().map((region) => ({ + value: region, + label: region, + })); + const categories = getCategories().map((category) => ({ + value: category, + label: category, + })); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + category: "", + region: "", + }, + }); + + function onSubmit(_values: z.infer) { + setOpenForm(false); + } + + return ( +
+

Mine utlegg

+
+
+ +
+
+ + ( + + Utleggsbeløp + + Beløpet må stemme nøyaktig med den på kvitteringen hvis + hele beløpet skal dekkes. Hvis du kun får dekket deler + av summen, skriv beløpet som skal dekkes. + + + +
+ + + kr + +
+
+
+ )} + /> + ( + + Utleggsdato + + + + + + + + + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + )} + /> +
+ ( + + Region + + ( + + )} + /> + + + + )} + /> + ( + + Kategori + + ( + + )} + /> + + + + )} + /> +
+ ( + + Utbetalingskonto + + + + + + )} + /> + ( + + + Last opp kvittering + + + { + if (!e.currentTarget.files) return; + if (e.currentTarget.files.length <= 0) return; + const file = e.currentTarget.files[0]; + if ( + file.type !== "image/jpeg" && + file.type !== "image/png" + ) + return; + field.onChange(file); + }} + /> + + + + )} + /> + + + +
+
+
+ + + Hva kan jeg få refundert? + + Du kan typisk få refusjon for bussbilletter til og fra skole, + kaffeposer til stand, kake til arrangementer og lignende. Det er + ellers lurt å høre med en leder om du kan få utlegget ditt + refundert før du legger ut. Om du har spørsmål kan du kontakte + økonomiteamet på okonomi@vektorprogrammet.no. + + + +
+
+
+ +
+
+ ); +} diff --git a/package.json b/package.json index 9144e46..5e3dcaf 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@axe-core/playwright": "^4.10.1", "@hookform/resolvers": "^4.1.3", "@mantine/hooks": "^7.17.2", + "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -41,6 +42,7 @@ "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.3.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", @@ -49,12 +51,15 @@ "@react-router/serve": "^7.4.0", "@tailwindcss/vite": "^4.0.17", "@tanstack/react-table": "^8.21.2", + "@tanstack/table-core": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "isbot": "^5.1.25", "lucide-react": "^0.482.0", "react": "^19.0.0", + "react-day-picker": "8.10.1", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-router": "^7.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72e0660..8333dc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@mantine/hooks': specifier: ^7.17.2 version: 7.17.2(react@19.0.0) + '@radix-ui/react-accordion': + specifier: ^1.2.3 + version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-avatar': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -44,6 +47,9 @@ importers: '@radix-ui/react-separator': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slider': + specifier: ^1.3.2 + version: 1.3.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-slot': specifier: ^1.1.2 version: 1.1.2(@types/react@19.0.12)(react@19.0.0) @@ -68,6 +74,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.2 version: 8.21.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@tanstack/table-core': + specifier: ^8.21.3 + version: 8.21.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -77,6 +86,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + date-fns: + specifier: ^4.1.0 + version: 4.1.0 isbot: specifier: ^5.1.25 version: 5.1.25 @@ -86,6 +98,9 @@ importers: react: specifier: ^19.0.0 version: 19.0.0 + react-day-picker: + specifier: 8.10.1 + version: 8.10.1(date-fns@4.1.0)(react@19.0.0) react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) @@ -568,9 +583,28 @@ packages: '@radix-ui/number@1.1.0': resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + '@radix-ui/primitive@1.1.2': + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + + '@radix-ui/react-accordion@1.2.3': + resolution: {integrity: sha512-RIQ15mrcvqIkDARJeERSuXSry2N8uYnxkdDetpfmalT/+0ntOXLkFOsh9iwlAsCv+qcmhZjbdJogIm6WBa6c4A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.2': resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} peerDependencies: @@ -636,6 +670,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.4': + resolution: {integrity: sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.1': resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} peerDependencies: @@ -645,6 +692,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.1': resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} peerDependencies: @@ -654,6 +710,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.6': resolution: {integrity: sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==} peerDependencies: @@ -676,6 +741,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.5': resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==} peerDependencies: @@ -824,6 +898,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.0': + resolution: {integrity: sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.2': resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==} peerDependencies: @@ -863,6 +950,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.3.2': + resolution: {integrity: sha512-oQnqfgSiYkxZ1MrF6672jw2/zZvpB+PJsrIc3Zm1zof1JHf/kj7WhmROw7JahLfOwYQ5/+Ip0rFORgF1tjSiaQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.2': resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} peerDependencies: @@ -872,6 +972,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.0': + resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-tabs@1.1.3': resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==} peerDependencies: @@ -916,6 +1025,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.0': resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} peerDependencies: @@ -934,6 +1061,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-previous@1.1.0': resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} peerDependencies: @@ -943,6 +1079,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.0': resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -961,6 +1106,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-visually-hidden@1.1.2': resolution: {integrity: sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==} peerDependencies: @@ -1228,6 +1382,10 @@ packages: resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==} engines: {node: '>=12'} + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -1384,6 +1542,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -1893,6 +2054,12 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} + react-day-picker@8.10.1: + resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} + peerDependencies: + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom@19.0.0: resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==} peerDependencies: @@ -2662,8 +2829,29 @@ snapshots: '@radix-ui/number@1.1.0': {} + '@radix-ui/number@1.1.1': {} + '@radix-ui/primitive@1.1.1': {} + '@radix-ui/primitive@1.1.2': {} + + '@radix-ui/react-accordion@1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collapsible': 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-collection': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.12)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -2729,18 +2917,42 @@ snapshots: '@types/react': 19.0.12 '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-collection@1.1.4(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.2.0(@types/react@19.0.12)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-compose-refs@1.1.1(@types/react@19.0.12)(react@19.0.0)': dependencies: react: 19.0.0 optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.12)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + '@radix-ui/react-context@1.1.1(@types/react@19.0.12)(react@19.0.0)': dependencies: react: 19.0.0 optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-context@1.1.2(@types/react@19.0.12)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + '@radix-ui/react-dialog@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -2769,6 +2981,12 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-direction@1.1.1(@types/react@19.0.12)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -2926,6 +3144,15 @@ snapshots: '@types/react': 19.0.12 '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-primitive@2.1.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-slot': 1.2.0(@types/react@19.0.12)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -2981,6 +3208,25 @@ snapshots: '@types/react': 19.0.12 '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-slider@1.3.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.0.12)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-slot@1.1.2(@types/react@19.0.12)(react@19.0.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0) @@ -2988,6 +3234,13 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-slot@1.2.0(@types/react@19.0.12)(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.12)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + '@radix-ui/react-tabs@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -3037,6 +3290,21 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.0.12)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.12)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.0.12)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.12)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@19.0.12)(react@19.0.0)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.0.12)(react@19.0.0) @@ -3050,12 +3318,24 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.0.12)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + '@radix-ui/react-use-previous@1.1.0(@types/react@19.0.12)(react@19.0.0)': dependencies: react: 19.0.0 optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.0.12)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + '@radix-ui/react-use-rect@1.1.0(@types/react@19.0.12)(react@19.0.0)': dependencies: '@radix-ui/rect': 1.1.0 @@ -3070,6 +3350,13 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-use-size@1.1.1(@types/react@19.0.12)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.12)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + '@radix-ui/react-visually-hidden@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -3310,6 +3597,8 @@ snapshots: '@tanstack/table-core@8.21.2': {} + '@tanstack/table-core@8.21.3': {} + '@types/cookie@0.6.0': {} '@types/estree@1.0.6': {} @@ -3478,6 +3767,8 @@ snapshots: csstype@3.1.3: {} + date-fns@4.1.0: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -3941,6 +4232,11 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + react-day-picker@8.10.1(date-fns@4.1.0)(react@19.0.0): + dependencies: + date-fns: 4.1.0 + react: 19.0.0 + react-dom@19.0.0(react@19.0.0): dependencies: react: 19.0.0 From cfe184b8432c3fc13dabffb728ec0802f7ec45d6 Mon Sep 17 00:00:00 2001 From: Julia Dai Date: Thu, 28 Aug 2025 18:40:23 +0200 Subject: [PATCH 02/14] vf-286 Vis kvittering som pop-up --- app/routes/dashboard.utlegg.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/routes/dashboard.utlegg.tsx b/app/routes/dashboard.utlegg.tsx index c511683..80c6505 100644 --- a/app/routes/dashboard.utlegg.tsx +++ b/app/routes/dashboard.utlegg.tsx @@ -8,7 +8,7 @@ import { } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calender"; -import { Dialog } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Form, FormControl, @@ -27,7 +27,7 @@ import { import { defineMeta, filterFn } from "@/lib/filters"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; -import { DialogContent, DialogTrigger } from "@radix-ui/react-dialog"; + import { createColumnHelper } from "@tanstack/react-table"; import { format } from "date-fns"; import { CalendarIcon, CircleDotDashedIcon } from "lucide-react"; @@ -108,11 +108,11 @@ export const columns = [ Vis kvittering - + Kvittering From 920a943f1a1451ee7449d149f104f1e5a483f656 Mon Sep 17 00:00:00 2001 From: Julia Dai Date: Wed, 3 Sep 2025 16:52:02 +0200 Subject: [PATCH 03/14] vf-286 Keep form label name during input error --- app/components/ui/form.tsx | 91 +++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/app/components/ui/form.tsx b/app/components/ui/form.tsx index 78fc2d6..e076b3e 100644 --- a/app/components/ui/form.tsx +++ b/app/components/ui/form.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { Slot } from "@radix-ui/react-slot" +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; import { Controller, FormProvider, @@ -9,27 +9,27 @@ import { type ControllerProps, type FieldPath, type FieldValues, -} from "react-hook-form" +} from "react-hook-form"; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; -const Form = FormProvider +const Form = FormProvider; type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, + TName extends FieldPath = FieldPath > = { - name: TName -} + name: TName; +}; const FormFieldContext = React.createContext( {} as FormFieldContextValue -) +); const FormField = < TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, + TName extends FieldPath = FieldPath >({ ...props }: ControllerProps) => { @@ -37,21 +37,21 @@ const FormField = < - ) -} + ); +}; const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState } = useFormContext() - const formState = useFormState({ name: fieldContext.name }) - const fieldState = getFieldState(fieldContext.name, formState) + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { - throw new Error("useFormField should be used within ") + throw new Error("useFormField should be used within "); } - const { id } = itemContext + const { id } = itemContext; return { id, @@ -60,19 +60,19 @@ const useFormField = () => { formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, - } -} + }; +}; type FormItemContextValue = { - id: string -} + id: string; +}; const FormItemContext = React.createContext( {} as FormItemContextValue -) +); function FormItem({ className, ...props }: React.ComponentProps<"div">) { - const id = React.useId() + const id = React.useId(); return ( @@ -82,28 +82,27 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) { {...props} /> - ) + ); } function FormLabel({ className, ...props }: React.ComponentProps) { - const { error, formItemId } = useFormField() + const { error } = useFormField(); return (