264 lines
8.6 KiB
TypeScript
264 lines
8.6 KiB
TypeScript
import React, { useState, useCallback } from "react";
|
|
import {
|
|
View,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
FlatList,
|
|
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 { User, Search, Building2, Plus } from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { StandardHeader } from "@/components/StandardHeader";
|
|
import { EmptyState } from "@/components/EmptyState";
|
|
import { Button } from "@/components/ui/button";
|
|
import { api } from "@/lib/api";
|
|
import { useFocusEffect } from "expo-router";
|
|
import { useAuthStore } from "@/lib/auth-store";
|
|
import { useColorScheme } from "nativewind";
|
|
import { getPlaceholderColor } from "@/lib/colors";
|
|
|
|
interface Customer {
|
|
id: string;
|
|
displayName: string;
|
|
email: string;
|
|
phone: string;
|
|
type: "INDIVIDUAL" | "COMPANY";
|
|
createdAt: string;
|
|
}
|
|
|
|
const TYPE_FILTERS = [
|
|
{ key: "", label: "All" },
|
|
{ key: "INDIVIDUAL", label: "Individual" },
|
|
{ key: "COMPANY", label: "Company" },
|
|
];
|
|
|
|
export default function CustomersScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const { colorScheme } = useColorScheme();
|
|
const isDark = colorScheme === "dark";
|
|
|
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
|
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("");
|
|
|
|
const fetchCustomers = useCallback(
|
|
async (pageNum: number, isRefresh = false) => {
|
|
const { isAuthenticated } = useAuthStore.getState();
|
|
if (!isAuthenticated) return;
|
|
|
|
try {
|
|
if (!isRefresh) {
|
|
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
|
|
}
|
|
|
|
const query: Record<string, any> = { page: pageNum, limit: 10 };
|
|
if (search.trim()) query.search = search.trim();
|
|
if (typeFilter) query.type = typeFilter;
|
|
|
|
const response = await api.customers.getAll({ query });
|
|
const newData = response.data;
|
|
|
|
if (isRefresh) {
|
|
setCustomers(newData);
|
|
} else {
|
|
setCustomers((prev) =>
|
|
pageNum === 1 ? newData : [...prev, ...newData],
|
|
);
|
|
}
|
|
setHasMore(response.meta.hasNextPage);
|
|
setPage(pageNum);
|
|
} catch (err: any) {
|
|
console.error("[Customers] Fetch error:", err);
|
|
setHasMore(false);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[search, typeFilter],
|
|
);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
setPage(1);
|
|
fetchCustomers(1);
|
|
}, [fetchCustomers]),
|
|
);
|
|
|
|
const onRefresh = () => {
|
|
setRefreshing(true);
|
|
setPage(1);
|
|
fetchCustomers(1, true);
|
|
};
|
|
|
|
const loadMore = () => {
|
|
if (hasMore && !loadingMore && !loading) {
|
|
fetchCustomers(page + 1);
|
|
}
|
|
};
|
|
|
|
const renderItem = ({ item }: { item: Customer }) => (
|
|
<View className="px-[16px]">
|
|
<Pressable onPress={() => nav.go("customers/[id]", { id: item.id })}>
|
|
<Card className="rounded-xl border-border bg-card overflow-hidden mb-2">
|
|
<View className="flex-row items-center px-3 py-3">
|
|
<View className="w-10 h-10 rounded-lg bg-primary/10 items-center justify-center mr-3">
|
|
{item.type === "COMPANY" ? (
|
|
<Building2 color="#E46212" size={18} strokeWidth={2} />
|
|
) : (
|
|
<User 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 flex-1 mr-2"
|
|
numberOfLines={1}
|
|
>
|
|
{item.displayName}
|
|
</Text>
|
|
<View
|
|
className={`px-2 py-0.5 rounded-[4px] ${
|
|
item.type === "COMPANY" ? "bg-blue-500/10" : "bg-primary/10"
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`text-[8px] font-sans-bold uppercase tracking-widest ${
|
|
item.type === "COMPANY" ? "text-blue-600" : "text-primary"
|
|
}`}
|
|
>
|
|
{item.type === "COMPANY" ? "Company" : "Individual"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
{item.email || item.phone ? (
|
|
<View className="flex-row items-center mt-0.5 gap-2">
|
|
{item.email ? (
|
|
<Text
|
|
className="text-muted-foreground text-[11px] font-sans-medium"
|
|
numberOfLines={1}
|
|
>
|
|
{item.email}
|
|
</Text>
|
|
) : null}
|
|
{item.email && item.phone ? (
|
|
<Text className="text-muted-foreground text-[11px]">·</Text>
|
|
) : null}
|
|
{item.phone ? (
|
|
<Text
|
|
className="text-muted-foreground text-[11px] font-sans-medium"
|
|
numberOfLines={1}
|
|
>
|
|
{item.phone}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<FlatList
|
|
data={customers}
|
|
renderItem={renderItem}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={{ paddingBottom: 150 }}
|
|
showsVerticalScrollIndicator={false}
|
|
onRefresh={onRefresh}
|
|
refreshing={refreshing}
|
|
onEndReached={loadMore}
|
|
onEndReachedThreshold={0.5}
|
|
ListHeaderComponent={
|
|
<>
|
|
<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 name, email or phone..."
|
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
|
value={search}
|
|
onChangeText={setSearch}
|
|
autoCapitalize="none"
|
|
/>
|
|
</View>
|
|
|
|
<Button
|
|
className="mb-4 h-10 rounded-lg bg-primary"
|
|
onPress={() => nav.go("customers/create")}
|
|
>
|
|
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
|
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
|
Create Customer
|
|
</Text>
|
|
</Button>
|
|
|
|
{/* Type filter */}
|
|
<View className="flex-row gap-2 mb-4">
|
|
{TYPE_FILTERS.map((f) => (
|
|
<Pressable
|
|
key={f.key}
|
|
onPress={() => {
|
|
setTypeFilter(f.key);
|
|
setPage(1);
|
|
}}
|
|
className={`px-4 py-1.5 rounded-[4px] ${
|
|
typeFilter === f.key
|
|
? "bg-primary"
|
|
: "bg-card border border-border"
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`text-[9px] font-sans-bold uppercase tracking-widest ${
|
|
typeFilter === f.key
|
|
? "text-white"
|
|
: "text-muted-foreground"
|
|
}`}
|
|
>
|
|
{f.label}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</>
|
|
}
|
|
ListFooterComponent={
|
|
loadingMore ? (
|
|
<ActivityIndicator color="#E46212" className="py-4" />
|
|
) : null
|
|
}
|
|
ListEmptyComponent={
|
|
!loading ? (
|
|
<EmptyState
|
|
title={search ? "No matching customers" : "No customers yet"}
|
|
centered
|
|
/>
|
|
) : (
|
|
<View className="py-20">
|
|
<ActivityIndicator size="large" color="#E46212" />
|
|
</View>
|
|
)
|
|
}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|