project setup

This commit is contained in:
Sone
2025-11-02 10:08:56 +01:00
commit 568838003e
174 changed files with 27100 additions and 0 deletions
@@ -0,0 +1,70 @@
import { ReactNode } from "react";
import { View } from "react-native";
import { SearchBar } from "@/components/ui/SearchBar";
import { ItemsPerPageMenu } from "@/components/shared/ItemsPerPageMenu";
import { SizeEnum } from "@/constants/sizeEnum";
interface DesktopTableControlsProps {
searchQuery: string;
menuVisible: boolean;
menuAnchor: { x: number; y: number };
searchPlaceholder?: string;
onSearch: (query: string) => void;
onMenuDismiss: () => void;
onItemsPerPageChange: (value: number) => void;
itemsPerPage?: number;
renderFilterComponent?: () => ReactNode;
renderFilterChips?: () => ReactNode;
renderActionButtons?: () => ReactNode;
itemsPerPageMenuOptions?: number[];
}
export const DesktopTableControls = ({
searchQuery,
menuVisible,
menuAnchor,
searchPlaceholder = "Search",
onSearch,
onMenuDismiss,
onItemsPerPageChange,
itemsPerPage = 25,
renderFilterComponent,
renderFilterChips,
renderActionButtons,
itemsPerPageMenuOptions,
}: DesktopTableControlsProps) => {
return (
<>
<View className="flex-row items-center justify-between mb-2" style={{ height: 40 }}>
<View className="flex-row items-center flex-1">
<View className="flex-1 max-w-[250px]">
<SearchBar
placeholderText={searchPlaceholder}
searchQuery={searchQuery}
onChange={onSearch}
size={SizeEnum.Small}
/>
</View>
{renderFilterComponent && (
<View className="ml-2">{renderFilterComponent()}</View>
)}
</View>
<View className="flex-row items-center ml-4">
{renderActionButtons && <View>{renderActionButtons()}</View>}
<ItemsPerPageMenu
menuVisible={menuVisible}
menuAnchor={menuAnchor}
onDismiss={onMenuDismiss}
onItemsPerPageChange={onItemsPerPageChange}
totalItems={itemsPerPage}
itemsPerPageMenuOptions={itemsPerPageMenuOptions || [25, 50, 100]}
/>
</View>
</View>
{renderFilterChips && <View className="">{renderFilterChips()}</View>}
</>
);
};
+110
View File
@@ -0,0 +1,110 @@
import { useMemo, useState } from "react";
import { View, TouchableOpacity } from "react-native";
import { Menu, Text, IconButton, Divider } from "react-native-paper";
import { MultiSelectGlyph } from "./filters/MultiSelectGlyph";
export type FilterOption = {
value: string;
label: string;
icon?: React.ReactNode;
};
interface FilterMultiSelectProps {
label: string;
options: FilterOption[];
selected: string[];
onChange: (values: string[]) => void;
iconName?: string;
className?: string;
menuWidth?: number;
showClearAll?: boolean;
}
export const FilterMultiSelect = ({
label,
options,
selected,
onChange,
iconName = undefined,
className,
menuWidth,
showClearAll = true,
}: FilterMultiSelectProps) => {
const [visible, setVisible] = useState(false);
const selectedSet = useMemo(() => new Set(selected), [selected]);
const toggle = (value: string) => {
const next = new Set(selectedSet);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
onChange(Array.from(next));
};
const anchor = (
<TouchableOpacity
onPress={() => setVisible(true)}
className={`flex-row items-center px-2 h-8 rounded ${className || ""}`}
activeOpacity={0.7}
>
<Text style={{ fontSize: 12, color: "#000" }}>{label}</Text>
{iconName ? (
<IconButton
icon={iconName}
size={18}
onPress={() => setVisible(true)}
/>
) : (
<MultiSelectGlyph />
)}
</TouchableOpacity>
);
return (
<Menu
visible={visible}
onDismiss={() => setVisible(false)}
anchor={anchor}
contentStyle={menuWidth ? { width: menuWidth } : undefined}
>
{options.map((opt) => {
const isSelected = selectedSet.has(opt.value);
return (
<Menu.Item
key={opt.value}
onPress={() => toggle(opt.value)}
leadingIcon={
isSelected ? "checkbox-marked" : "checkbox-blank-outline"
}
title={
<View className="flex-row items-center">
{opt.icon ? <View className="mr-2">{opt.icon}</View> : null}
<Text>{opt.label}</Text>
</View>
}
/>
);
})}
{showClearAll && (
<>
<Divider />
<Menu.Item
onPress={() => onChange([])}
leadingIcon="close-circle-outline"
disabled={selectedSet.size === 0}
title="Clear all"
/>
</>
)}
<Menu.Item
onPress={() => setVisible(false)}
leadingIcon="check"
title="Done"
/>
</Menu>
);
};
+33
View File
@@ -0,0 +1,33 @@
import { Menu } from "react-native-paper";
interface ItemsPerPageMenuProps {
menuVisible: boolean;
menuAnchor: { x: number; y: number };
onDismiss: () => void;
onItemsPerPageChange: (value: number) => void;
totalItems: number;
itemsPerPageMenuOptions: number[];
}
export const ItemsPerPageMenu = ({
menuVisible,
menuAnchor,
onDismiss,
onItemsPerPageChange,
itemsPerPageMenuOptions,
}: ItemsPerPageMenuProps) => {
return (
<Menu visible={menuVisible} onDismiss={onDismiss} anchor={menuAnchor}>
{itemsPerPageMenuOptions.map((option) => (
<Menu.Item
key={option}
onPress={() => {
onItemsPerPageChange(option);
onDismiss();
}}
title={`${option} per page`}
/>
))}
</Menu>
);
};
+73
View File
@@ -0,0 +1,73 @@
import { View } from "react-native";
import { SearchBar } from "@/components/ui/SearchBar";
import { ItemsPerPageMenu } from "@/components/shared/ItemsPerPageMenu";
interface MobileTableControlsProps {
searchQuery: string;
menuVisible: boolean;
menuAnchor: { x: number; y: number };
searchPlaceholder?: string;
onSearch: (query: string) => void;
onMenuDismiss: () => void;
onItemsPerPageChange: (value: number) => void;
itemsPerPage?: number;
renderFilterComponent?: () => React.ReactNode;
renderFilterChips?: () => React.ReactNode;
renderActionButtons?: () => React.ReactNode;
itemsPerPageMenuOptions?: number[];
}
export const MobileTableControls = ({
searchQuery,
menuVisible,
menuAnchor,
searchPlaceholder = "Search",
onSearch,
onMenuDismiss,
onItemsPerPageChange,
itemsPerPage = 25,
renderFilterComponent,
renderFilterChips,
renderActionButtons,
itemsPerPageMenuOptions,
}: MobileTableControlsProps) => {
return (
<>
<View className="flex-1">
<SearchBar
placeholderText={searchPlaceholder}
searchQuery={searchQuery}
onChange={onSearch}
/>
</View>
{renderFilterComponent && (
<View className="flex-row flex-wrap items-center mb-2 mt-2">
<View className="flex-row flex-wrap items-center flex-1">
{renderFilterComponent()}
</View>
</View>
)}
{/* Filter Chips Row */}
{renderFilterChips && <View className="mb-2">{renderFilterChips()}</View>}
{/* Action Buttons and Items Per Page Row */}
<View className="flex-row items-center justify-between">
<View className="flex-row items-center flex-1">
{renderActionButtons && renderActionButtons()}
</View>
<View className="ml-2">
<ItemsPerPageMenu
menuVisible={menuVisible}
menuAnchor={menuAnchor}
onDismiss={onMenuDismiss}
onItemsPerPageChange={onItemsPerPageChange}
totalItems={itemsPerPage}
itemsPerPageMenuOptions={itemsPerPageMenuOptions || [25, 50, 100]}
/>
</View>
</View>
</>
);
};
+56
View File
@@ -0,0 +1,56 @@
import { Ionicons } from "@expo/vector-icons";
import { View } from "react-native";
import { DataTable, Text } from "react-native-paper";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
interface SortableHeaderProps {
title: string;
column: string;
sortColumn: string | null;
sortDirection: TableSortDirection;
onSort: (column: string) => void;
centerAlign?: boolean;
style?: any;
}
export const SortableHeader = ({
title,
column,
sortColumn,
sortDirection,
onSort,
centerAlign = false,
style,
}: SortableHeaderProps) => {
return (
<DataTable.Title
style={{
justifyContent: centerAlign ? "center" : "flex-start",
...style,
}}
onPress={() => onSort(column)}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: centerAlign ? "center" : "flex-start",
}}
>
<Text style={{ marginRight: 4, fontSize: 14 }}>
{sortColumn === column ? (
sortDirection === TableSortDirection.ASCENDING ? (
<Ionicons name="arrow-up" size={20} color="black" />
) : (
<Ionicons name="arrow-down" size={20} color="black" />
)
) : (
<Ionicons name="swap-vertical-outline" size={20} color="black" />
)}
</Text>
<Text>{title}</Text>
</View>
</DataTable.Title>
);
};
+72
View File
@@ -0,0 +1,72 @@
import { View } from "react-native";
import { MobileTableControls } from "./MobileTableControls";
import { DesktopTableControls } from "./DesktopTableControls";
interface TableControlsProps {
searchQuery: string;
menuVisible: boolean;
menuAnchor: { x: number; y: number };
searchPlaceholder?: string;
onSearch: (query: string) => void;
onMenuDismiss: () => void;
onItemsPerPageChange: (value: number) => void;
itemsPerPage?: number;
renderFilterComponent?: () => React.ReactNode;
renderFilterChips?: () => React.ReactNode;
renderActionButtons?: () => React.ReactNode;
itemsPerPageMenuOptions?: number[];
}
export const TableControls = ({
searchQuery,
menuVisible,
menuAnchor,
searchPlaceholder = "Search",
onSearch,
onMenuDismiss,
onItemsPerPageChange,
itemsPerPage = 25,
renderFilterComponent,
renderFilterChips,
renderActionButtons,
itemsPerPageMenuOptions,
}: TableControlsProps) => {
return (
<>
<View className="hidden md:flex flex-col mb-4">
<DesktopTableControls
searchQuery={searchQuery}
menuVisible={menuVisible}
menuAnchor={menuAnchor}
onSearch={onSearch}
onMenuDismiss={onMenuDismiss}
onItemsPerPageChange={onItemsPerPageChange}
itemsPerPage={itemsPerPage}
renderFilterComponent={renderFilterComponent}
renderFilterChips={renderFilterChips}
renderActionButtons={renderActionButtons}
itemsPerPageMenuOptions={itemsPerPageMenuOptions}
searchPlaceholder={searchPlaceholder}
/>
</View>
<View className="flex md:hidden flex-col mb-4">
<MobileTableControls
searchQuery={searchQuery}
menuVisible={menuVisible}
menuAnchor={menuAnchor}
onSearch={onSearch}
onMenuDismiss={onMenuDismiss}
onItemsPerPageChange={onItemsPerPageChange}
itemsPerPage={itemsPerPage}
renderFilterComponent={renderFilterComponent}
renderFilterChips={renderFilterChips}
renderActionButtons={renderActionButtons}
itemsPerPageMenuOptions={itemsPerPageMenuOptions}
searchPlaceholder={searchPlaceholder}
/>
</View>
</>
);
};
+132
View File
@@ -0,0 +1,132 @@
import { View, Text, GestureResponderEvent } from "react-native";
import { IconButton } from "react-native-paper";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
interface TablePaginationProps {
page: number;
itemsPerPage: number;
totalItems: number;
from: number;
to: number;
onPageChange: (page: number) => void;
onItemsPerPageButtonPress: (event: GestureResponderEvent) => void;
}
export const TablePagination = ({
page,
itemsPerPage,
totalItems,
from,
to,
onPageChange,
onItemsPerPageButtonPress,
}: TablePaginationProps) => {
const isMobile = useAppSelector(selectIsMobile);
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1;
const desktopNavigationButtons = (
<>
<Text className="text-sm mr-2">{`${from}-${to} of ${totalItems}`}</Text>
<IconButton
icon="chevron-double-left"
size={20}
disabled={page === 0}
onPress={() => onPageChange(0)}
/>
<IconButton
icon="chevron-left"
size={20}
disabled={page === 0}
onPress={() => onPageChange(Math.max(0, page - 1))}
/>
<IconButton
icon="chevron-right"
size={20}
disabled={page >= maxPage}
onPress={() => onPageChange(Math.min(maxPage, page + 1))}
/>
<IconButton
icon="chevron-double-right"
size={20}
disabled={page >= maxPage}
onPress={() => onPageChange(maxPage)}
/>
</>
);
const mobileNavigationButtons = (
<>
<IconButton
icon="chevron-double-left"
size={16}
style={{ margin: 0 }}
disabled={page === 0}
onPress={() => onPageChange(0)}
/>
<IconButton
icon="chevron-left"
size={16}
style={{ margin: 0 }}
disabled={page === 0}
onPress={() => onPageChange(Math.max(0, page - 1))}
/>
<IconButton
icon="chevron-right"
size={16}
style={{ margin: 0 }}
disabled={page >= maxPage}
onPress={() => onPageChange(Math.min(maxPage, page + 1))}
/>
<IconButton
icon="chevron-double-right"
size={16}
style={{ margin: 0 }}
disabled={page >= maxPage}
onPress={() => onPageChange(maxPage)}
/>
</>
);
const itemsPerPageControls = (
<>
<IconButton icon="menu" size={20} onPress={onItemsPerPageButtonPress} />
<Text className="text-sm">{`${itemsPerPage} per page`}</Text>
</>
);
return (
<View
className="mt-4 flex items-center"
style={{
marginLeft: isMobile ? 0 : "auto",
width: isMobile ? "100%" : "auto",
}}
>
{isMobile ? (
<View className="bg-white py-1 px-2 rounded-md w-full">
<View className="flex-row items-center justify-between">
<View className="flex-row items-center">{mobileNavigationButtons}</View>
<Text className="text-xs text-gray-600">{`${from}-${to} / ${totalItems}`}</Text>
<View className="flex-row items-center">
<IconButton
icon="menu"
size={16}
style={{ margin: 0 }}
onPress={onItemsPerPageButtonPress}
/>
<Text className="text-xs text-gray-600">{`${itemsPerPage}/page`}</Text>
</View>
</View>
</View>
) : (
<View className="flex-row items-center bg-white py-0.5 px-3 rounded-md">
{desktopNavigationButtons}
<View className="h-4 w-px bg-gray-300 mx-2" />
{itemsPerPageControls}
</View>
)}
</View>
);
};
@@ -0,0 +1,21 @@
import { TouchableOpacity } from "react-native";
import { IconButton, Text } from "react-native-paper";
export const CombinedFilterAnchor = ({
setVisible,
}: {
setVisible: (visible: boolean) => void;
}) => (
<TouchableOpacity
onPress={() => setVisible(true)}
className="flex-row items-center px-2 h-8 rounded bg-white"
activeOpacity={0.7}
>
<Text style={{ fontSize: 12, color: "black" }}>Filter</Text>
<IconButton
icon="tune-vertical"
size={18}
onPress={() => setVisible(true)}
/>
</TouchableOpacity>
);
@@ -0,0 +1,148 @@
import { useMemo, useState, ReactNode } from "react";
import { View, useWindowDimensions } from "react-native";
import { Divider, Menu, Text } from "react-native-paper";
import { CombinedFilterAnchor } from "@/components/shared/filters/CombinedFilterAnchor";
import { CombinedFilterResetButton } from "@/components/shared/filters/CombinedFilterResetButton";
type CombinedFilterOption = {
value: string;
label: string;
icon?: ReactNode;
};
export type CombinedFilterSection = {
key: string;
title: string;
options: CombinedFilterOption[];
selected: string[];
onChange: (values: string[]) => void;
};
interface CombinedFilterMenuProps {
sections: CombinedFilterSection[];
onResetAll?: () => void;
showResetButton?: boolean;
}
export const CombinedFilterMenu = ({
sections,
onResetAll,
showResetButton = false,
}: CombinedFilterMenuProps) => {
const [visible, setVisible] = useState(false);
const { width } = useWindowDimensions();
const totalSelected = useMemo(
() => sections.reduce((sum, s) => sum + (s.selected?.length || 0), 0),
[sections]
);
// Determine if mobile or desktop
const isMobile = width < 768;
const toggle = (sectionKey: string, value: string) => {
const section = sections.find((s) => s.key === sectionKey);
if (!section) return;
const set = new Set(section.selected);
if (set.has(value)) {
set.delete(value);
} else {
set.add(value);
}
section.onChange(Array.from(set));
};
const clearAll = () => {
sections.forEach((s) => s.onChange([]));
if (onResetAll) onResetAll();
};
const columnSections = useMemo(() => {
if (isMobile) {
return [sections];
}
return sections.map((section) => [section]);
}, [sections, isMobile]);
const renderSection = (
section: CombinedFilterSection
) => (
<View key={section.key} className="mb-2">
<Menu.Item
title={<Text style={{ fontWeight: "bold" }}>{section.title}</Text>}
disabled
/>
{section.options.map((opt) => {
const isSelected = section.selected.includes(opt.value);
return (
<Menu.Item
key={`${section.key}-${opt.value}`}
onPress={() => toggle(section.key, opt.value)}
leadingIcon={
isSelected ? "checkbox-marked" : "checkbox-blank-outline"
}
title={
<View className="flex-row items-center">
{opt.icon ? <View className="mr-2">{opt.icon}</View> : null}
<Text>{opt.label}</Text>
</View>
}
/>
);
})}
</View>
);
return (
<View className="flex-row items-center">
<Menu
visible={visible}
onDismiss={() => setVisible(false)}
anchor={<CombinedFilterAnchor setVisible={setVisible} />}
contentStyle={{
minWidth: isMobile
? width - 32
: Math.min(sections.length * 250, 800),
maxWidth: isMobile ? width - 32 : undefined,
}}
>
<View
className="flex-row"
style={{ flexWrap: isMobile ? "nowrap" : "wrap" }}
>
{columnSections.map((columnSects, colIndex) => (
<View
key={`column-${colIndex}`}
style={{
flex: 1,
minWidth: isMobile ? undefined : 200,
paddingRight: colIndex < columnSections.length - 1 ? 8 : 0,
}}
>
{columnSects.map((section) => renderSection(section))}
</View>
))}
</View>
<Divider />
<Menu.Item
onPress={clearAll}
leadingIcon="close-circle-outline"
disabled={totalSelected <= 1}
title="Clear all"
/>
<Menu.Item
onPress={() => setVisible(false)}
leadingIcon="check"
title="Done"
/>
</Menu>
<CombinedFilterResetButton
showResetButton={showResetButton}
totalSelected={totalSelected}
clearAll={clearAll}
/>
</View>
);
};
@@ -0,0 +1,25 @@
import { TouchableOpacity } from "react-native";
import { IconButton, Text } from "react-native-paper";
interface CombinedFilterResetButtonProps {
showResetButton: boolean;
totalSelected: number;
clearAll: () => void;
}
export const CombinedFilterResetButton = ({
showResetButton,
totalSelected,
clearAll,
}: CombinedFilterResetButtonProps) =>
showResetButton &&
totalSelected > 1 && (
<TouchableOpacity
onPress={clearAll}
className="flex-row items-center px-2 h-8 rounded bg-white ml-2"
activeOpacity={0.7}
>
<Text style={{ fontSize: 12, color: "black" }}>Reset all</Text>
<IconButton icon="close-circle-outline" size={18} onPress={clearAll} />
</TouchableOpacity>
);
@@ -0,0 +1,9 @@
import { View } from "react-native";
export const MultiSelectGlyph = () => (
<View className="ml-1 flex-row items-center">
<View className="w-1 h-4 bg-gray-500 rounded" />
<View className="ml-1 w-2 h-2 bg-gray-500 rounded-full" />
<View className="ml-1 w-2 h-2 bg-gray-500 rounded-full" />
</View>
);
@@ -0,0 +1,26 @@
import { View } from "react-native";
import { Chip } from "react-native-paper";
export type SelectedChip = {
key: string;
label: string;
onRemove: () => void;
};
interface SelectedFilterChipsProps {
chips: SelectedChip[];
}
export const SelectedFilterChips = ({ chips }: SelectedFilterChipsProps) => {
if (chips.length === 0) return null;
return (
<View className="flex-row flex-wrap items-center" style={{ width: '100%' }}>
{chips.map((chip) => (
<View key={chip.key} className="mr-1 mb-1">
<Chip onClose={chip.onRemove}>{chip.label}</Chip>
</View>
))}
</View>
);
};