Yaltopia-Tickets-App/app/customers/[id].tsx
2026-06-05 13:39:37 +03:00

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>
);
}