Yaltopia-Tickets-App/app/(tabs)/payments.tsx
2026-06-05 13:39:37 +03:00

481 lines
15 KiB
TypeScript

import React, { useState, useCallback } from "react";
import { CommandPalette } from "@/components/CommandPalette";
import {
View,
ScrollView,
Pressable,
TextInput,
ActivityIndicator,
Image,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { useFocusEffect } from "expo-router";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/lib/api";
import {
Wallet,
ChevronRight,
AlertTriangle,
Plus,
Search,
Banknote,
FileText,
Clock,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { EmptyState } from "@/components/EmptyState";
import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store";
import { useColorScheme } from "nativewind";
import { getPlaceholderColor } from "@/lib/colors";
import { getProviderLogo, isCash } from "@/lib/payment-providers";
type Tab = "payment" | "request";
interface Payment {
id: string;
transactionId: string;
amount: number;
currency: string;
paymentDate: string;
paymentMethod: string;
isFlagged: boolean;
senderName?: string;
receiverName?: string;
userId: string;
invoiceId?: string;
createdAt: string;
updatedAt: string;
}
interface PaymentRequest {
id: string;
paymentRequestNumber: string;
customerName: string;
amount: number;
currency: string;
issueDate: string;
dueDate: string;
status: string;
openedCount?: number;
copiedAccountCount?: number;
createdAt: string;
}
const STATUS_COLORS: Record<string, string> = {
DRAFT: "#6b7280",
SENT: "#E46212",
OPENED: "#2563eb",
PAID: "#16a34a",
EXPIRED: "#dc2626",
CANCELLED: "#6b7280",
};
const STATUS_BG: Record<string, string> = {
DRAFT: "#6b728015",
SENT: "#E4621215",
OPENED: "#2563eb15",
PAID: "#16a34a15",
EXPIRED: "#dc262615",
CANCELLED: "#dc262615",
};
function formatAmountCurrency(amount: any, currency = "ETB") {
const val = typeof amount === "object" ? (amount as any)?.value : amount;
return `${currency} ${(Number(val) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export default function PaymentsScreen() {
const nav = useSirouRouter<AppRoutes>();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [tab, setTab] = useState<Tab>("payment");
// Payment state
const [payments, setPayments] = useState<Payment[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [search, setSearch] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
// Request state
const [requests, setRequests] = useState<PaymentRequest[]>([]);
const [requestsLoading, setRequestsLoading] = useState(false);
const [reqPage, setReqPage] = useState(1);
const [reqHasMore, setReqHasMore] = useState(true);
const [reqLoadingMore, setReqLoadingMore] = useState(false);
const fetchPayments = useCallback(async (pageNum: number) => {
const { isAuthenticated } = useAuthStore.getState();
if (!isAuthenticated) return;
try {
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
const response = await api.payments.getAll({
query: { page: pageNum, limit: 10 },
});
const newPayments = response.data;
setPayments((prev) =>
pageNum === 1 ? newPayments : [...prev, ...newPayments],
);
setHasMore(response.meta.hasNextPage);
setPage(pageNum);
} catch (err: any) {
console.error("[Payments] Fetch error:", err);
toast.error("Error", "Failed to fetch payments.");
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
const fetchRequests = useCallback(async (pageNum: number) => {
const { isAuthenticated } = useAuthStore.getState();
if (!isAuthenticated) return;
try {
pageNum === 1 ? setRequestsLoading(true) : setReqLoadingMore(true);
const response = await api.paymentRequests.getAll({
query: { page: pageNum, limit: 10 },
});
const newRequests = response.data;
setRequests((prev) =>
pageNum === 1 ? newRequests : [...prev, ...newRequests],
);
setReqHasMore(response.meta.hasNextPage);
setReqPage(pageNum);
} catch (err: any) {
console.error("[PaymentRequests] Fetch error:", err);
toast.error("Error", "Failed to fetch payment requests.");
} finally {
setRequestsLoading(false);
setReqLoadingMore(false);
}
}, []);
useFocusEffect(
useCallback(() => {
if (tab === "payment") fetchPayments(1);
else fetchRequests(1);
}, [tab, fetchPayments, fetchRequests]),
);
const loadMore = () => {
if (tab === "payment" && hasMore && !loadingMore && !loading) {
fetchPayments(page + 1);
}
if (
tab === "request" &&
reqHasMore &&
!reqLoadingMore &&
!requestsLoading
) {
fetchRequests(reqPage + 1);
}
};
const formatAmount = (pay: Payment) => {
const val =
typeof pay.amount === "object" ? (pay.amount as any).value : pay.amount;
return `${pay.currency || "ETB"} ${(val || 0).toLocaleString()}`;
};
const filteredPayments = useCallback(() => {
if (!search.trim()) return payments;
const q = search.toLowerCase();
const searchNum = parseFloat(q);
return payments.filter((p) => {
if (p.senderName?.toLowerCase().includes(q)) return true;
if (p.transactionId?.toLowerCase().includes(q)) return true;
if (p.paymentMethod?.toLowerCase().includes(q)) return true;
const pAmount =
typeof p.amount === "object"
? parseFloat(p.amount.value)
: parseFloat(p.amount);
if (!isNaN(searchNum) && !isNaN(pAmount)) {
if (pAmount === searchNum) return true;
if (String(pAmount).includes(q)) return true;
}
return false;
});
}, [payments, search]);
const filteredRequests = useCallback(() => {
if (!search.trim()) return requests;
const q = search.toLowerCase();
const searchNum = parseFloat(q);
return requests.filter((r) => {
if (r.customerName?.toLowerCase().includes(q)) return true;
if (r.paymentRequestNumber?.toLowerCase().includes(q)) return true;
if (r.status?.toLowerCase().includes(q)) return true;
const rAmount =
typeof r.amount === "object"
? parseFloat(r.amount.value)
: parseFloat(r.amount);
if (!isNaN(searchNum) && !isNaN(rAmount)) {
if (rAmount === searchNum) return true;
if (String(rAmount).includes(q)) return true;
}
return false;
});
}, [requests, search]);
const renderPaymentItem = (pay: Payment) => {
const dateStr = new Date(pay.paymentDate).toLocaleDateString();
const logo = getProviderLogo(pay.paymentMethod);
const cash = isCash(pay.paymentMethod);
const hasFlag = pay.isFlagged;
return (
<Pressable
key={pay.id}
onPress={() => nav.go("payments/[id]", { id: pay.id })}
>
<Card className="rounded-xl border-border bg-card overflow-hidden mb-2">
<View className="flex-row items-center px-3 py-3">
{logo ? (
<View className="w-10 h-10 items-center justify-center mr-3 overflow-hidden">
<Image source={logo} className="w-7 h-7" resizeMode="contain" />
</View>
) : (
<View
className={`w-10 h-10 rounded-lg items-center justify-center mr-3 ${
hasFlag
? "bg-red-500/10"
: cash
? "bg-green-500/10"
: "bg-primary/10"
}`}
>
{hasFlag ? (
<AlertTriangle color="#EF435E" size={18} strokeWidth={2} />
) : cash ? (
<Banknote color="#16a34a" size={18} strokeWidth={2} />
) : (
<Wallet color="#E46212" size={18} strokeWidth={2} />
)}
</View>
)}
<View className="flex-1">
<View className="flex-row items-center justify-between">
<Text className="text-foreground font-sans-bold text-sm">
{formatAmount(pay)}
</Text>
{hasFlag && (
<View className="bg-red-500/10 px-2 py-0.5 rounded-[4px] border border-red-200">
<Text className="text-red-700 text-[8px] font-sans-bold uppercase tracking-widest">
Flagged
</Text>
</View>
)}
</View>
<Text
variant="muted"
className="text-[11px] font-sans-medium mt-0.5"
>
{pay.paymentMethod} · {pay.senderName} · {dateStr}
</Text>
</View>
{!hasFlag && (
<ChevronRight size={16} strokeWidth={2} color="#94a3b8" />
)}
</View>
</Card>
</Pressable>
);
};
const renderRequestItem = (req: PaymentRequest) => {
const statusColor = STATUS_COLORS[req.status] || "#6b7280";
const statusBg = STATUS_BG[req.status] || "#6b728015";
return (
<Pressable
key={req.id}
onPress={() => nav.go("payment-requests/[id]", { id: req.id })}
>
<Card className="rounded-xl border-border bg-card overflow-hidden mb-2">
<View className="flex-row items-center px-3 py-3">
<View className="w-10 h-10 rounded-lg bg-primary/10 items-center justify-center mr-3">
<FileText color="#E46212" size={18} strokeWidth={2} />
</View>
<View className="flex-1">
<View className="flex-row items-center justify-between">
<Text className="text-foreground font-sans-bold text-sm">
{formatAmountCurrency(req.amount, req.currency)}
</Text>
<View
className="px-2 py-0.5 rounded-[4px]"
style={{ backgroundColor: statusBg }}
>
<Text
className="text-[8px] font-sans-bold uppercase tracking-widest"
style={{ color: statusColor }}
>
{req.status}
</Text>
</View>
</View>
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-0.5">
{req.customerName} · #{req.paymentRequestNumber}
</Text>
</View>
</View>
</Card>
</Pressable>
);
};
const isLoading =
tab === "payment"
? loading && page === 1
: requestsLoading && reqPage === 1;
if (isLoading) {
return (
<ScreenWrapper className="bg-background">
<StandardHeader />
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#E46212" />
</View>
</ScreenWrapper>
);
}
const items = tab === "payment" ? filteredPayments() : filteredRequests();
return (
<ScreenWrapper className="bg-background">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
onScroll={({ nativeEvent }) => {
const isCloseToBottom =
nativeEvent.layoutMeasurement.height +
nativeEvent.contentOffset.y >=
nativeEvent.contentSize.height - 20;
if (isCloseToBottom) loadMore();
}}
scrollEventThrottle={400}
>
<StandardHeader showSearch onSearchPress={() => setSearchOpen(true)} />
<View className="px-[16px] pt-6">
<View className="flex-row items-center bg-card border border-border rounded-xl px-3 h-11 mb-3">
<Search size={16} color="#94a3b8" strokeWidth={2} />
<TextInput
className="flex-1 ml-2 text-foreground text-sm"
placeholder={
tab === "payment"
? "Search by sender name or transaction ID..."
: "Search by customer, number or status..."
}
placeholderTextColor={getPlaceholderColor(isDark)}
value={search}
onChangeText={setSearch}
autoCapitalize="none"
/>
</View>
{/* Create button */}
<Button
className="mb-4 h-10 rounded-lg bg-primary"
onPress={() =>
tab === "payment"
? nav.go("payments/create")
: nav.go("payment-requests/create")
}
>
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
{tab === "payment" ? "Create Payment" : "Create Request"}
</Text>
</Button>
{/* Tabs */}
<View className="flex-row bg-card border border-border rounded-xl p-1 mb-4">
<Pressable
onPress={() => {
if (tab !== "payment") {
setTab("payment");
setSearch("");
}
}}
className={`flex-1 py-2 rounded-[8px] items-center ${
tab === "payment" ? "bg-primary" : ""
}`}
>
<Text
className={`text-[11px] font-sans-bold uppercase tracking-widest ${
tab === "payment" ? "text-white" : "text-muted-foreground"
}`}
>
Payment
</Text>
</Pressable>
<Pressable
onPress={() => {
if (tab !== "request") {
setTab("request");
setSearch("");
}
}}
className={`flex-1 py-2 rounded-[8px] items-center ${
tab === "request" ? "bg-primary" : ""
}`}
>
<Text
className={`text-[11px] font-sans-bold uppercase tracking-widest ${
tab === "request" ? "text-white" : "text-muted-foreground"
}`}
>
Request
</Text>
</Pressable>
</View>
<View className="gap-2">
{items.length > 0 ? (
items.map((item) =>
tab === "payment"
? renderPaymentItem(item as Payment)
: renderRequestItem(item as PaymentRequest),
)
) : (
<EmptyState
title={
search
? "No matching results"
: tab === "payment"
? "No payments yet"
: "No payment requests yet"
}
centered
/>
)}
</View>
{(loadingMore || reqLoadingMore) && (
<View className="py-4">
<ActivityIndicator color="#E46212" />
</View>
)}
</View>
</ScrollView>
<CommandPalette
visible={searchOpen}
onClose={() => setSearchOpen(false)}
/>
</ScreenWrapper>
);
}