project setup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 "{searchQuery}"
|
||||
</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 "{searchQuery}"
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
@@ -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 "{searchQuery}"
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user