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
+29
View File
@@ -0,0 +1,29 @@
import { ReactNode } from "react";
import { Portal, Snackbar, SnackbarProps } from "react-native-paper";
interface AppSnackbarProps extends SnackbarProps {
children: ReactNode;
}
export const AppSnackbar = ({
visible,
onDismiss,
action,
wrapperStyle,
duration,
children,
}: AppSnackbarProps) => {
return (
<Portal>
<Snackbar
visible={visible}
onDismiss={onDismiss}
action={action}
duration={duration}
wrapperStyle={[{ zIndex: 1001 }, wrapperStyle]}
>
{children}
</Snackbar>
</Portal>
);
};
+46
View File
@@ -0,0 +1,46 @@
import { ReactNode } from "react";
import { View, Text, GestureResponderEvent } from "react-native";
import { Button as PaperButton } from "react-native-paper";
interface ButtonProps {
buttonColor?: string;
buttonMode?: "outlined" | "contained";
buttonTextColor?: string;
onPress: (event: GestureResponderEvent) => void;
disabled: boolean;
children: ReactNode;
className?: string;
icon?: string;
}
export const Button = ({
buttonColor = "#6750a4",
buttonMode = "outlined",
buttonTextColor = "white",
onPress,
disabled,
children,
className,
icon,
}: ButtonProps) => {
return (
<View className={className}>
<PaperButton
mode={buttonMode}
background={{ color: buttonColor }}
buttonColor={buttonColor}
onPress={onPress}
disabled={disabled}
textColor={buttonTextColor}
icon={icon}
style={{ borderRadius: 4, borderColor: "transparent" }}
>
{typeof children === "string" || typeof children === "number" ? (
<Text>{children}</Text>
) : (
children
)}
</PaperButton>
</View>
);
};
+48
View File
@@ -0,0 +1,48 @@
import { TouchableOpacity, View } from "react-native";
import { Text } from "react-native-paper";
interface CustomCheckboxProps {
checked: boolean;
onPress: () => void;
size?: number;
}
export const CustomCheckbox = ({
checked,
onPress,
size = 20,
}: CustomCheckboxProps) => {
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={1}
style={{
width: size,
height: size,
justifyContent: "center",
alignItems: "center",
paddingRight: 8,
}}
>
<View
style={{
width: size,
height: size,
borderWidth: checked ? 0 : 1,
borderRadius: 2,
justifyContent: "center",
alignItems: "center",
backgroundColor: checked ? "#6750a4" : "transparent",
}}
>
{checked && (
<Text
style={{ color: "white", fontSize: size * 0.6, lineHeight: size }}
>
</Text>
)}
</View>
</TouchableOpacity>
);
};
+57
View File
@@ -0,0 +1,57 @@
import { View, StyleSheet } from "react-native";
import { Picker, PickerProps } from "@react-native-picker/picker";
import { Ionicons } from "@expo/vector-icons";
export const PickerItem = Picker.Item;
interface CustomPickerProps extends PickerProps {
children?: React.ReactNode;
}
export const CustomPicker = ({
selectedValue,
onValueChange,
children,
}: CustomPickerProps) => {
return (
<View style={styles.container}>
<Picker
selectedValue={selectedValue}
onValueChange={(itemValue, itemIndex) => {
onValueChange?.(itemValue, itemIndex);
}}
style={[styles.pickerStyle, { appearance: "none" } as any]}
>
{children}
</Picker>
<Ionicons
name="chevron-down"
size={20}
color="#333"
style={styles.customIcon}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
pickerStyle: {
height: 50,
width: "100%",
paddingHorizontal: 10,
paddingVertical: 5,
cursor: "pointer",
},
customIcon: {
position: "absolute",
right: 12,
top: "50%",
transform: [{ translateY: -10 }],
pointerEvents: "none",
},
});
+37
View File
@@ -0,0 +1,37 @@
import { ReactNode } from "react";
import { View, StyleProp, ViewStyle } from "react-native";
import { Dialog, Portal } from "react-native-paper";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
interface DialogContainerProps {
visible: boolean;
onDismiss: () => void;
dialogStyle?: StyleProp<ViewStyle>;
children: ReactNode;
}
export const DialogContainer = ({
visible,
onDismiss,
dialogStyle,
children,
}: DialogContainerProps) => {
const isMobile = useAppSelector(selectIsMobile);
const dialogWidth = isMobile ? "95%" : "60%";
return (
<Portal>
<View className="flex-1" pointerEvents="box-none">
<Dialog
visible={visible}
onDismiss={onDismiss}
style={[{ width: dialogWidth, alignSelf: "center" }, dialogStyle]}
>
{children}
</Dialog>
</View>
</Portal>
);
};
+22
View File
@@ -0,0 +1,22 @@
import { ActivityIndicator } from "react-native";
import { SizeEnum } from "@/constants/sizeEnum";
interface LoadingSpinnerProps {
size?: SizeEnum.Large | SizeEnum.Small | number;
color?: string;
}
export const LoadingSpinner = ({ size, color }: LoadingSpinnerProps) => {
let resolvedSize: number | "small" | "large" = "large";
if (typeof size === "number") {
resolvedSize = size;
} else if (size === SizeEnum.Small) {
resolvedSize = "small";
} else if (size === SizeEnum.Large) {
resolvedSize = "large";
}
return <ActivityIndicator size={resolvedSize} color={color || "#6750a4"} />;
};
+30
View File
@@ -0,0 +1,30 @@
import { View, Text } from "react-native";
import { IconButton } from "react-native-paper";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { Button } from "@/components/ui/Button";
interface NoDataProps {
onRetry: () => void;
message: string;
isRetrying: boolean;
}
export const NoData = ({ onRetry, message, isRetrying }: NoDataProps) => {
return (
<View className="flex-1 items-center justify-center py-6">
<IconButton iconColor="#F83434" icon="alert-circle-outline" size={48} />
<Text className="text-center text-base font-medium mb-2">{message}</Text>
<Text className="text-center text-sm mb-4">
Please try again or contact support if the problem persists.
</Text>
{isRetrying ? (
<LoadingSpinner />
) : (
<Button onPress={onRetry} disabled={isRetrying}>
Retry
</Button>
)}
</View>
);
};
+97
View File
@@ -0,0 +1,97 @@
import { useMemo } from "react";
import { View } from "react-native";
import { Text } from "react-native-paper";
import { PriorityStatus } from "@/constants/priorityStatusEnum";
import { SizeEnum } from "@/constants/sizeEnum";
type PriorityData = {
bars: number;
color: string;
};
const PRIORITY_CONFIG: Record<string, PriorityData> = {
[PriorityStatus.HIGH_PRIORITY]: { bars: 3, color: "bg-highRisk" },
[PriorityStatus.MODERATE_PRIORITY]: { bars: 2, color: "bg-mediumRisk" },
[PriorityStatus.LOW_PRIORITY]: { bars: 1, color: "bg-lowRisk" },
};
const SIZE_CONFIG = {
[SizeEnum.Small]: {
width: "w-1",
heights: ["h-2", "h-3", "h-4"],
},
[SizeEnum.Moderate]: {
width: "w-1.5",
heights: ["h-3", "h-4", "h-5"],
},
[SizeEnum.Regular]: {
width: "w-1.5",
heights: ["h-3", "h-4", "h-5"],
},
[SizeEnum.Large]: {
width: "w-2",
heights: ["h-4", "h-6", "h-8"],
},
};
interface PriorityIndicatorProps {
priority: PriorityStatus | string;
showLabel?: boolean;
size?: SizeEnum;
}
export const PriorityIndicator = ({
priority,
showLabel = false,
size = SizeEnum.Moderate,
}: PriorityIndicatorProps) => {
const { filledBars, barColor, barSizes } = useMemo(() => {
const priorityData = PRIORITY_CONFIG[priority] || {
bars: 0,
color: "bg-mediumGray",
};
const sizeData = SIZE_CONFIG[size] || SIZE_CONFIG[SizeEnum.Moderate];
return {
filledBars: priorityData.bars,
barColor: priorityData.color,
barSizes: {
width: sizeData.width,
heights: sizeData.heights,
},
};
}, [priority, size]);
return (
<View className="flex-row items-center">
<View className="flex-row items-end gap-0.5 mr-1">
<View
className={`${barSizes.width} ${barSizes.heights[0]} rounded-sm ${
filledBars >= 1 ? barColor : "bg-mediumGray"
}`}
/>
<View
className={`${barSizes.width} ${barSizes.heights[1]} rounded-sm ${
filledBars >= 2 ? barColor : "bg-mediumGray"
}`}
/>
<View
className={`${barSizes.width} ${barSizes.heights[2]} rounded-sm ${
filledBars >= 3 ? barColor : "bg-mediumGray"
}`}
/>
</View>
{showLabel && (
<Text className="text-xs ml-1">
{typeof priority === "string"
? priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase()
: priority}
</Text>
)}
</View>
);
};
+101
View File
@@ -0,0 +1,101 @@
import { useMemo } from "react";
import { View } from "react-native";
import { Text } from "react-native-paper";
import { RiskEnum } from "@/constants/riskEnum";
import { SizeEnum } from "@/constants/sizeEnum";
type RiskData = {
bars: number;
color: string;
};
const RISK_CONFIG: Record<string, RiskData> = {
[RiskEnum.HIGH_RISK]: { bars: 3, color: "bg-highRisk" },
[RiskEnum.MODERATE_RISK]: { bars: 2, color: "bg-mediumRisk" },
[RiskEnum.LOW_RISK]: { bars: 1, color: "bg-lowRisk" },
};
const SIZE_CONFIG = {
[SizeEnum.Small]: {
width: "w-1",
heights: ["h-2", "h-3", "h-4"],
},
[SizeEnum.Moderate]: {
width: "w-1.5",
heights: ["h-3", "h-4", "h-5"],
},
[SizeEnum.Regular]: {
width: "w-1.5",
heights: ["h-3", "h-4", "h-5"],
},
[SizeEnum.Large]: {
width: "w-2",
heights: ["h-4", "h-6", "h-8"],
},
};
interface RiskIndicatorProps {
risk: RiskEnum | string;
showLabel?: boolean;
size?: SizeEnum;
}
/**
* RiskIndicator component displays a visual representation of risk level
* using color bars similar to a pie chart
*/
export const RiskIndicator = ({
risk,
showLabel = false,
size = SizeEnum.Moderate,
}: RiskIndicatorProps) => {
const { filledBars, barColor, barSizes } = useMemo(() => {
const riskData = RISK_CONFIG[risk] || {
bars: 0,
color: "bg-mediumGray",
};
const sizeData = SIZE_CONFIG[size] || SIZE_CONFIG[SizeEnum.Moderate];
return {
filledBars: riskData.bars,
barColor: riskData.color,
barSizes: {
width: sizeData.width,
heights: sizeData.heights,
},
};
}, [risk, size]);
return (
<View className="flex-row items-center">
<View className="flex-row items-end gap-0.5 mr-1">
<View
className={`${barSizes.width} ${barSizes.heights[0]} rounded-sm ${
filledBars >= 1 ? barColor : "bg-mediumGray"
}`}
/>
<View
className={`${barSizes.width} ${barSizes.heights[1]} rounded-sm ${
filledBars >= 2 ? barColor : "bg-mediumGray"
}`}
/>
<View
className={`${barSizes.width} ${barSizes.heights[2]} rounded-sm ${
filledBars >= 3 ? barColor : "bg-mediumGray"
}`}
/>
</View>
{showLabel && (
<Text className="text-xs ml-1">
{typeof risk === "string"
? risk.charAt(0).toUpperCase() + risk.slice(1).toLowerCase()
: risk}
</Text>
)}
</View>
);
};
+93
View File
@@ -0,0 +1,93 @@
import { useCallback, useEffect, useState } from "react";
import { Text, View } from "react-native";
import PieChart from "react-native-pie-chart";
import { getRiskLevel } from "@/utils/getRiskLevel";
interface RiskScoreIndicatorProps {
value: number;
maxValue?: number;
size?: number;
subtitle?: string;
}
/**
* RiskScoreIndicator component displays a visual representation of risk level
* using PieChart from react-native-pie-chart
*/
export const RiskScoreIndicator = ({
value,
maxValue = 100,
size = 100,
subtitle,
}: RiskScoreIndicatorProps) => {
const { color } = getRiskLevel(value);
const [animatedValue, setAnimatedValue] = useState(0.1);
const finalValue = value > 0 ? value : 0.1;
const animationSetup = useCallback(() => {
const startTime = Date.now();
const duration = 1000;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = (t: number) => 1 - Math.pow(1 - t, 2);
const currentValue = 0.1 + easeOut(progress) * (finalValue - 0.1);
setAnimatedValue(currentValue);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
const animationFrame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrame);
}, [finalValue]);
useEffect(() => {
animationSetup();
}, [animationSetup]);
return (
<View className="items-center">
<View
className="relative justify-center items-center bg-white p-3 rounded-full"
style={{
width: size,
height: size,
}}
>
<View className="absolute rotate-[-145deg]">
<PieChart
widthAndHeight={size - 12}
series={[
{
value: animatedValue,
color,
},
{
value:
maxValue - animatedValue > 0 ? maxValue - animatedValue : 0.1,
color: "white",
},
{ value: maxValue / 4, color: "transparent" },
]}
cover={{ radius: 0.8, color: "white" }}
/>
</View>
<View className="absolute bottom-10 items-center">
<Text className="text-black text-3xl font-normal">
{Math.round(value)}
</Text>
</View>
</View>
{subtitle && <Text className="text-black text-sm mt-2">{subtitle}</Text>}
</View>
);
};
+45
View File
@@ -0,0 +1,45 @@
import { View } from "react-native";
import { TextInput } from "react-native-paper";
import { SizeEnum } from "@/constants/sizeEnum";
interface SearchBarProps {
searchQuery: string;
onChange: (query: string) => void;
placeholderText?: string;
size?: SizeEnum;
}
export const SearchBar = ({
searchQuery,
onChange,
placeholderText = "Search",
size = SizeEnum.Regular,
}: SearchBarProps) => {
const isSmall = size === SizeEnum.Small;
return (
<View>
<TextInput
placeholder={placeholderText}
value={searchQuery}
onChangeText={onChange}
mode="outlined"
right={
searchQuery ? (
<TextInput.Icon icon="close" onPress={() => onChange("")} />
) : (
<TextInput.Icon icon="magnify" size={isSmall ? 20 : 24} />
)
}
style={isSmall ? { height: 40, fontSize: 14 } : undefined}
contentStyle={isSmall ? { paddingTop: 0, paddingBottom: 0 } : undefined}
outlineStyle={[
isSmall ? { borderRadius: 24 } : undefined,
{ borderColor: "transparent" },
]}
placeholderTextColor="#d9d9d9"
/>
</View>
);
};
+244
View File
@@ -0,0 +1,244 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import {
View,
Text,
TouchableOpacity,
FlatList,
Platform,
useWindowDimensions,
ScrollView,
} from "react-native";
import { Portal } from "react-native-paper";
import { Ionicons } from "@expo/vector-icons";
import { useLazyQuickSearchQuery, SearchResult } from "@/store/api/searchApi";
import { useAppSelector } from "@/store/hooks";
import { selectSidebarCollapsed } from "@/store/screen/screenSizeSlice";
interface SearchDropdownProps {
searchQuery: string;
onResultSelect: (result: SearchResult) => void;
onViewAllResults: (query: string) => void;
}
const getIconName = (type: SearchResult["type"]) => {
switch (type) {
case "user":
return "person-outline";
case "admin":
return "shield-outline";
case "todo":
return "list-outline";
default:
return "search-outline";
}
};
const getResultTypeLabel = (type: SearchResult["type"]) => {
switch (type) {
case "user":
return "User";
case "admin":
return "Admin";
case "todo":
return "Todo";
default:
return "Result";
}
};
export const SearchDropdown = ({
searchQuery,
onResultSelect,
onViewAllResults,
}: SearchDropdownProps) => {
const [isVisible, setIsVisible] = useState(false);
const containerId = "global-search-dropdown";
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const sidebarCollapsed = useAppSelector(selectSidebarCollapsed);
const [triggerSearch, { data, isLoading, error }] = useLazyQuickSearchQuery();
// Fetch results when query changes
useEffect(() => {
if (searchQuery && searchQuery.trim().length >= 2) {
triggerSearch({ q: searchQuery })
.unwrap()
.then(() => setIsVisible(true))
.catch(() => setIsVisible(false));
} else {
setIsVisible(false);
}
}, [searchQuery, triggerSearch]);
// Hide if query cleared
useEffect(() => {
if (!searchQuery.trim()) setIsVisible(false);
}, [searchQuery]);
// Close dropdown on outside click (web only)
useEffect(() => {
if (!isVisible || Platform.OS !== "web") return;
const handler = (e: MouseEvent | TouchEvent) => {
const el = document.getElementById(containerId);
if (!el) return;
if (e.target instanceof Node && !el.contains(e.target)) {
setIsVisible(false);
}
};
document.addEventListener("mousedown", handler);
document.addEventListener("touchstart", handler, { passive: true });
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("touchstart", handler);
};
}, [isVisible]);
// Group results memoized
const groupedResults = useMemo(() => {
if (!data?.results) return [];
const groups: { type: string; items: SearchResult[] }[] = [];
const map: Record<string, SearchResult[]> = {};
data.results.forEach((r) => {
(map[r.type] ||= []).push(r);
});
for (const [type, items] of Object.entries(map)) {
groups.push({ type, items });
}
return groups;
}, [data?.results]);
const renderGroupHeader = useCallback(
({ type, items }: { type: string; items: SearchResult[] }) => (
<View>
<View className="p-2 bg-gray-50 border-b border-gray-200 flex-row items-center">
<Ionicons
name={getIconName(type as SearchResult["type"])}
size={14}
color="#6b7280"
style={{ marginRight: 4 }}
/>
<Text className="font-bold text-xs text-gray-700 uppercase tracking-wider">
{getResultTypeLabel(type as SearchResult["type"])}s ({items.length})
</Text>
</View>
<FlatList
data={items}
keyExtractor={(r) => `${r.type}-${r.id}`}
renderItem={({ item }) => (
<TouchableOpacity
className="flex-row items-center p-3 border-b border-gray-100"
onPress={() => {
setIsVisible(false);
onResultSelect(item);
}}
>
<View className="flex-1 pl-6">
<Text className="font-semibold text-gray-900" numberOfLines={1}>
{item.title}
</Text>
{item.subtitle ? (
<Text
className="text-sm text-gray-600 mb-1"
numberOfLines={1}
>
{item.subtitle}
</Text>
) : null}
{item.description ? (
<Text className="text-xs text-gray-500" numberOfLines={1}>
{item.description}
</Text>
) : null}
</View>
</TouchableOpacity>
)}
/>
</View>
),
[onResultSelect]
);
if (!isVisible || !searchQuery || searchQuery.length < 2) return null;
// Calculate dropdown positioning based on sidebar and content area
// Sidebar widths: collapsed = 80px, expanded = 200px
// We want dropdown to span from sidebar edge to content area with padding
const sidebarWidth = sidebarCollapsed ? 80 : 200;
const contentPadding = 20;
const rightPadding = 4;
const leftOffset = sidebarWidth + contentPadding;
const dropdownWidth = windowWidth - leftOffset - rightPadding - 16;
// Calculate max height: leave space at bottom (48px from top + some bottom margin)
const maxDropdownHeight = windowHeight - 48 - 32;
// For empty results, make it narrower and align to the right
const isEmpty = data && data.results.length === 0 && !isLoading;
return (
<Portal>
<View
className="absolute top-12 bg-white rounded-md shadow-lg z-50 p-2"
style={{
left: leftOffset,
right: rightPadding + 16,
width: dropdownWidth,
maxWidth: dropdownWidth,
zIndex: 9999,
}}
nativeID={containerId}
>
{isLoading && (
<View className="p-4">
<Text className="text-gray-500 text-center">Searching...</Text>
</View>
)}
{error && (
<View className="p-4">
<Text className="text-red-500 text-center">Search failed</Text>
</View>
)}
{isEmpty && (
<View className="p-4">
<Text className="text-gray-500 text-center">
No results found for &quot;{searchQuery}&quot;
</Text>
</View>
)}
{groupedResults.length > 0 && (
<ScrollView
style={{ maxHeight: maxDropdownHeight }}
showsVerticalScrollIndicator={true}
>
<FlatList
data={groupedResults}
keyExtractor={(g) => g.type}
renderItem={({ item }) => renderGroupHeader(item)}
scrollEnabled={false}
ListFooterComponent={
<TouchableOpacity
className="p-3 border-t border-gray-200 bg-gray-50"
onPress={() => {
setIsVisible(false);
onViewAllResults(searchQuery);
}}
>
<Text className="text-center text-blue-600 font-medium">
View all results for &quot;{searchQuery}&quot;
</Text>
</TouchableOpacity>
}
/>
</ScrollView>
)}
</View>
</Portal>
);
};
+318
View File
@@ -0,0 +1,318 @@
import { useState, useEffect } from "react";
import { View, Text, ScrollView, Image, TouchableOpacity } from "react-native";
import { TextInput, Checkbox } from "react-native-paper";
import { Ionicons } from "@expo/vector-icons";
export interface DropdownItem {
id: string;
label: string;
subtitle?: string;
description?: string;
imageUrl?: string;
initials?: string;
}
interface SearchableDropdownProps {
items: DropdownItem[];
selectedIds?: string | string[];
onSelectionChange: (value: string | string[] | undefined) => void;
label?: string;
placeholder?: string;
error?: string;
onSearchChange?: (query: string) => void;
mode?: "single" | "multi";
}
export const SearchableDropdown = ({
items,
selectedIds: selectedIdsProp,
onSelectionChange,
label = "Select Items",
placeholder = "Search...",
error,
onSearchChange,
mode = "single",
}: SearchableDropdownProps) => {
const [searchQuery, setSearchQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [allSelectedItems, setAllSelectedItems] = useState<DropdownItem[]>([]);
const selectedIds = Array.isArray(selectedIdsProp)
? selectedIdsProp
: selectedIdsProp
? [selectedIdsProp]
: [];
const selectedItems = items.filter((item) => selectedIds.includes(item.id));
// Update allSelectedItems whenever selection changes
// This preserves previously selected items even when they're not in the current search results
useEffect(() => {
// If no items selected, clear all
if (selectedIds.length === 0) {
setAllSelectedItems([]);
return;
}
// Add newly selected items from current search results
if (selectedItems.length > 0) {
setAllSelectedItems((prev) => {
const itemsMap = new Map(prev.map((item) => [item.id, item]));
selectedItems.forEach((item) => {
itemsMap.set(item.id, item);
});
// Filter to only keep items that are still selected
return Array.from(itemsMap.values()).filter((item) =>
selectedIds.includes(item.id)
);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds.length, selectedIds.join(",")]);
const handleToggleItem = (itemId: string) => {
if (mode === "single") {
onSelectionChange(itemId);
setIsOpen(false);
setSearchQuery("");
} else {
const isSelected = selectedIds.includes(itemId);
const newSelection = isSelected
? selectedIds.filter((id) => id !== itemId)
: [...selectedIds, itemId];
onSelectionChange(newSelection);
// Keep dropdown open in multi-select mode
}
};
const handleRemoveItem = (itemId: string) => {
if (mode === "single") {
onSelectionChange(undefined);
} else {
onSelectionChange(selectedIds.filter((id) => id !== itemId));
}
};
const handleSearchChange = (query: string) => {
setSearchQuery(query);
if (onSearchChange) {
onSearchChange(query);
}
};
const handleClearSearch = () => {
setSearchQuery("");
if (onSearchChange) onSearchChange("");
};
return (
<View className="mb-4" style={{ zIndex: isOpen ? 1000 : 1 }}>
<Text className="text-sm mb-1 font-medium">{label}</Text>
<View>
{/* Selected Item Display (Single-select) */}
{mode === "single" && selectedItems.length > 0 && !isOpen ? (
<TouchableOpacity
className="border border-gray-300 rounded p-3 bg-white flex-row items-center justify-between mb-2"
onPress={() => setIsOpen(true)}
>
<View className="flex-row items-center flex-1">
<View className="w-8 h-8 rounded-full overflow-hidden bg-primaryPurpleDark mr-2">
{selectedItems[0].imageUrl ? (
<Image
source={{ uri: selectedItems[0].imageUrl }}
className="w-full h-full"
resizeMode="cover"
/>
) : (
<View className="w-full h-full bg-blue-500 items-center justify-center">
<Text className="text-white text-xs font-bold">
{selectedItems[0].initials || "?"}
</Text>
</View>
)}
</View>
<View className="flex-1">
<Text className="font-medium" numberOfLines={1}>
{selectedItems[0].label}
</Text>
{selectedItems[0].subtitle && (
<Text className="text-xs text-gray-500" numberOfLines={1}>
{selectedItems[0].subtitle}
</Text>
)}
</View>
</View>
<TouchableOpacity
onPress={() => {
handleRemoveItem(selectedItems[0].id);
setIsOpen(true);
}}
className="ml-2 p-1"
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
>
<Ionicons name="close-circle" size={20} color="#666" />
</TouchableOpacity>
</TouchableOpacity>
) : null}
{/* Search Input */}
{((mode === "single" && selectedItems.length === 0) ||
mode === "multi" ||
isOpen) && (
<TextInput
className={`border !mr-0 ${
error ? "border-highRisk" : "border-gray-300"
} rounded p-2 text-base bg-white`}
value={searchQuery}
onChangeText={handleSearchChange}
placeholder={placeholder}
placeholderTextColor="#d9d9d9"
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
right={
searchQuery ? (
<TextInput.Icon icon="close" onPress={handleClearSearch} />
) : null
}
/>
)}
{/* Dropdown List - Only show when search query has 2+ characters */}
{isOpen && searchQuery.length >= 2 && (
<View
style={{
position: "absolute",
top: 48,
left: 0,
right: 0,
marginTop: 4,
backgroundColor: "white",
maxHeight: 240,
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 8,
overflow: "hidden",
zIndex: 9999,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 10,
}}
>
<ScrollView nestedScrollEnabled keyboardShouldPersistTaps="handled">
{items.length === 0 ? (
<View className="p-4">
<Text className="text-gray-500 text-center">
No items found for &quot;{searchQuery}&quot;
</Text>
</View>
) : (
items.map((item) => (
<TouchableOpacity
key={item.id}
className="flex-row items-center p-3 border-b border-gray-100"
onPress={() => handleToggleItem(item.id)}
>
{mode === "multi" && (
<Checkbox
status={
selectedIds.includes(item.id)
? "checked"
: "unchecked"
}
onPress={() => handleToggleItem(item.id)}
/>
)}
<View
className={`w-8 h-8 rounded-full overflow-hidden bg-primaryPurpleDark ${
mode === "multi" ? "ml-2 mr-2" : "mr-2"
}`}
>
{item.imageUrl ? (
<Image
source={{ uri: item.imageUrl }}
className="w-full h-full"
resizeMode="cover"
/>
) : (
<View className="w-full h-full bg-blue-500 items-center justify-center">
<Text className="text-white text-xs font-bold">
{item.initials || "?"}
</Text>
</View>
)}
</View>
<View className="flex-1">
<Text className="font-medium" numberOfLines={1}>
{item.label}
</Text>
{item.subtitle && (
<Text
className="text-xs text-gray-500"
numberOfLines={1}
>
{item.subtitle}
</Text>
)}
{item.description && (
<Text
className="text-xs text-gray-400"
numberOfLines={1}
>
{item.description}
</Text>
)}
</View>
</TouchableOpacity>
))
)}
</ScrollView>
</View>
)}
{/* Selected Items Bubbles (Multi-select) - Show below dropdown */}
{mode === "multi" && allSelectedItems.length > 0 && (
<View className="mt-2 flex-row flex-wrap gap-2" style={{ zIndex: 1 }}>
{allSelectedItems.map((item) => (
<View
key={item.id}
className="flex-row items-center bg-gray-100 rounded-full px-3 py-1"
>
<View className="w-6 h-6 rounded-full overflow-hidden bg-primaryPurpleDark mr-2">
{item.imageUrl ? (
<Image
source={{ uri: item.imageUrl }}
className="w-full h-full"
resizeMode="cover"
/>
) : (
<View className="w-full h-full bg-blue-500 items-center justify-center">
<Text className="text-white text-xs font-bold">
{item.initials || "?"}
</Text>
</View>
)}
</View>
<Text className="text-sm mr-2" numberOfLines={1}>
{item.label}
</Text>
<TouchableOpacity
onPress={() => handleRemoveItem(item.id)}
className="p-1"
>
<Ionicons name="close-circle" size={16} color="#666" />
</TouchableOpacity>
</View>
))}
</View>
)}
</View>
{error && <Text className="text-highRisk text-xs mt-1">{error}</Text>}
</View>
);
};
+32
View File
@@ -0,0 +1,32 @@
import { Chip } from "react-native-paper";
import {
TodoStatus,
TodoStatusLabels,
TodoStatusColors,
} from "@/constants/todoStatusEnum";
interface StatusTableItemProps {
todoStatus: TodoStatus;
}
export const StatusTableItem = ({ todoStatus }: StatusTableItemProps) => {
return (
<Chip
key={todoStatus}
textStyle={{
color: "black",
}}
compact
mode="outlined"
showSelectedCheck={false}
style={{
backgroundColor: TodoStatusColors[todoStatus],
borderColor: "transparent",
borderRadius: 4,
}}
>
{TodoStatusLabels[todoStatus]}
</Chip>
);
};