290 lines
9.1 KiB
TypeScript
290 lines
9.1 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import {
|
|
View,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
FlatList,
|
|
ListRenderItem,
|
|
TextInput,
|
|
} from "react-native";
|
|
import { Text } from "@/components/ui/text";
|
|
import { Card } from "@/components/ui/card";
|
|
import { useSirouRouter } from "@sirou/react-native";
|
|
import { AppRoutes } from "@/lib/routes";
|
|
import { Plus, FileText, Search } from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { StandardHeader } from "@/components/StandardHeader";
|
|
import { Button } from "@/components/ui/button";
|
|
import { EmptyState } from "@/components/EmptyState";
|
|
import { api } from "@/lib/api";
|
|
import { useAuthStore } from "@/lib/auth-store";
|
|
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
|
|
|
|
interface ProformaItem {
|
|
id: string;
|
|
proformaNumber: string;
|
|
customerName: string;
|
|
customerEmail: string;
|
|
customerPhone: string;
|
|
amount: any;
|
|
currency: string;
|
|
issueDate: string;
|
|
dueDate: string;
|
|
description: string;
|
|
notes: string;
|
|
taxAmount: any;
|
|
discountAmount: any;
|
|
pdfPath: string;
|
|
userId: string;
|
|
items: any[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
const dummyData: ProformaItem = {
|
|
id: "dummy-1",
|
|
proformaNumber: "PF-001",
|
|
customerName: "John Doe",
|
|
customerEmail: "john@example.com",
|
|
customerPhone: "+1234567890",
|
|
amount: { value: 1000, currency: "USD" },
|
|
currency: "USD",
|
|
issueDate: "2026-03-10T11:51:36.134Z",
|
|
dueDate: "2026-03-10T11:51:36.134Z",
|
|
description: "Dummy proforma",
|
|
notes: "Test notes",
|
|
taxAmount: { value: 100, currency: "USD" },
|
|
discountAmount: { value: 50, currency: "USD" },
|
|
pdfPath: "dummy.pdf",
|
|
userId: "user-1",
|
|
items: [
|
|
{
|
|
id: "item-1",
|
|
description: "Test item",
|
|
quantity: 1,
|
|
unitPrice: { value: 1000, currency: "USD" },
|
|
total: { value: 1000, currency: "USD" },
|
|
},
|
|
],
|
|
createdAt: "2026-03-10T11:51:36.134Z",
|
|
updatedAt: "2026-03-10T11:51:36.134Z",
|
|
};
|
|
|
|
export default function ProformaScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const permissions = useAuthStore((s) => s.permissions);
|
|
const [proformas, setProformas] = useState<ProformaItem[]>([]);
|
|
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 canCreateProformas = hasPermission(
|
|
permissions,
|
|
PERMISSION_MAP["proforma:create"],
|
|
);
|
|
|
|
const fetchProformas = useCallback(
|
|
async (pageNum: number, isRefresh = false) => {
|
|
const { isAuthenticated } = useAuthStore.getState();
|
|
if (!isAuthenticated) return;
|
|
|
|
try {
|
|
if (!isRefresh) {
|
|
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
|
|
}
|
|
|
|
const response = await api.proforma.getAll({
|
|
query: { page: pageNum, limit: 10 },
|
|
});
|
|
|
|
let newProformas = response.data;
|
|
|
|
const newData = newProformas;
|
|
if (isRefresh) {
|
|
setProformas(newData);
|
|
} else {
|
|
setProformas((prev) =>
|
|
pageNum === 1 ? newData : [...prev, ...newData],
|
|
);
|
|
}
|
|
setHasMore(response.meta.hasNextPage);
|
|
setPage(pageNum);
|
|
} catch (err: any) {
|
|
console.error("[Proforma] Fetch error:", err);
|
|
setHasMore(false);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchProformas(1);
|
|
}, [fetchProformas]);
|
|
|
|
const onRefresh = () => {
|
|
setRefreshing(true);
|
|
fetchProformas(1, true);
|
|
};
|
|
|
|
const loadMore = () => {
|
|
if (hasMore && !loadingMore && !loading) {
|
|
fetchProformas(page + 1);
|
|
}
|
|
};
|
|
|
|
const renderProformaItem: ListRenderItem<ProformaItem> = ({ item }) => {
|
|
const amountVal =
|
|
typeof item.amount === "object" ? item.amount.value : item.amount;
|
|
const issuedStr = item.issueDate
|
|
? new Date(item.issueDate).toLocaleDateString()
|
|
: "";
|
|
const dueStr = item.dueDate
|
|
? new Date(item.dueDate).toLocaleDateString()
|
|
: "";
|
|
const itemsCount = Array.isArray(item.items) ? item.items.length : 0;
|
|
|
|
return (
|
|
<View className="px-[16px]">
|
|
<Pressable
|
|
onPress={() => nav.go("proforma/[id]", { id: item.id })}
|
|
className="mb-3"
|
|
>
|
|
<Card className="rounded-[12px] bg-card overflow-hidden border border-border/40">
|
|
<View className="p-4">
|
|
<View className="flex-row items-start">
|
|
<View className="bg-primary/10 p-2.5 rounded-[12px] border border-primary/10 mr-3">
|
|
<FileText color="#ea580c" size={18} strokeWidth={2.5} />
|
|
</View>
|
|
|
|
<View className="flex-1">
|
|
<View className="flex-row justify-between">
|
|
<View className="flex-1 pr-2">
|
|
<Text
|
|
className="text-foreground font-sans-semibold"
|
|
numberOfLines={1}
|
|
>
|
|
{item.proformaNumber || "Proforma"}
|
|
</Text>
|
|
<Text
|
|
variant="muted"
|
|
className="text-xs mt-0.5"
|
|
numberOfLines={1}
|
|
>
|
|
{item.customerName || "Customer"}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="items-end">
|
|
<Text className="text-foreground font-sans-bold text-base">
|
|
{item.currency || "$"}
|
|
{amountVal?.toLocaleString?.() ?? amountVal ?? "0"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="mt-2 flex-row items-center justify-between">
|
|
<Text
|
|
variant="muted"
|
|
className="text-[10px] font-sans-medium"
|
|
>
|
|
Issued: {issuedStr} | Due: {dueStr} | {itemsCount} item
|
|
{itemsCount !== 1 ? "s" : ""}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const filteredProformas = useMemo(() => {
|
|
if (!search.trim()) return proformas;
|
|
const q = search.toLowerCase();
|
|
const searchNum = parseFloat(q);
|
|
return proformas.filter((p) => {
|
|
if (p.customerName?.toLowerCase().includes(q)) return true;
|
|
if (p.proformaNumber?.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;
|
|
});
|
|
}, [proformas, search]);
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<FlatList
|
|
data={filteredProformas}
|
|
renderItem={renderProformaItem}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={{ paddingBottom: 150 }}
|
|
showsVerticalScrollIndicator={false}
|
|
onRefresh={onRefresh}
|
|
refreshing={refreshing}
|
|
onEndReached={loadMore}
|
|
onEndReachedThreshold={0.5}
|
|
ListHeaderComponent={
|
|
<>
|
|
<StandardHeader showBack title="Proforma" />
|
|
<View className="px-[16px] pt-6">
|
|
<View className="flex-row items-center bg-card border border-border rounded-xl px-3 h-11 mb-4">
|
|
<Search size={16} color="#94a3b8" strokeWidth={2} />
|
|
<TextInput
|
|
className="flex-1 ml-2 text-foreground text-sm"
|
|
placeholder="Search by name, number, or amount..."
|
|
placeholderTextColor="#94a3b8"
|
|
value={search}
|
|
onChangeText={setSearch}
|
|
autoCapitalize="none"
|
|
/>
|
|
</View>
|
|
<Button
|
|
className="mb-4 h-10 rounded-[10px] bg-primary"
|
|
onPress={() => nav.go("proforma/create")}
|
|
>
|
|
<Plus color="white" size={20} strokeWidth={3} />
|
|
<Text className="text-white text-xs font-sans-semibold uppercase tracking-widest ml-2">
|
|
Create New Proforma
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
</>
|
|
}
|
|
ListFooterComponent={
|
|
loadingMore ? (
|
|
<ActivityIndicator color="#ea580c" className="py-4" />
|
|
) : null
|
|
}
|
|
ListEmptyComponent={
|
|
!loading ? (
|
|
<EmptyState
|
|
title="No proformas yet"
|
|
description="Create your first proforma to get started with invoicing."
|
|
centered
|
|
/>
|
|
) : (
|
|
<View className="py-20">
|
|
<ActivityIndicator size="large" color="#ea580c" />
|
|
</View>
|
|
)
|
|
}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|