570 lines
19 KiB
TypeScript
570 lines
19 KiB
TypeScript
import React from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
Image,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
} from "react-native";
|
|
import { router } from "expo-router";
|
|
import { ROUTES } from "~/lib/routes";
|
|
import TransactionCard from "~/components/ui/transactionCard";
|
|
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
|
import { useTransactions } from "~/lib/hooks/useTransactions";
|
|
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
|
import { Icons } from "~/assets/icons";
|
|
import { Input } from "~/components/ui/input";
|
|
import Skeleton from "~/components/ui/skeleton";
|
|
import BottomSheet from "~/components/ui/bottomSheet";
|
|
import ModalToast from "~/components/ui/toast";
|
|
import { useTranslation } from "react-i18next";
|
|
import BackButton from "~/components/ui/backButton";
|
|
|
|
type TransactionTypeFilter = "all" | "incoming" | "outgoing";
|
|
|
|
interface DateRange {
|
|
startDate: Date | null;
|
|
endDate: Date | null;
|
|
}
|
|
|
|
const daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"];
|
|
|
|
const stripTime = (date: Date) => {
|
|
const d = new Date(date);
|
|
d.setHours(0, 0, 0, 0);
|
|
return d;
|
|
};
|
|
|
|
const isSameDay = (a: Date | null, b: Date | null) => {
|
|
if (!a || !b) return false;
|
|
return (
|
|
a.getFullYear() === b.getFullYear() &&
|
|
a.getMonth() === b.getMonth() &&
|
|
a.getDate() === b.getDate()
|
|
);
|
|
};
|
|
|
|
const SimpleCalendarRange: React.FC<{
|
|
value: DateRange;
|
|
onChange: (range: DateRange) => void;
|
|
}> = ({ value, onChange }) => {
|
|
const initialBase = value.startDate || new Date();
|
|
const [currentMonth, setCurrentMonth] = React.useState(
|
|
() => new Date(initialBase.getFullYear(), initialBase.getMonth(), 1)
|
|
);
|
|
|
|
const monthLabel = currentMonth.toLocaleString("default", {
|
|
month: "short",
|
|
year: "numeric",
|
|
});
|
|
|
|
const days: (Date | null)[] = React.useMemo(() => {
|
|
const year = currentMonth.getFullYear();
|
|
const month = currentMonth.getMonth();
|
|
const firstDayOfMonth = new Date(year, month, 1);
|
|
const firstWeekday = firstDayOfMonth.getDay();
|
|
const numDays = new Date(year, month + 1, 0).getDate();
|
|
|
|
const arr: (Date | null)[] = [];
|
|
for (let i = 0; i < firstWeekday; i++) {
|
|
arr.push(null);
|
|
}
|
|
for (let d = 1; d <= numDays; d++) {
|
|
arr.push(new Date(year, month, d));
|
|
}
|
|
return arr;
|
|
}, [currentMonth]);
|
|
|
|
const handleSelectDay = (day: Date) => {
|
|
const { startDate, endDate } = value;
|
|
|
|
if (!startDate || (startDate && endDate)) {
|
|
onChange({ startDate: stripTime(day), endDate: null });
|
|
return;
|
|
}
|
|
|
|
const start = stripTime(startDate);
|
|
const selected = stripTime(day);
|
|
|
|
if (selected < start) {
|
|
onChange({ startDate: selected, endDate: start });
|
|
} else {
|
|
onChange({ startDate: start, endDate: selected });
|
|
}
|
|
};
|
|
|
|
const inRange = (day: Date) => {
|
|
const { startDate, endDate } = value;
|
|
if (!startDate || !endDate) return false;
|
|
const d = stripTime(day);
|
|
const s = stripTime(startDate);
|
|
const e = stripTime(endDate);
|
|
return d > s && d < e;
|
|
};
|
|
|
|
const goMonth = (offset: number) => {
|
|
setCurrentMonth(
|
|
(prev) => new Date(prev.getFullYear(), prev.getMonth() + offset, 1)
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View className="mt-3">
|
|
<View className="flex-row justify-between items-center mb-3">
|
|
<TouchableOpacity
|
|
onPress={() => goMonth(-1)}
|
|
className="w-10 h-10 rounded-full bg-white items-center justify-center"
|
|
>
|
|
<Text className="text-sm font-dmsans">{"<"}</Text>
|
|
</TouchableOpacity>
|
|
<Text className="text-sm font-dmsans-medium text-black">
|
|
{monthLabel}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={() => goMonth(1)}
|
|
className="w-10 h-10 rounded-full bg-white items-center justify-center"
|
|
>
|
|
<Text className="text-sm font-dmsans">{">"}</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View className="flex-row mb-1">
|
|
{daysOfWeek.map((d, idx) => (
|
|
<View
|
|
key={`${d}-${idx}`}
|
|
style={{ width: `${100 / 7}%` }}
|
|
className="items-center"
|
|
>
|
|
<Text className="text-[10px] text-gray-400 font-dmsans">{d}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
<View className="flex-row flex-wrap">
|
|
{days.map((day, index) => {
|
|
if (!day) {
|
|
return (
|
|
<View
|
|
key={index}
|
|
style={{ width: `${100 / 7}%`, aspectRatio: 1 }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const selectedStart = isSameDay(day, value.startDate);
|
|
const selectedEnd = isSameDay(day, value.endDate);
|
|
const between = inRange(day);
|
|
|
|
const isSelected = selectedStart || selectedEnd;
|
|
const bgColor = isSelected
|
|
? "#0F7B4A"
|
|
: between
|
|
? "rgba(15,123,74,0.08)"
|
|
: "transparent";
|
|
const textColor = isSelected ? "#ffffff" : "#111827";
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
key={index}
|
|
onPress={() => handleSelectDay(day)}
|
|
style={{ width: `${100 / 7}%`, aspectRatio: 1 }}
|
|
className="items-center justify-center"
|
|
>
|
|
<View
|
|
style={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 16,
|
|
backgroundColor: bgColor,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Text
|
|
style={{ color: textColor, fontSize: 12 }}
|
|
className="font-dmsans"
|
|
>
|
|
{day.getDate()}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default function History() {
|
|
const { user } = useAuthWithProfile();
|
|
const {
|
|
transactions,
|
|
loading: transactionsLoading,
|
|
error: transactionsError,
|
|
} = useTransactions(user?.uid);
|
|
const { t } = useTranslation();
|
|
const [searchQuery, setSearchQuery] = React.useState("");
|
|
const [filterVisible, setFilterVisible] = React.useState(false);
|
|
const [dateRange, setDateRange] = React.useState<DateRange>({
|
|
startDate: null,
|
|
endDate: null,
|
|
});
|
|
const [typeFilter, setTypeFilter] =
|
|
React.useState<TransactionTypeFilter>("all");
|
|
|
|
const [toastVisible, setToastVisible] = React.useState(false);
|
|
const [toastTitle, setToastTitle] = React.useState("");
|
|
const [toastDescription, setToastDescription] = React.useState<
|
|
string | undefined
|
|
>(undefined);
|
|
const [toastVariant, setToastVariant] = React.useState<
|
|
"success" | "error" | "warning" | "info"
|
|
>("info");
|
|
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
|
null
|
|
);
|
|
|
|
const showToast = (
|
|
title: string,
|
|
description?: string,
|
|
variant: "success" | "error" | "warning" | "info" = "info"
|
|
) => {
|
|
if (toastTimeoutRef.current) {
|
|
clearTimeout(toastTimeoutRef.current);
|
|
}
|
|
|
|
setToastTitle(title);
|
|
setToastDescription(description);
|
|
setToastVariant(variant);
|
|
setToastVisible(true);
|
|
|
|
toastTimeoutRef.current = setTimeout(() => {
|
|
setToastVisible(false);
|
|
toastTimeoutRef.current = null;
|
|
}, 2500);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (toastTimeoutRef.current) {
|
|
clearTimeout(toastTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (transactionsError) {
|
|
showToast(
|
|
t("history.toastErrorTitle"),
|
|
transactionsError || t("history.errorTitle"),
|
|
"error"
|
|
);
|
|
}
|
|
}, [transactionsError, t]);
|
|
|
|
const filteredTransactions = React.useMemo(() => {
|
|
let data = transactions || [];
|
|
|
|
const query = searchQuery.trim().toLowerCase();
|
|
if (query) {
|
|
data = data.filter((t) => {
|
|
const parts: string[] = [];
|
|
if ((t as any).recipientName)
|
|
parts.push(String((t as any).recipientName));
|
|
if ((t as any).senderName) parts.push(String((t as any).senderName));
|
|
if ((t as any).note) parts.push(String((t as any).note));
|
|
parts.push(String(t.type));
|
|
const haystack = parts.join(" ").toLowerCase();
|
|
return haystack.includes(query);
|
|
});
|
|
}
|
|
|
|
if (dateRange.startDate || dateRange.endDate) {
|
|
const start = dateRange.startDate ? stripTime(dateRange.startDate) : null;
|
|
const end = dateRange.endDate ? stripTime(dateRange.endDate) : null;
|
|
|
|
data = data.filter((t) => {
|
|
const created =
|
|
t.createdAt instanceof Date
|
|
? stripTime(t.createdAt)
|
|
: stripTime(new Date(t.createdAt));
|
|
|
|
if (start && created < start) return false;
|
|
if (end && created > end) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
if (typeFilter !== "all") {
|
|
data = data.filter((t) => {
|
|
const txType = t.type;
|
|
const isOutgoing = txType === "send" || txType === "cash_out";
|
|
const isIncoming = txType === "receive" || txType === "add_cash";
|
|
|
|
if (typeFilter === "outgoing") return isOutgoing;
|
|
if (typeFilter === "incoming") return isIncoming;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
return data;
|
|
}, [transactions, searchQuery, dateRange, typeFilter]);
|
|
|
|
return (
|
|
<ScreenWrapper edges={["bottom"]}>
|
|
<View className="flex items-center h-full w-full ">
|
|
{transactionsLoading ? (
|
|
<View className="flex items-center justify-center w-full">
|
|
<View className=" ">
|
|
<BackButton />
|
|
</View>
|
|
<View className="flex px-5 space-y-3 w-full">
|
|
<View className="flex flex-col space-y-1 items-left">
|
|
<Text className="text-xl font-dmsans text-primary">
|
|
{t("history.title")}
|
|
</Text>
|
|
<Text className="text-base font-dmsans text-gray-400">
|
|
{t("history.subtitle")}
|
|
</Text>
|
|
</View>
|
|
<View className="flex flex-col gap-4 py-4">
|
|
{Array.from({ length: 5 }).map((_, index) => (
|
|
<View key={index} className="w-full">
|
|
<Skeleton width="100%" height={72} radius={12} />
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : transactionsError ? (
|
|
<View className="flex items-center justify-center w-full">
|
|
<View className="">
|
|
<BackButton />
|
|
</View>
|
|
<View className="flex px-5 space-y-3 w-full">
|
|
<View className="flex flex-col space-y-1 py-5 items-left">
|
|
<Text className="text-xl font-dmsans text-primary">
|
|
{t("history.title")}
|
|
</Text>
|
|
<Text className="text-base font-dmsans text-gray-400">
|
|
{t("history.subtitle")}
|
|
</Text>
|
|
</View>
|
|
<View className="flex items-center justify-center py-8">
|
|
<Text className="text-red-500 font-dmsans">
|
|
{t("history.errorTitle")}
|
|
</Text>
|
|
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
|
|
{transactionsError}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
<>
|
|
<FlatList
|
|
className=""
|
|
data={filteredTransactions}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={{ paddingBottom: 30 }}
|
|
ListHeaderComponent={
|
|
<>
|
|
<View className="w-full mb-2">
|
|
<BackButton />
|
|
</View>
|
|
<View className="flex px-5 space-y-3 w-full pb-6">
|
|
<View className="flex flex-col space-y-1 py-5 items-left">
|
|
<Text className="text-xl font-dmsans text-primary">
|
|
{t("history.title")}
|
|
</Text>
|
|
<Text className="text-base font-dmsans text-gray-400">
|
|
{t("history.subtitle")}
|
|
</Text>
|
|
</View>
|
|
<View className="w-full">
|
|
<Input
|
|
value={searchQuery}
|
|
onChangeText={setSearchQuery}
|
|
placeholderText={t("history.searchPlaceholder")}
|
|
containerClassName="w-full"
|
|
borderClassName="border-[#D9DBE9] bg-white"
|
|
placeholderColor="#7E7E7E"
|
|
textClassName="text-[#000] text-sm"
|
|
rightIcon={
|
|
<TouchableOpacity
|
|
onPress={() => setFilterVisible(true)}
|
|
className="p-1"
|
|
>
|
|
<Image
|
|
source={Icons.filterIcon}
|
|
style={{
|
|
width: 25,
|
|
height: 25,
|
|
tintColor: "#0F7B4A",
|
|
}}
|
|
resizeMode="contain"
|
|
/>
|
|
</TouchableOpacity>
|
|
}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</>
|
|
}
|
|
ListEmptyComponent={
|
|
<View className="flex items-center justify-center pb-8">
|
|
<Text className="text-gray-500 font-dmsans">
|
|
{t("history.emptyTitle")}
|
|
</Text>
|
|
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
|
|
{t("history.emptySubtitle")}
|
|
</Text>
|
|
</View>
|
|
}
|
|
renderItem={({ item: transaction }) => (
|
|
<View className="px-5">
|
|
<TransactionCard
|
|
transaction={transaction}
|
|
onPress={() => {
|
|
router.push({
|
|
pathname: ROUTES.TRANSACTION_DETAIL,
|
|
params: {
|
|
transactionId: transaction.id,
|
|
amount: transaction.amount.toString(),
|
|
type: transaction.type,
|
|
recipientName:
|
|
transaction.type === "send"
|
|
? transaction.recipientName
|
|
: transaction.type === "receive"
|
|
? transaction.senderName
|
|
: transaction.type === "add_cash"
|
|
? "Card"
|
|
: "Bank",
|
|
date: transaction.createdAt.toISOString(),
|
|
status: transaction.status,
|
|
//@ts-ignore
|
|
note: transaction?.note || "",
|
|
fromHistory: "true",
|
|
},
|
|
});
|
|
}}
|
|
/>
|
|
</View>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
<BottomSheet
|
|
visible={filterVisible}
|
|
onClose={() => setFilterVisible(false)}
|
|
maxHeightRatio={0.9}
|
|
>
|
|
<Text className="text-lg font-dmsans-bold text-black mb-1">
|
|
{t("history.filterTitle")}
|
|
</Text>
|
|
<Text className="text-xs font-dmsans text-gray-500 mb-4">
|
|
{t("history.filterSubtitle")}
|
|
</Text>
|
|
|
|
{/* Date range */}
|
|
<Text className="text-base font-dmsans-medium text-black mb-2">
|
|
{t("history.dateRangeLabel")}
|
|
</Text>
|
|
<View className="flex-row justify-between mb-2">
|
|
<View className="flex-1 mr-2">
|
|
<Text className="text-[14px] text-gray-500 font-dmsans mb-1">
|
|
{t("history.fromLabel")}
|
|
</Text>
|
|
<View className="h-9 rounded-[4px] bg-gray-100 px-3 justify-center">
|
|
<Text className="text-xs font-dmsans text-gray-800">
|
|
{dateRange.startDate
|
|
? dateRange.startDate.toLocaleDateString()
|
|
: t("history.selectStart")}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<View className="flex-1 ml-2">
|
|
<Text className="text-[14px] text-gray-500 font-dmsans mb-1">
|
|
{t("history.toLabel")}
|
|
</Text>
|
|
<View className="h-9 rounded-[4px] bg-gray-100 px-3 justify-center">
|
|
<Text className="text-xs font-dmsans text-gray-800">
|
|
{dateRange.endDate
|
|
? dateRange.endDate.toLocaleDateString()
|
|
: t("history.selectEnd")}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<SimpleCalendarRange value={dateRange} onChange={setDateRange} />
|
|
|
|
<View className="flex-row justify-end mt-2 mb-4">
|
|
<TouchableOpacity
|
|
onPress={() => setDateRange({ startDate: null, endDate: null })}
|
|
>
|
|
<Text className="text-[14px] text-primary font-dmsans">
|
|
{t("history.clearDates")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Type filter */}
|
|
<Text className="text-base font-dmsans-medium text-black mb-4 mt-2">
|
|
{t("history.typeLabel")}
|
|
</Text>
|
|
<View className="flex-row mb-6">
|
|
{(
|
|
[
|
|
{ label: t("history.typeAll"), value: "all" },
|
|
{ label: t("history.typeIncoming"), value: "incoming" },
|
|
{ label: t("history.typeOutgoing"), value: "outgoing" },
|
|
] as { label: string; value: TransactionTypeFilter }[]
|
|
).map((option) => {
|
|
const active = typeFilter === option.value;
|
|
return (
|
|
<TouchableOpacity
|
|
key={option.value}
|
|
onPress={() => setTypeFilter(option.value)}
|
|
className={`px-4 py-2 rounded-[4px] mr-2 border ${
|
|
active
|
|
? "bg-primary border-primary"
|
|
: "bg-white border-gray-200"
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`text-sm font-dmsans-medium ${
|
|
active ? "text-white" : "text-gray-700"
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
<View className="w-full mt-2">
|
|
<TouchableOpacity
|
|
onPress={() => setFilterVisible(false)}
|
|
className="h-11 rounded-3xl bg-[#FFB668] items-center justify-center"
|
|
>
|
|
<Text className="text-white font-dmsans-bold text-sm">
|
|
{t("history.applyFilters")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</BottomSheet>
|
|
<ModalToast
|
|
visible={toastVisible}
|
|
title={toastTitle}
|
|
description={toastDescription}
|
|
variant={toastVariant}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|