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

244 lines
8.4 KiB
TypeScript

import React, { useCallback, useMemo, useState } from "react";
import {
View,
ScrollView,
Pressable,
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 { EmptyState } from "@/components/EmptyState";
import { Card, CardContent } from "@/components/ui/card";
import { FileText, Plus } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
const STATUSES = ["All", "Draft", "Pending", "Paid", "Overdue", "Cancelled"];
export default function InvoicesListScreen() {
const nav = useSirouRouter<AppRoutes>();
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 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 (e) {
console.error("[InvoicesList] Failed to fetch invoices:", e);
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
}, []);
useFocusEffect(
useCallback(() => {
fetchPage(1);
}, [fetchPage]),
);
const onRefresh = () => {
setRefreshing(true);
fetchPage(1, true);
};
const loadMore = () => {
if (hasMore && !loadingMore && !loading) {
fetchPage(page + 1);
}
};
const filteredInvoices = useMemo(() => {
if (activeFilter === "All") return allInvoices;
return allInvoices.filter(
(inv) => inv.status === activeFilter.toUpperCase(),
);
}, [allInvoices, activeFilter]);
if (loading && page === 1) {
return (
<ScreenWrapper className="bg-background">
<StandardHeader title="Invoices" showBack />
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#ea580c" size="large" />
</View>
</ScreenWrapper>
);
}
return (
<ScreenWrapper className="bg-background">
<StandardHeader title="Invoices" showBack />
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 100 }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#ea580c"
/>
}
onScroll={({ nativeEvent }) => {
const isCloseToBottom =
nativeEvent.layoutMeasurement.height +
nativeEvent.contentOffset.y >=
nativeEvent.contentSize.height - 20;
if (isCloseToBottom) loadMore();
}}
scrollEventThrottle={400}
>
<View className="px-[16px] pt-4">
<Button
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
onPress={() => nav.go("invoices/create")}
>
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="text-white text-xs font-sans-semibold uppercase tracking-widest ml-2">
Create Invoice
</Text>
</Button>
{/* Status Filters — client-side */}
<View className="mb-6">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8 }}
>
{STATUSES.map((filter) => (
<Pressable
key={filter}
onPress={() => setActiveFilter(filter)}
className={`rounded-[4px] px-4 py-1.5 ${
activeFilter === filter
? "bg-primary"
: "bg-card border border-border"
}`}
>
<Text
className={`text-xs font-sans-bold ${
activeFilter === filter
? "text-white"
: "text-muted-foreground"
}`}
>
{filter}
</Text>
</Pressable>
))}
</ScrollView>
</View>
{/* Invoices List */}
<View className="gap-2">
{loading ? (
<ActivityIndicator color="#ea580c" className="py-20" />
) : filteredInvoices.length > 0 ? (
filteredInvoices.map((inv) => (
<Pressable
key={inv.id}
onPress={() => nav.go("invoices/[id]", { id: inv.id })}
>
<Card className="overflow-hidden rounded-[6px] bg-card border border-border">
<CardContent className="flex-row items-center py-3 px-2">
<View className="bg-secondary/40 rounded-[6px] p-2 mr-2 border border-border/10">
<FileText
className="text-muted-foreground"
size={22}
strokeWidth={2}
/>
</View>
<View className="flex-1">
<Text
variant="p"
className="text-foreground font-sans-semibold"
>
{inv.customerName}
</Text>
<Text
variant="muted"
className="mt-1 text-[11px] font-sans-medium opacity-70"
>
{new Date(inv.issueDate).toLocaleDateString()} ·
{inv.invoiceNumber
? ` #${inv.invoiceNumber}`
: " Proforma"}
</Text>
</View>
<View className="items-end">
<Text
variant="p"
className="text-foreground font-sans-semibold"
>
${Number(inv.amount).toLocaleString()}
</Text>
<View
className={`mt-1 rounded-[5px] px-3 py-1 border border-border ${
inv.status === "PAID"
? "bg-emerald-500/30 text-emerald-600"
: inv.status === "PENDING"
? "bg-amber-500/30 text-amber-600"
: inv.status === "DRAFT"
? "bg-secondary text-muted-foreground"
: "bg-red-500/30 text-red-600"
}`}
>
<Text className="text-[9px] font-sans-semibold uppercase tracking-widest">
{inv.status}
</Text>
</View>
</View>
</CardContent>
</Card>
</Pressable>
))
) : (
<EmptyState
title="No invoices found"
description={
activeFilter === "All"
? "Create your first invoice to get started."
: `No invoices with status "${activeFilter}".`
}
/>
)}
</View>
{loadingMore && (
<View className="py-4">
<ActivityIndicator color="#ea580c" />
</View>
)}
</View>
</ScrollView>
</ScreenWrapper>
);
}