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

281 lines
9.7 KiB
TypeScript

import React, { useCallback, useState } from "react";
import {
View,
ScrollView,
Pressable,
TextInput,
ActivityIndicator,
RefreshControl,
} from "react-native";
import { useFocusEffect } from "expo-router";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { api } from "@/lib/api";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FileText, Plus, Search } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { EmptyState } from "@/components/EmptyState";
import { useColorScheme } from "nativewind";
import { getPlaceholderColor } from "@/lib/colors";
const STATUSES = ["All", "Draft", "Pending", "Paid", "Overdue", "Cancelled"];
export default function InvoicesTabScreen() {
const nav = useSirouRouter<AppRoutes>();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [activeFilter, setActiveFilter] = useState("All");
const [allInvoices, setAllInvoices] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [search, setSearch] = useState("");
const fetchPage = useCallback(async (pageNum: number, replace = false) => {
try {
pageNum === 1 && !replace ? setLoading(true) : setLoadingMore(true);
const response = await api.invoices.getAll({
query: { page: pageNum, limit: 10 },
});
const newInvoices = response.data || [];
setAllInvoices((prev) =>
replace || pageNum === 1 ? newInvoices : [...prev, ...newInvoices],
);
setHasMore(response.meta?.hasNextPage ?? false);
setPage(pageNum);
} catch (err) {
console.error("[Invoices] Fetch error:", err);
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
}, []);
useFocusEffect(
useCallback(() => {
fetchPage(1, true);
}, [fetchPage]),
);
const onRefresh = () => {
setRefreshing(true);
fetchPage(1, true);
};
const loadMore = () => {
if (hasMore && !loadingMore && !loading) {
fetchPage(page + 1);
}
};
const filteredInvoices = useCallback(() => {
let list = allInvoices;
if (activeFilter !== "All") {
list = list.filter(
(inv) => inv.status?.toUpperCase() === activeFilter.toUpperCase(),
);
}
if (search.trim()) {
const q = search.toLowerCase();
const searchNum = parseFloat(q);
list = list.filter((inv) => {
if (inv.customerName?.toLowerCase().includes(q)) return true;
if (inv.invoiceNumber?.toLowerCase().includes(q)) return true;
const invAmount =
typeof inv.amount === "object"
? parseFloat(inv.amount.value)
: parseFloat(inv.amount);
if (!isNaN(searchNum) && !isNaN(invAmount)) {
if (invAmount === searchNum) return true;
if (String(invAmount).includes(q)) return true;
}
return false;
});
}
return list;
}, [allInvoices, activeFilter, search]);
const getStatusStyle = (status: string) => {
switch (status?.toUpperCase()) {
case "PAID":
return "bg-emerald-500/15 text-emerald-700 border-emerald-200";
case "PENDING":
return "bg-amber-500/15 text-amber-700 border-amber-200";
case "DRAFT":
return "bg-muted text-muted-foreground border-border";
case "OVERDUE":
return "bg-red-500/15 text-red-700 border-red-200";
case "CANCELLED":
return "bg-muted text-muted-foreground border-border";
default:
return "bg-muted text-muted-foreground border-border";
}
};
if (loading && allInvoices.length === 0) {
return (
<ScreenWrapper className="bg-background">
<StandardHeader />
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#E46212" />
</View>
</ScreenWrapper>
);
}
return (
<ScreenWrapper className="bg-background">
<ScrollView
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#E46212"
/>
}
contentContainerStyle={{ paddingBottom: 150 }}
onScroll={({ nativeEvent }) => {
const isCloseToBottom =
nativeEvent.layoutMeasurement.height +
nativeEvent.contentOffset.y >=
nativeEvent.contentSize.height - 20;
if (isCloseToBottom) loadMore();
}}
scrollEventThrottle={400}
>
<StandardHeader />
<View className="px-[16px] pt-6">
{/* Search */}
<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="Search by customer name or invoice number..."
placeholderTextColor={getPlaceholderColor(isDark)}
value={search}
onChangeText={setSearch}
autoCapitalize="none"
/>
</View>
{/* Create button */}
<Button
className="mb-4 h-10 rounded-lg bg-primary"
onPress={() => nav.go("invoices/create")}
>
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
Create Invoice
</Text>
</Button>
{/* Filters */}
{allInvoices.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mb-4"
contentContainerStyle={{ gap: 6 }}
>
{STATUSES.map((s) => (
<Pressable
key={s}
onPress={() => setActiveFilter(s)}
className={`rounded-[4px] px-4 py-1.5 ${
activeFilter === s
? "bg-primary"
: "bg-card border border-border"
}`}
>
<Text
className={`text-[9px] font-sans-bold uppercase tracking-widest ${
activeFilter === s
? "text-white"
: "text-muted-foreground"
}`}
>
{s}
</Text>
</Pressable>
))}
</ScrollView>
)}
{filteredInvoices().length > 0 ? (
<View className="gap-2">
{filteredInvoices().map((inv: any) => (
<Pressable
key={inv.id}
onPress={() => nav.go("invoices/[id]", { id: inv.id })}
>
<Card className="rounded-xl border-border bg-card overflow-hidden">
<CardContent className="flex-row items-center py-3 px-3">
<View className="bg-primary/10 rounded-lg p-2 mr-3">
<FileText color="#E46212" size={20} strokeWidth={2} />
</View>
<View className="flex-1">
<Text className="text-foreground font-sans-semibold text-sm">
{inv.customerName || "Unknown"}
</Text>
<Text
variant="muted"
className="text-[11px] font-sans-medium mt-0.5"
>
{inv.issueDate
? new Date(inv.issueDate).toLocaleDateString()
: ""}
{inv.invoiceNumber ? ` · ${inv.invoiceNumber}` : ""}
</Text>
</View>
<View className="items-end">
<Text className="text-foreground font-sans-bold text-sm">
{inv.currency || ""}
{Number(inv.amount || 0).toLocaleString()}
</Text>
<View
className={`mt-1 rounded-[4px] px-2.5 py-0.5 border ${getStatusStyle(inv.status)}`}
>
<Text className="text-[8px] font-sans-bold uppercase tracking-widest">
{inv.status || "DRAFT"}
</Text>
</View>
</View>
</CardContent>
</Card>
</Pressable>
))}
{hasMore && (
<Pressable
onPress={loadMore}
disabled={loadingMore}
className="py-4 items-center"
>
{loadingMore ? (
<ActivityIndicator color="#E46212" size="small" />
) : (
<Text className="text-primary font-sans-bold text-[10px] uppercase tracking-widest">
Load More
</Text>
)}
</Pressable>
)}
</View>
) : (
<EmptyState title="No invoices yet" centered />
)}
</View>
</ScrollView>
</ScreenWrapper>
);
}