303 lines
10 KiB
TypeScript
303 lines
10 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 { Plus, Search, FileText, Calendar, ChevronRight } 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 TYPE_OPTIONS = ["All", "VAT", "WITHHOLDING_TAX"];
|
|
const STATUS_OPTIONS = ["All", "DRAFT", "SUBMITTED", "PAID", "CANCELLED"];
|
|
|
|
const TYPE_STYLES: Record<string, { bg: string; text: string }> = {
|
|
VAT: { bg: "bg-blue-500/10", text: "text-blue-600" },
|
|
WITHHOLDING_TAX: { bg: "bg-purple-500/10", text: "text-purple-600" },
|
|
};
|
|
|
|
const STATUS_STYLES: Record<
|
|
string,
|
|
{ bg: string; text: string; border: string }
|
|
> = {
|
|
DRAFT: {
|
|
bg: "bg-slate-500/15",
|
|
text: "text-slate-700",
|
|
border: "border-slate-200",
|
|
},
|
|
SUBMITTED: {
|
|
bg: "bg-amber-500/15",
|
|
text: "text-amber-700",
|
|
border: "border-amber-200",
|
|
},
|
|
PAID: {
|
|
bg: "bg-emerald-500/15",
|
|
text: "text-emerald-700",
|
|
border: "border-emerald-200",
|
|
},
|
|
CANCELLED: {
|
|
bg: "bg-red-500/15",
|
|
text: "text-red-700",
|
|
border: "border-red-200",
|
|
},
|
|
};
|
|
|
|
export default function DeclarationsScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const { colorScheme } = useColorScheme();
|
|
const isDark = colorScheme === "dark";
|
|
|
|
const [declarations, setDeclarations] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
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 [typeFilter, setTypeFilter] = useState("All");
|
|
const [statusFilter, setStatusFilter] = useState("All");
|
|
|
|
const fetchPage = useCallback(
|
|
async (pageNum: number, replace = false) => {
|
|
try {
|
|
pageNum === 1 && !replace ? setLoading(true) : setLoadingMore(true);
|
|
|
|
const query: any = { page: pageNum, limit: 10 };
|
|
if (typeFilter !== "All") query.type = typeFilter;
|
|
if (statusFilter !== "All") query.status = statusFilter;
|
|
|
|
const response = await api.declarations.getAll({ query });
|
|
|
|
const data = response.data || response;
|
|
setDeclarations((prev) =>
|
|
replace || pageNum === 1 ? data : [...prev, ...data],
|
|
);
|
|
setHasMore(response.meta?.hasNextPage ?? false);
|
|
setPage(pageNum);
|
|
} catch (err) {
|
|
console.error("[Declarations] Fetch error:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[typeFilter, statusFilter],
|
|
);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
fetchPage(1, true);
|
|
}, [fetchPage]),
|
|
);
|
|
|
|
const onRefresh = () => {
|
|
setRefreshing(true);
|
|
fetchPage(1, true);
|
|
};
|
|
|
|
const loadMore = () => {
|
|
if (hasMore && !loadingMore && !loading) {
|
|
fetchPage(page + 1);
|
|
}
|
|
};
|
|
|
|
const filtered = useCallback(() => {
|
|
if (!search.trim()) return declarations;
|
|
const q = search.toLowerCase();
|
|
return declarations.filter(
|
|
(d) =>
|
|
d.title?.toLowerCase().includes(q) ||
|
|
d.declarationNumber?.toLowerCase().includes(q) ||
|
|
d.tin?.toLowerCase().includes(q),
|
|
);
|
|
}, [declarations, search]);
|
|
|
|
const typeStyle = (t: string) => TYPE_STYLES[t] || TYPE_STYLES.VAT;
|
|
const statusStyle = (s: string) => STATUS_STYLES[s] || STATUS_STYLES.DRAFT;
|
|
|
|
if (loading && declarations.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 close =
|
|
nativeEvent.layoutMeasurement.height +
|
|
nativeEvent.contentOffset.y >=
|
|
nativeEvent.contentSize.height - 20;
|
|
if (close) loadMore();
|
|
}}
|
|
scrollEventThrottle={400}
|
|
>
|
|
<StandardHeader title="Declarations" showBack />
|
|
|
|
<View className="px-5 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="Search by title, number, or TIN..."
|
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
|
value={search}
|
|
onChangeText={setSearch}
|
|
autoCapitalize="none"
|
|
/>
|
|
</View>
|
|
|
|
<Button
|
|
className="mb-4 h-10 rounded-lg bg-primary"
|
|
onPress={() => nav.go("declarations/create")}
|
|
>
|
|
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
|
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
|
Create Declaration
|
|
</Text>
|
|
</Button>
|
|
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
className="mb-2"
|
|
contentContainerStyle={{ gap: 6 }}
|
|
>
|
|
{TYPE_OPTIONS.map((t) => (
|
|
<Pressable
|
|
key={t}
|
|
onPress={() => {
|
|
setTypeFilter(t);
|
|
setPage(1);
|
|
}}
|
|
className={`rounded-[4px] px-4 py-1.5 ${
|
|
typeFilter === t
|
|
? "bg-primary"
|
|
: "bg-card border border-border"
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`text-[9px] font-sans-bold uppercase tracking-widest ${
|
|
typeFilter === t ? "text-white" : "text-muted-foreground"
|
|
}`}
|
|
>
|
|
{t === "All" ? "All" : t.replace("_", " ")}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</ScrollView>
|
|
|
|
{filtered().length > 0 ? (
|
|
<View className="gap-2">
|
|
{filtered().map((d: any) => (
|
|
<Pressable
|
|
key={d.id}
|
|
onPress={() => nav.go("declarations/[id]", { id: d.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 mr-2">
|
|
<View className="flex-row items-center gap-1.5 mb-0.5">
|
|
<View
|
|
className={`px-1.5 py-0.5 rounded-[3px] ${typeStyle(d.type).bg}`}
|
|
>
|
|
<Text
|
|
className={`text-[7px] font-sans-bold uppercase tracking-widest ${typeStyle(d.type).text}`}
|
|
>
|
|
{d.type?.replace("_", " ") || "VAT"}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
className="text-foreground font-sans-semibold text-sm flex-1"
|
|
numberOfLines={1}
|
|
>
|
|
{d.title || `Declaration #${d.declarationNumber}`}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
variant="muted"
|
|
className="text-[11px] font-sans-medium"
|
|
>
|
|
{d.declarationNumber ? `#${d.declarationNumber}` : ""}
|
|
{d.period ? ` · ${d.period}` : ""}
|
|
{d.daysUntilDue != null
|
|
? ` · ${d.isOverdue ? `${Math.abs(d.daysUntilDue)}d overdue` : `${d.daysUntilDue}d left`}`
|
|
: ""}
|
|
</Text>
|
|
</View>
|
|
<View className="items-end">
|
|
<View
|
|
className={`rounded-[4px] px-2.5 py-0.5 border ${statusStyle(d.status).bg} ${statusStyle(d.status).border}`}
|
|
>
|
|
<Text
|
|
className={`text-[8px] font-sans-bold uppercase tracking-widest ${statusStyle(d.status).text}`}
|
|
>
|
|
{d.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 declarations yet"
|
|
description="Create your first tax declaration to get started."
|
|
centered
|
|
/>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|