374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
import React, { useState, useCallback } from "react";
|
||
import { View, ScrollView, ActivityIndicator } 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 { Button } from "@/components/ui/button";
|
||
import { User, Calendar, Clock, Building2, Hash, Send } from "@/lib/icons";
|
||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||
import { StandardHeader } from "@/components/StandardHeader";
|
||
import { api } from "@/lib/api";
|
||
import { useColorScheme } from "nativewind";
|
||
import { toast } from "@/lib/toast-store";
|
||
|
||
const STATUS_THEME: Record<
|
||
string,
|
||
{ label: string; bg: string; text: string; dot: string }
|
||
> = {
|
||
DRAFT: {
|
||
label: "Draft",
|
||
bg: "bg-slate-500/10",
|
||
text: "text-slate-600",
|
||
dot: "bg-slate-500",
|
||
},
|
||
SENT: {
|
||
label: "Sent",
|
||
bg: "bg-primary/10",
|
||
text: "text-primary",
|
||
dot: "bg-primary",
|
||
},
|
||
OPENED: {
|
||
label: "Opened",
|
||
bg: "bg-blue-500/10",
|
||
text: "text-blue-600",
|
||
dot: "bg-blue-500",
|
||
},
|
||
PAID: {
|
||
label: "Paid",
|
||
bg: "bg-emerald-500/10",
|
||
text: "text-emerald-600",
|
||
dot: "bg-emerald-500",
|
||
},
|
||
EXPIRED: {
|
||
label: "Expired",
|
||
bg: "bg-red-500/10",
|
||
text: "text-red-600",
|
||
dot: "bg-red-500",
|
||
},
|
||
CANCELLED: {
|
||
label: "Cancelled",
|
||
bg: "bg-slate-500/10",
|
||
text: "text-slate-600",
|
||
dot: "bg-slate-500",
|
||
},
|
||
};
|
||
|
||
function safeVal(v: any): number {
|
||
if (v == null) return 0;
|
||
if (typeof v === "object") return Number(v.value) || 0;
|
||
return Number(v) || 0;
|
||
}
|
||
|
||
function fmt(v: number, currency = "ETB") {
|
||
return `${currency} ${v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
}
|
||
|
||
export default function PaymentRequestDetailScreen() {
|
||
const nav = useSirouRouter<AppRoutes>();
|
||
const { id } = useLocalSearchParams();
|
||
const { colorScheme } = useColorScheme();
|
||
const isDark = colorScheme === "dark";
|
||
|
||
const [data, setData] = useState<any>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [sending, setSending] = useState(false);
|
||
|
||
const fetch = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const reqId = Array.isArray(id) ? id[0] : id;
|
||
if (!reqId) return;
|
||
const result = await api.paymentRequests.getById({
|
||
params: { id: reqId },
|
||
});
|
||
setData(result);
|
||
await api.paymentRequests.open({ params: { id: reqId } }).catch(() => {});
|
||
} catch (err: any) {
|
||
toast.error("Error", "Failed to load payment request");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [id]);
|
||
|
||
useFocusEffect(
|
||
useCallback(() => {
|
||
fetch();
|
||
}, [fetch]),
|
||
);
|
||
|
||
const handleSendEmail = async () => {
|
||
try {
|
||
setSending(true);
|
||
const reqId = Array.isArray(id) ? id[0] : id;
|
||
await api.paymentRequests.sendEmail({ params: { id: reqId } });
|
||
toast.success("Sent", "Payment request emailed to customer");
|
||
} catch (err: any) {
|
||
toast.error("Error", err?.message || "Failed to send email");
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
};
|
||
|
||
if (loading || !data) {
|
||
return (
|
||
<ScreenWrapper className="bg-background">
|
||
<StandardHeader title="Payment Request" showBack />
|
||
<View className="flex-1 items-center justify-center">
|
||
<ActivityIndicator size="large" color="#E46212" />
|
||
</View>
|
||
</ScreenWrapper>
|
||
);
|
||
}
|
||
|
||
const statusKey = (data.status || "DRAFT").toUpperCase();
|
||
const theme = STATUS_THEME[statusKey] || STATUS_THEME.DRAFT;
|
||
const items: any[] = data.items || [];
|
||
const accounts: any[] = data.accounts || [];
|
||
const currency = data.currency || "ETB";
|
||
const amount = safeVal(data.amount);
|
||
const subtotal = items.reduce((s: number, i: any) => s + safeVal(i.total), 0);
|
||
const tax = safeVal(data.taxAmount);
|
||
const discount = safeVal(data.discountAmount);
|
||
const total = amount || subtotal + tax - discount;
|
||
|
||
return (
|
||
<ScreenWrapper className="bg-background">
|
||
<Stack.Screen options={{ headerShown: false }} />
|
||
<StandardHeader title="Payment Request" showBack />
|
||
|
||
<ScrollView
|
||
className="flex-1"
|
||
contentContainerStyle={{ paddingBottom: 120 }}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
{/* Customer + Dates cluster */}
|
||
<View className="px-5 mt-6 mb-6">
|
||
<View className="bg-card rounded-[6px] border border-border p-4 gap-4">
|
||
<View className="flex-row items-center gap-3">
|
||
<View className="h-9 w-9 rounded-full bg-primary/10 items-center justify-center">
|
||
<User color="#E46212" size={17} strokeWidth={2} />
|
||
</View>
|
||
<View className="flex-1">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground">
|
||
Customer
|
||
</Text>
|
||
<Text className="text-foreground font-sans-bold text-sm mt-px">
|
||
{data.customerName || "—"}
|
||
</Text>
|
||
{(data.customerEmail || data.customerPhone) && (
|
||
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-0.5">
|
||
{[data.customerEmail, data.customerPhone]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
<View className="border-t border-border" />
|
||
<View className="flex-row gap-4">
|
||
<View className="flex-1">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-1">
|
||
Issued
|
||
</Text>
|
||
<View className="flex-row items-center gap-1.5">
|
||
<Calendar size={12} color="#94a3b8" strokeWidth={2} />
|
||
<Text className="text-foreground font-sans-bold text-sm">
|
||
{data.issueDate
|
||
? new Date(data.issueDate).toLocaleDateString()
|
||
: "—"}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<View className="w-px bg-border" />
|
||
<View className="flex-1">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-1">
|
||
Due
|
||
</Text>
|
||
<View className="flex-row items-center gap-1.5">
|
||
<Clock size={12} color="#94a3b8" strokeWidth={2} />
|
||
<Text className="text-foreground font-sans-bold text-sm">
|
||
{data.dueDate
|
||
? new Date(data.dueDate).toLocaleDateString()
|
||
: "—"}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Description */}
|
||
{data.description ? (
|
||
<View className="px-5 mb-6">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||
Description
|
||
</Text>
|
||
<View className="bg-card rounded-[6px] p-3.5 border border-border">
|
||
<Text className="text-foreground text-sm font-sans-medium leading-5">
|
||
{data.description}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
|
||
{/* Items */}
|
||
{items.length > 0 && (
|
||
<View className="px-5 mb-6">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||
Items ({items.length})
|
||
</Text>
|
||
<View className="bg-card rounded-[6px] border border-border overflow-hidden">
|
||
{items.map((item: any, idx: number) => (
|
||
<View
|
||
key={item.id || idx}
|
||
className={`px-4 py-3 ${idx < items.length - 1 ? "border-b border-border" : ""}`}
|
||
>
|
||
<View className="flex-row justify-between items-start mb-0.5">
|
||
<Text
|
||
className="text-foreground font-sans-bold text-sm flex-1 mr-3"
|
||
numberOfLines={1}
|
||
>
|
||
{item.description || `Item ${idx + 1}`}
|
||
</Text>
|
||
<Text className="text-foreground font-sans-bold text-sm">
|
||
{fmt(safeVal(item.total), currency)}
|
||
</Text>
|
||
</View>
|
||
<Text className="text-muted-foreground text-[11px] font-sans-medium">
|
||
{safeVal(item.quantity)} ×{" "}
|
||
{fmt(safeVal(item.unitPrice), currency)}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* Totals */}
|
||
<View className="px-5 mb-6">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||
Summary
|
||
</Text>
|
||
<View className="bg-card rounded-[6px] p-4 border border-border gap-2.5">
|
||
{subtotal > 0 && (
|
||
<View className="flex-row justify-between">
|
||
<Text className="text-muted-foreground text-sm font-sans-medium">
|
||
Subtotal
|
||
</Text>
|
||
<Text className="text-foreground text-sm font-sans-bold">
|
||
{fmt(subtotal, currency)}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
{tax > 0 && (
|
||
<View className="flex-row justify-between">
|
||
<Text className="text-muted-foreground text-sm font-sans-medium">
|
||
Tax
|
||
</Text>
|
||
<Text className="text-foreground text-sm font-sans-bold">
|
||
{fmt(tax, currency)}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
{discount > 0 && (
|
||
<View className="flex-row justify-between">
|
||
<Text className="text-muted-foreground text-sm font-sans-medium">
|
||
Discount
|
||
</Text>
|
||
<Text className="text-foreground text-sm font-sans-bold">
|
||
-{fmt(discount, currency)}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
<View className="border-t border-border/60 pt-2.5 flex-row justify-between">
|
||
<Text className="text-foreground font-sans-black text-base">
|
||
Total
|
||
</Text>
|
||
<Text className="text-primary font-sans-black text-base">
|
||
{fmt(total, currency)}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Accounts */}
|
||
{accounts.length > 0 && (
|
||
<View className="px-5 mb-6">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||
Bank Accounts ({accounts.length})
|
||
</Text>
|
||
<View className="gap-2">
|
||
{accounts.map((acc: any, idx: number) => (
|
||
<View
|
||
key={acc.id || idx}
|
||
className="bg-card rounded-[6px] p-3.5 border border-border"
|
||
>
|
||
<View className="flex-row items-center gap-2 mb-2">
|
||
<Building2 size={14} color="#E46212" strokeWidth={2} />
|
||
<Text className="text-foreground font-sans-bold text-sm flex-1">
|
||
{acc.bankName || "Bank"}
|
||
</Text>
|
||
<View className="bg-primary/10 px-2 py-0.5 rounded-[4px]">
|
||
<Text className="text-primary text-[10px] font-sans-bold">
|
||
{acc.currency || currency}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<Text className="text-muted-foreground text-xs font-sans-medium">
|
||
{acc.accountName || "—"}
|
||
</Text>
|
||
<View className="flex-row items-center gap-1.5 mt-0.5">
|
||
<Text className="text-foreground text-xs font-sans-bold">
|
||
{acc.accountNumber || "—"}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* Notes */}
|
||
{data.notes ? (
|
||
<View className="px-5 mb-6">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||
Notes
|
||
</Text>
|
||
<View className="bg-card rounded-[6px] p-3.5 border border-border">
|
||
<Text className="text-foreground text-sm font-sans-medium leading-5">
|
||
{data.notes}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
|
||
{/* Actions */}
|
||
<View className="px-5 gap-3">
|
||
<Button
|
||
className="h-10 rounded-[6px] bg-primary"
|
||
onPress={handleSendEmail}
|
||
disabled={sending || !data.customerEmail}
|
||
>
|
||
{sending ? (
|
||
<ActivityIndicator color="#ffffff" size="small" />
|
||
) : (
|
||
<>
|
||
<Send color="#ffffff" size={16} strokeWidth={2.5} />
|
||
<Text className="ml-2 text-white text-[11px] font-sans-bold uppercase tracking-widest">
|
||
Send Email
|
||
</Text>
|
||
</>
|
||
)}
|
||
</Button>
|
||
{!data.customerEmail && (
|
||
<Text className="text-[11px] text-muted-foreground font-sans-medium text-center">
|
||
No customer email on file
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</ScrollView>
|
||
</ScreenWrapper>
|
||
);
|
||
}
|