517 lines
20 KiB
TypeScript
517 lines
20 KiB
TypeScript
import React, { useState, useCallback, useMemo } from "react";
|
|
import {
|
|
View,
|
|
ScrollView,
|
|
ActivityIndicator,
|
|
Pressable,
|
|
TextInput,
|
|
Modal,
|
|
Dimensions,
|
|
} from "react-native";
|
|
import { useSirouRouter } from "@sirou/react-native";
|
|
import { AppRoutes } from "@/lib/routes";
|
|
import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router";
|
|
import { Text } from "@/components/ui/text";
|
|
import {
|
|
User,
|
|
Building2,
|
|
Mail,
|
|
Phone,
|
|
MapPin,
|
|
Hash,
|
|
Tag,
|
|
ShieldCheck,
|
|
BookOpen,
|
|
FileText,
|
|
Wallet,
|
|
Plus,
|
|
Search,
|
|
X,
|
|
} from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { StandardHeader } from "@/components/StandardHeader";
|
|
import { api } from "@/lib/api";
|
|
import { toast } from "@/lib/toast-store";
|
|
import { useColorScheme } from "nativewind";
|
|
|
|
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
|
|
|
export default function CustomerDetailScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const { id } = useLocalSearchParams();
|
|
|
|
const [data, setData] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [proformaItems, setProformaItems] = useState<any[]>([]);
|
|
const [paymentRequestItems, setPaymentRequestItems] = useState<any[]>([]);
|
|
const [showProformaSheet, setShowProformaSheet] = useState(false);
|
|
const [showPaymentRequestSheet, setShowPaymentRequestSheet] = useState(false);
|
|
const [sheetLoading, setSheetLoading] = useState(false);
|
|
const [proformaSearch, setProformaSearch] = useState("");
|
|
const [paymentReqSearch, setPaymentReqSearch] = useState("");
|
|
|
|
const fetch = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const cId = Array.isArray(id) ? id[0] : id;
|
|
if (!cId) return;
|
|
const response = await api.customers.getById({ params: { id: cId } });
|
|
setData(response || null);
|
|
} catch {
|
|
toast.error("Error", "Failed to load customer");
|
|
setData(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
useFocusEffect(useCallback(() => { fetch(); }, [fetch]));
|
|
|
|
const openProformaSheet = async () => {
|
|
setSheetLoading(true);
|
|
setShowProformaSheet(true);
|
|
setProformaSearch("");
|
|
try {
|
|
const res = await api.proforma.getAll({ query: { page: 1, limit: 50 } });
|
|
setProformaItems(res?.data || []);
|
|
} catch {
|
|
setProformaItems([]);
|
|
} finally {
|
|
setSheetLoading(false);
|
|
}
|
|
};
|
|
|
|
const openPaymentRequestSheet = async () => {
|
|
setSheetLoading(true);
|
|
setShowPaymentRequestSheet(true);
|
|
setPaymentReqSearch("");
|
|
try {
|
|
const res = await api.paymentRequests.getAll({ query: { page: 1, limit: 50 } });
|
|
setPaymentRequestItems(res?.data || []);
|
|
} catch {
|
|
setPaymentRequestItems([]);
|
|
} finally {
|
|
setSheetLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredProformas = useMemo(() => {
|
|
if (!proformaSearch.trim()) return proformaItems;
|
|
const q = proformaSearch.toLowerCase();
|
|
return proformaItems.filter(
|
|
(p: any) =>
|
|
(p.proformaNumber || "")?.toLowerCase().includes(q) ||
|
|
(p.customerName || "")?.toLowerCase().includes(q) ||
|
|
(String(p.amount || "")).includes(q),
|
|
);
|
|
}, [proformaItems, proformaSearch]);
|
|
|
|
const filteredPaymentRequests = useMemo(() => {
|
|
if (!paymentReqSearch.trim()) return paymentRequestItems;
|
|
const q = paymentReqSearch.toLowerCase();
|
|
return paymentRequestItems.filter(
|
|
(r: any) =>
|
|
(r.paymentRequestNumber || "")?.toLowerCase().includes(q) ||
|
|
(r.customerName || "")?.toLowerCase().includes(q) ||
|
|
(String(r.amount || "")).includes(q),
|
|
);
|
|
}, [paymentRequestItems, paymentReqSearch]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<StandardHeader title="Customer" showBack />
|
|
<View className="flex-1 items-center justify-center">
|
|
<ActivityIndicator size="large" color="#E46212" />
|
|
</View>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
if (!data) {
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<StandardHeader title="Customer" showBack />
|
|
<View className="flex-1 items-center justify-center px-8">
|
|
<Text className="text-muted-foreground text-center font-sans-medium">
|
|
Failed to load customer details.
|
|
</Text>
|
|
<Pressable onPress={fetch} className="mt-4 px-6 py-2 bg-primary rounded-[6px]">
|
|
<Text className="text-white font-sans-bold text-sm">Retry</Text>
|
|
</Pressable>
|
|
</View>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
const isCompany = data?.type === "COMPANY";
|
|
const d = data || {};
|
|
|
|
const goProformaCreate = () => nav.go("proforma/create");
|
|
const goPaymentRequestCreate = () => nav.go("payment-requests/create");
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<Stack.Screen options={{ headerShown: false }} />
|
|
<StandardHeader title="Customer" showBack />
|
|
|
|
<ScrollView
|
|
className="flex-1"
|
|
contentContainerStyle={{ paddingBottom: 120 }}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Identity Header */}
|
|
<View className="px-5 pt-6 pb-2">
|
|
<View className="flex-row items-center gap-4 mb-4">
|
|
<View className={`h-14 w-14 rounded-full items-center justify-center ${isCompany ? "bg-blue-500/10" : "bg-primary/10"}`}>
|
|
{isCompany ? (
|
|
<Building2 color="#2563eb" size={26} strokeWidth={2} />
|
|
) : (
|
|
<User color="#E46212" size={26} strokeWidth={2} />
|
|
)}
|
|
</View>
|
|
<View className="flex-1">
|
|
<View className="flex-row items-center gap-2">
|
|
<Text className="text-xl font-sans-black text-foreground tracking-tight flex-1">
|
|
{d.displayName || "—"}
|
|
</Text>
|
|
<View className={`px-2.5 py-1 rounded-[4px] ${isCompany ? "bg-blue-500/10" : "bg-primary/10"}`}>
|
|
<Text className={`text-[9px] font-sans-bold uppercase tracking-widest ${isCompany ? "text-blue-600" : "text-primary"}`}>
|
|
{isCompany ? "Company" : "Individual"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<Text className="text-muted-foreground text-xs font-sans-medium mt-0.5">
|
|
Created {d.createdAt ? new Date(d.createdAt).toLocaleDateString() : "—"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Contact */}
|
|
{(d.email || d.phone) && (
|
|
<View className="px-5 mb-5">
|
|
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
|
Contact
|
|
</Text>
|
|
<View className="bg-card rounded-[6px] border border-border p-4 gap-3">
|
|
<InfoRow icon={<Mail color="#E46212" size={14} strokeWidth={2} />} label="Email" value={d.email} />
|
|
{d.email && d.phone ? <View className="border-b border-border/40" /> : null}
|
|
<InfoRow icon={<Phone color="#E46212" size={14} strokeWidth={2} />} label="Phone" value={d.phone} />
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Identity Details */}
|
|
{(d.firstName || d.lastName || d.companyName) && (
|
|
<View className="px-5 mb-5">
|
|
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
|
Identity Details
|
|
</Text>
|
|
<View className="bg-card rounded-[6px] border border-border p-4 gap-3">
|
|
{isCompany ? (
|
|
<InfoRow icon={<Building2 color="#2563eb" size={14} strokeWidth={2} />} label="Company Name" value={d.companyName} />
|
|
) : (
|
|
<>
|
|
<InfoRow icon={<User color="#E46212" size={14} strokeWidth={2} />} label="First Name" value={d.firstName} />
|
|
{d.firstName && d.lastName ? <View className="border-b border-border/40" /> : null}
|
|
<InfoRow icon={<User color="#E46212" size={14} strokeWidth={2} />} label="Last Name" value={d.lastName} />
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Documents */}
|
|
{(d.tin || d.vatRegistrationNumber || d.businessLicenseNumber) && (
|
|
<View className="px-5 mb-5">
|
|
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
|
Documents
|
|
</Text>
|
|
<View className="bg-card rounded-[6px] border border-border p-4 gap-3">
|
|
<InfoRow icon={<Hash color="#E46212" size={14} strokeWidth={2} />} label="TIN Number" value={d.tin} />
|
|
{d.tin && d.vatRegistrationNumber ? <View className="border-b border-border/40" /> : null}
|
|
<InfoRow icon={<Tag color="#E46212" size={14} strokeWidth={2} />} label="VAT Registration" value={d.vatRegistrationNumber} />
|
|
{(d.tin || d.vatRegistrationNumber) && d.businessLicenseNumber ? <View className="border-b border-border/40" /> : null}
|
|
<InfoRow icon={<ShieldCheck color="#E46212" size={14} strokeWidth={2} />} label="Business License" value={d.businessLicenseNumber} />
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Address */}
|
|
{d.address && (
|
|
<View className="px-5 mb-5">
|
|
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
|
Address
|
|
</Text>
|
|
<View className="bg-card rounded-[6px] border border-border p-4">
|
|
<View className="flex-row items-start gap-3">
|
|
<View className="w-8 h-8 rounded-full bg-primary/10 items-center justify-center mt-0.5">
|
|
<MapPin color="#E46212" size={14} strokeWidth={2} />
|
|
</View>
|
|
<Text className="text-foreground font-sans-medium text-sm leading-5 flex-1">
|
|
{d.address}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Notes */}
|
|
{d.notes && (
|
|
<View className="px-5 mb-5">
|
|
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
|
Notes
|
|
</Text>
|
|
<View className="bg-card rounded-[6px] border border-border p-4">
|
|
<View className="flex-row items-start gap-3">
|
|
<View className="w-8 h-8 rounded-full bg-primary/10 items-center justify-center mt-0.5">
|
|
<BookOpen color="#E46212" size={14} strokeWidth={2} />
|
|
</View>
|
|
<Text className="text-foreground font-sans-medium text-sm leading-5 flex-1">
|
|
{d.notes}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Divider */}
|
|
<View className="mx-5 mb-5 border-t border-border/60" />
|
|
|
|
{/* Action Buttons */}
|
|
<View className="px-5 mb-6 gap-3">
|
|
<Pressable
|
|
onPress={openProformaSheet}
|
|
className="bg-primary h-10 rounded-[6px] flex-row items-center justify-center gap-2"
|
|
>
|
|
<FileText color="white" size={15} strokeWidth={2.5} />
|
|
<Text className="text-white font-sans-bold text-[11px] uppercase tracking-widest">
|
|
Proformas
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={openPaymentRequestSheet}
|
|
className="bg-primary h-10 rounded-[6px] flex-row items-center justify-center gap-2"
|
|
>
|
|
<Wallet color="white" size={15} strokeWidth={2.5} />
|
|
<Text className="text-white font-sans-bold text-[11px] uppercase tracking-widest">
|
|
Payment Requests
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Proforma Bottom Sheet */}
|
|
<ProformaSheet
|
|
visible={showProformaSheet}
|
|
onClose={() => setShowProformaSheet(false)}
|
|
loading={sheetLoading}
|
|
items={filteredProformas}
|
|
search={proformaSearch}
|
|
onSearchChange={setProformaSearch}
|
|
onCreateNew={goProformaCreate}
|
|
onSelectItem={(id: string) => {
|
|
setShowProformaSheet(false);
|
|
nav.go("proforma/[id]", { id });
|
|
}}
|
|
/>
|
|
|
|
{/* Payment Request Bottom Sheet */}
|
|
<ProformaSheet
|
|
visible={showPaymentRequestSheet}
|
|
onClose={() => setShowPaymentRequestSheet(false)}
|
|
loading={sheetLoading}
|
|
items={filteredPaymentRequests}
|
|
search={paymentReqSearch}
|
|
onSearchChange={setPaymentReqSearch}
|
|
onCreateNew={goPaymentRequestCreate}
|
|
onSelectItem={(id: string) => {
|
|
setShowPaymentRequestSheet(false);
|
|
nav.go("payment-requests/[id]", { id });
|
|
}}
|
|
type="payment"
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
function ProformaSheet({
|
|
visible,
|
|
onClose,
|
|
loading,
|
|
items,
|
|
search,
|
|
onSearchChange,
|
|
onCreateNew,
|
|
onSelectItem,
|
|
type = "proforma",
|
|
}: {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
loading: boolean;
|
|
items: any[];
|
|
search: string;
|
|
onSearchChange: (v: string) => void;
|
|
onCreateNew: () => void;
|
|
onSelectItem: (id: string) => void;
|
|
type?: "proforma" | "payment";
|
|
}) {
|
|
const { colorScheme } = useColorScheme();
|
|
const isDark = colorScheme === "dark";
|
|
const label = type === "proforma" ? "Proforma" : "Payment Request";
|
|
|
|
return (
|
|
<Modal
|
|
visible={visible}
|
|
transparent
|
|
animationType="slide"
|
|
onRequestClose={onClose}
|
|
>
|
|
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
|
|
<View className="flex-1 justify-end">
|
|
<Pressable
|
|
className="bg-card rounded-t-[36px] overflow-hidden border-t-[3px] border-border/20"
|
|
style={{ maxHeight: SCREEN_HEIGHT * 0.8 }}
|
|
onPress={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<View className="px-6 pb-4 pt-4 flex-row justify-between items-center">
|
|
<View className="w-10" />
|
|
<Text className="text-foreground font-sans-bold text-[18px]">
|
|
{label}s
|
|
</Text>
|
|
<Pressable
|
|
onPress={onClose}
|
|
className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10"
|
|
>
|
|
<X
|
|
size={14}
|
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
|
strokeWidth={2.5}
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Search */}
|
|
<View className="px-5 pb-4">
|
|
<View className="bg-background rounded-[6px] border border-border flex-row items-center px-3.5 py-2.5">
|
|
<Search size={15} color="#94a3b8" strokeWidth={2} />
|
|
<TextInput
|
|
className="flex-1 ml-2.5 text-foreground font-sans-medium text-sm"
|
|
placeholder={`Search ${label}s...`}
|
|
placeholderTextColor="#94a3b8"
|
|
value={search}
|
|
onChangeText={onSearchChange}
|
|
/>
|
|
{search.length > 0 && (
|
|
<Pressable onPress={() => onSearchChange("")}>
|
|
<X size={14} color="#94a3b8" strokeWidth={2.5} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Create New */}
|
|
<View className="px-5 pb-5">
|
|
<Pressable
|
|
onPress={onCreateNew}
|
|
className="bg-primary rounded-[6px] py-3.5 flex-row items-center justify-center gap-2"
|
|
>
|
|
<Plus size={16} color="white" strokeWidth={2.5} />
|
|
<Text className="text-white font-sans-bold text-sm">
|
|
Create New {label}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* List */}
|
|
<ScrollView
|
|
className="px-5"
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={{ paddingBottom: 40 }}
|
|
>
|
|
{loading ? (
|
|
<View className="py-8 items-center">
|
|
<ActivityIndicator color="#E46212" size="small" />
|
|
</View>
|
|
) : items.length === 0 ? (
|
|
<View className="py-8 items-center">
|
|
<Text className="text-muted-foreground text-sm font-sans-medium">
|
|
{search ? `No ${label}s match your search` : `No ${label}s found`}
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
items.map((item: any) => {
|
|
const num = type === "proforma"
|
|
? item.proformaNumber
|
|
: item.paymentRequestNumber;
|
|
const status = (item.status || "DRAFT").toUpperCase();
|
|
const st: Record<string, { label: string; bg: string; text: string }> = {
|
|
PAID: { label: "Paid", bg: "bg-emerald-500/10", text: "text-emerald-600" },
|
|
PENDING: { label: "Pending", bg: "bg-amber-500/10", text: "text-amber-600" },
|
|
DRAFT: { label: "Draft", bg: "bg-blue-500/10", text: "text-blue-600" },
|
|
CANCELLED: { label: "Cancelled", bg: "bg-slate-500/10", text: "text-slate-600" },
|
|
};
|
|
const s = st[status] || st.DRAFT;
|
|
return (
|
|
<Pressable
|
|
key={item.id}
|
|
onPress={() => onSelectItem(item.id)}
|
|
className="bg-card rounded-[6px] border border-border p-4 mb-3"
|
|
>
|
|
<View className="flex-row items-start justify-between mb-1.5">
|
|
<View className="flex-1 mr-3">
|
|
<Text className="text-foreground font-sans-bold text-sm">
|
|
{num || item.id?.slice(0, 8) || "—"}
|
|
</Text>
|
|
<Text className="text-muted-foreground text-xs font-sans-medium mt-0.5" numberOfLines={1}>
|
|
{item.customerName || "—"}
|
|
</Text>
|
|
</View>
|
|
<Text className="text-foreground font-sans-bold text-sm">
|
|
{item.amount != null ? Number(item.amount).toLocaleString("en-US", { minimumFractionDigits: 2 }) : "—"}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row items-center gap-2">
|
|
<View className={`px-2 py-0.5 rounded-[3px] ${s.bg}`}>
|
|
<Text className={`text-[8px] font-sans-bold uppercase tracking-widest ${s.text}`}>
|
|
{s.label}
|
|
</Text>
|
|
</View>
|
|
{item.issueDate && (
|
|
<Text className="text-muted-foreground text-[10px] font-sans-medium">
|
|
{new Date(item.issueDate).toLocaleDateString()}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
})
|
|
)}
|
|
</ScrollView>
|
|
</Pressable>
|
|
</View>
|
|
</Pressable>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
|
if (!value) return null;
|
|
return (
|
|
<View className="flex-row items-center gap-3">
|
|
<View className="w-8 h-8 rounded-full bg-primary/10 items-center justify-center">
|
|
{icon}
|
|
</View>
|
|
<View className="flex-1">
|
|
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground">
|
|
{label}
|
|
</Text>
|
|
<Text className="text-foreground font-sans-bold text-sm mt-px">{value}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|