Amba-Agent-App/app/(root)/(screens)/history.tsx
2026-01-16 00:22:35 +03:00

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>
);
}