460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
import React, { useCallback, useState } from "react";
|
|
import { CommandPalette } from "@/components/CommandPalette";
|
|
import {
|
|
View,
|
|
ScrollView,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
RefreshControl,
|
|
Image,
|
|
} from "react-native";
|
|
import { useFocusEffect } from "expo-router";
|
|
import { api, newsApi } from "@/lib/api";
|
|
import { Text } from "@/components/ui/text";
|
|
import { Card } from "@/components/ui/card";
|
|
import { useSirouRouter } from "@sirou/react-native";
|
|
import { AppRoutes } from "@/lib/routes";
|
|
import {
|
|
Clock,
|
|
DollarSign,
|
|
FileText,
|
|
ShieldCheck,
|
|
Receipt,
|
|
Wallet,
|
|
ChevronRight,
|
|
AlertTriangle,
|
|
Banknote,
|
|
FileCheck,
|
|
} from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { StandardHeader } from "@/components/StandardHeader";
|
|
import { EmptyState } from "@/components/EmptyState";
|
|
import { useAuthStore } from "@/lib/auth-store";
|
|
import { getProviderLogo, isCash } from "@/lib/payment-providers";
|
|
|
|
interface NewsItem {
|
|
id: string;
|
|
title: string;
|
|
content: string;
|
|
category: "ANNOUNCEMENT" | "UPDATE" | "MAINTENANCE" | "NEWS";
|
|
priority: "LOW" | "MEDIUM" | "HIGH";
|
|
publishedAt: string;
|
|
viewCount: number;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export default function HomeScreen() {
|
|
const [stats, setStats] = useState({
|
|
total: 0,
|
|
paid: 0,
|
|
pending: 0,
|
|
overdue: 0,
|
|
totalRevenue: 0,
|
|
});
|
|
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
|
|
const [newsItems, setNewsItems] = useState<NewsItem[]>([]);
|
|
const [loadingPayments, setLoadingPayments] = useState(false);
|
|
const [loadingNews, setLoadingNews] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
fetchStats();
|
|
fetchRecentPayments();
|
|
fetchNews();
|
|
}, []),
|
|
);
|
|
|
|
const fetchStats = async () => {
|
|
const { isAuthenticated } = useAuthStore.getState();
|
|
if (!isAuthenticated) return;
|
|
try {
|
|
const data = await api.invoices.stats();
|
|
setStats(data);
|
|
} catch (e) {
|
|
console.error("[HomeScreen] Failed to fetch stats:", e);
|
|
}
|
|
};
|
|
|
|
const fetchRecentPayments = async () => {
|
|
const { isAuthenticated } = useAuthStore.getState();
|
|
if (!isAuthenticated) return;
|
|
setLoadingPayments(true);
|
|
try {
|
|
const response = await api.payments.getAll({
|
|
query: { page: 1, limit: 5 },
|
|
});
|
|
setRecentPayments(response.data || []);
|
|
} catch (e) {
|
|
console.error("[HomeScreen] Failed to fetch payments:", e);
|
|
} finally {
|
|
setLoadingPayments(false);
|
|
}
|
|
};
|
|
|
|
const fetchNews = async () => {
|
|
const { isAuthenticated } = useAuthStore.getState();
|
|
if (!isAuthenticated) return;
|
|
setLoadingNews(true);
|
|
try {
|
|
const service = newsApi || api.news;
|
|
const data = await service.getLatest({ query: { limit: 5 } });
|
|
setNewsItems(data || []);
|
|
} catch (e) {
|
|
console.error("[HomeScreen] Failed to fetch news:", e);
|
|
} finally {
|
|
setLoadingNews(false);
|
|
}
|
|
};
|
|
|
|
const onRefresh = async () => {
|
|
setRefreshing(true);
|
|
await Promise.all([fetchStats(), fetchRecentPayments(), fetchNews()]);
|
|
};
|
|
|
|
const getCategoryColor = (category: string) => {
|
|
switch (category) {
|
|
case "ANNOUNCEMENT":
|
|
return "bg-amber-500";
|
|
case "UPDATE":
|
|
return "bg-blue-500";
|
|
case "MAINTENANCE":
|
|
return "bg-red-500";
|
|
default:
|
|
return "bg-emerald-500";
|
|
}
|
|
};
|
|
|
|
const formatAmount = (pay: Payment) => {
|
|
const val =
|
|
typeof pay.amount === "object" ? (pay.amount as any).value : pay.amount;
|
|
return `${pay.currency || "ETB"} ${(val || 0).toLocaleString()}`;
|
|
};
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<ScrollView
|
|
showsVerticalScrollIndicator={false}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
tintColor="#E46212"
|
|
/>
|
|
}
|
|
contentContainerStyle={{ paddingTop: 10, paddingBottom: 150 }}
|
|
>
|
|
<StandardHeader showSearch onSearchPress={() => setSearchOpen(true)} />
|
|
|
|
<View className="px-[16px] pt-6">
|
|
{/* ── Balance Card + Quick Actions ── */}
|
|
<View className="mb-5">
|
|
<Card className="overflow-hidden rounded-xl border-0 bg-primary">
|
|
<View className="p-4 relative">
|
|
<View
|
|
className="absolute -top-10 -right-10 w-48 h-48 bg-white/10 rounded-full"
|
|
style={{ transform: [{ scale: 1.5 }] }}
|
|
/>
|
|
<Text className="text-white font-sans-bold text-[14px]">
|
|
Available Balance
|
|
</Text>
|
|
<View className="mt-1 flex-row items-baseline">
|
|
<Text className="text-white text-3xl font-sans-bold">
|
|
ETB
|
|
</Text>
|
|
<Text className="ml-2 text-3xl font-sans-bold text-white">
|
|
{stats.total.toLocaleString()}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="mt-4 flex-row gap-4">
|
|
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10">
|
|
<View className="flex-row items-center gap-2">
|
|
<View className="p-1.5 bg-white/20 rounded-lg">
|
|
<Clock color="white" size={12} strokeWidth={2.5} />
|
|
</View>
|
|
<Text className="text-white text-[12px] font-sans-bold">
|
|
Pending
|
|
</Text>
|
|
</View>
|
|
<Text className="text-white font-sans-bold text-lg mt-1">
|
|
ETB {stats.pending.toLocaleString()}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10">
|
|
<View className="flex-row items-center gap-2">
|
|
<View className="p-1.5 bg-white/20 rounded-lg">
|
|
<DollarSign color="white" size={12} strokeWidth={2.5} />
|
|
</View>
|
|
<Text className="text-white text-[12px] font-sans-bold">
|
|
Income
|
|
</Text>
|
|
</View>
|
|
<Text className="text-white font-sans-bold text-lg mt-1">
|
|
ETB {stats.totalRevenue.toLocaleString()}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Quick Actions integrated into card */}
|
|
<View className="mt-4 pt-3 border-t border-white/15 flex-row justify-around items-center">
|
|
<QuickActionInline
|
|
icon={
|
|
<FileText color="white" size={18} strokeWidth={1.5} />
|
|
}
|
|
label="Proforma"
|
|
onPress={() => nav.go("proforma")}
|
|
/>
|
|
<QuickActionInline
|
|
icon={<Receipt color="white" size={18} strokeWidth={1.5} />}
|
|
label="Receipt"
|
|
onPress={() => nav.go("add-receipt")}
|
|
/>
|
|
<QuickActionInline
|
|
icon={
|
|
<ShieldCheck color="white" size={18} strokeWidth={1.5} />
|
|
}
|
|
label="Verify"
|
|
onPress={() => nav.go("verify-payment")}
|
|
/>
|
|
<QuickActionInline
|
|
icon={
|
|
<FileCheck color="white" size={18} strokeWidth={1.5} />
|
|
}
|
|
label="Declaration"
|
|
onPress={() => nav.go("declarations/index")}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
</View>
|
|
|
|
{/* ── Recent Payments ── */}
|
|
<View className="mb-6">
|
|
<View className="flex-row justify-between items-center mb-3">
|
|
<Text className="text-foreground font-sans-bold text-base tracking-tight">
|
|
Recent Payments
|
|
</Text>
|
|
{recentPayments.length > 0 && (
|
|
<Pressable
|
|
onPress={() => nav.go("(tabs)/payments")}
|
|
className="flex-row items-center"
|
|
>
|
|
<Text className="text-primary font-sans-bold text-[11px]">
|
|
View More
|
|
</Text>
|
|
<ChevronRight color="#E46212" size={14} strokeWidth={3} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
|
|
{loadingPayments ? (
|
|
<ActivityIndicator color="#E46212" className="py-6" />
|
|
) : recentPayments.length > 0 ? (
|
|
<View className="gap-2">
|
|
{recentPayments.map((pay) => {
|
|
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">
|
|
<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>
|
|
);
|
|
})}
|
|
</View>
|
|
) : (
|
|
<EmptyState title="No payments yet" />
|
|
)}
|
|
</View>
|
|
|
|
{/* ── News Section ── */}
|
|
<View>
|
|
<View className="flex-row justify-between items-center mb-3">
|
|
<Text className="text-foreground font-sans-bold text-base tracking-tight">
|
|
News & Updates
|
|
</Text>
|
|
{newsItems.length > 0 && (
|
|
<Pressable
|
|
onPress={() => nav.go("news/index")}
|
|
className="flex-row items-center"
|
|
>
|
|
<Text className="text-primary font-sans-bold text-[11px] ">
|
|
View More
|
|
</Text>
|
|
<ChevronRight color="#E46212" size={14} strokeWidth={3} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
|
|
{loadingNews ? (
|
|
<ActivityIndicator color="#E46212" className="py-6" />
|
|
) : newsItems.length > 0 ? (
|
|
<View className="gap-2">
|
|
{newsItems.map((item) => (
|
|
<Pressable
|
|
key={item.id}
|
|
onPress={() => nav.go("news/[id]", { id: item.id })}
|
|
>
|
|
<Card className="rounded-xl border-border bg-card overflow-hidden">
|
|
<View className="p-4">
|
|
<View className="flex-row items-center gap-2 mb-1.5">
|
|
<View
|
|
className={`w-1.5 h-1.5 rounded-full ${getCategoryColor(item.category)}`}
|
|
/>
|
|
<Text className="text-[9px] font-sans-bold uppercase tracking-widest text-muted-foreground">
|
|
{item.category}
|
|
</Text>
|
|
<Text className="text-[9px] font-sans-bold text-muted-foreground ml-auto">
|
|
{new Date(item.publishedAt).toLocaleDateString()}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
className="text-foreground font-sans-bold text-sm"
|
|
numberOfLines={1}
|
|
>
|
|
{item.title}
|
|
</Text>
|
|
<Text
|
|
variant="muted"
|
|
className="text-[11px] font-sans-bold leading-relaxed mt-1"
|
|
numberOfLines={2}
|
|
>
|
|
{item.content}
|
|
</Text>
|
|
</View>
|
|
</Card>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<EmptyState title="No news yet" />
|
|
)}
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
<CommandPalette
|
|
visible={searchOpen}
|
|
onClose={() => setSearchOpen(false)}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
function QuickActionInline({
|
|
icon,
|
|
label,
|
|
onPress,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
onPress?: () => void;
|
|
}) {
|
|
return (
|
|
<View className="items-center" style={{ width: 68 }}>
|
|
<Pressable
|
|
onPress={onPress}
|
|
className="h-10 w-10 rounded-full bg-white/15 items-center justify-center"
|
|
>
|
|
{icon}
|
|
</Pressable>
|
|
<Text className="text-white text-[12px] font-sans-bold tracking-tight text-center mt-1">
|
|
{label}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|