Yaltopia-Tickets-App/app/payment-requests/[id].tsx
2026-06-17 15:16:40 +03:00

521 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback } from "react";
import { View, ScrollView, ActivityIndicator, Pressable, TextInput, Modal } 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, Send, Pencil, ChevronRight } 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";
function useInputColors() {
const { colorScheme } = useColorScheme();
const dark = colorScheme === "dark";
return {
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
text: dark ? "#f1f5f9" : "#0f172a",
placeholder: "rgba(100,116,139,0.45)",
};
}
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 c = useInputColors();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [showSendModal, setShowSendModal] = useState(false);
const [sendChannel, setSendChannel] = useState("EMAIL");
const [sendRecipient, setSendRecipient] = useState("");
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 openSendModal = () => {
setSendChannel("EMAIL");
setSendRecipient(data?.customerEmail || "");
setShowSendModal(true);
};
const handleSend = async () => {
if (!sendRecipient.trim()) {
toast.error("Validation", "Recipient is required");
return;
}
try {
setSending(true);
const reqId = Array.isArray(id) ? id[0] : id;
await api.paymentRequests.send({
params: { id: reqId },
body: {
channel: sendChannel,
recipient: sendRecipient.trim(),
},
headers: { "Content-Type": "application/json" },
});
toast.success("Sent", `Payment request sent via ${sendChannel.toLowerCase()}`);
setShowSendModal(false);
fetch();
} catch (err: any) {
toast.error("Error", err?.message || "Failed to send payment request");
} 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}
>
{/* Status Badge */}
<View className="px-5 mt-6 mb-4">
<View className={`self-start px-3 py-1 rounded-[6px] ${theme.bg}`}>
<View className="flex-row items-center gap-1.5">
<View className={`w-2 h-2 rounded-full ${theme.dot}`} />
<Text className={`text-[11px] font-sans-bold uppercase tracking-wider ${theme.text}`}>
{theme.label}
</Text>
</View>
</View>
</View>
{/* Customer + Dates cluster */}
<View className="px-5 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-11 rounded-[6px] bg-primary"
onPress={openSendModal}
disabled={sending}
>
<Send color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white text-[11px] font-sans-bold uppercase tracking-widest">
Send
</Text>
</Button>
<Button
className="h-11 rounded-[6px] bg-secondary"
variant="outline"
onPress={() => {
const reqId = Array.isArray(id) ? id[0] : id;
nav.go("payment-requests/edit", { id: reqId });
}}
>
<Pencil color={isDark ? "#f1f5f9" : "#0f172a"} size={16} strokeWidth={2} />
<Text className="ml-2 text-foreground text-[11px] font-sans-bold uppercase tracking-widest">
Edit
</Text>
</Button>
</View>
</ScrollView>
{/* Send Modal */}
<Modal
visible={showSendModal}
transparent
animationType="slide"
onRequestClose={() => setShowSendModal(false)}
>
<Pressable
className="flex-1 bg-black/40"
onPress={() => setShowSendModal(false)}
>
<View className="flex-1 justify-end">
<Pressable
className="bg-card rounded-t-[36px] border-t border-border p-6 gap-5"
onPress={(e) => e.stopPropagation()}
>
<View className="flex-row justify-between items-center">
<Text className="text-[18px] font-sans-bold text-foreground">
Send Payment Request
</Text>
<Pressable
onPress={() => setShowSendModal(false)}
className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center"
>
<Text className="text-foreground text-xs font-sans-bold"></Text>
</Pressable>
</View>
{/* Channel Toggle */}
<View className="flex-row gap-2">
{(["EMAIL", "PHONE"] as const).map((ch) => (
<Pressable
key={ch}
onPress={() => {
setSendChannel(ch);
if (ch === "EMAIL") {
setSendRecipient(data?.customerEmail || "");
} else {
setSendRecipient(data?.customerPhone || "");
}
}}
className={`flex-1 h-11 rounded-[6px] items-center justify-center border ${
sendChannel === ch
? "bg-primary border-primary"
: "bg-card border-border"
}`}
>
<Text
className={`text-xs font-sans-bold ${
sendChannel === ch ? "text-white" : "text-foreground"
}`}
>
{ch === "EMAIL" ? "Email" : "SMS"}
</Text>
</Pressable>
))}
</View>
{/* Recipient Input */}
<View>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
{sendChannel === "EMAIL" ? "Email Address" : "Phone Number"}
</Text>
<TextInput
className="h-12 px-3 rounded-[6px] border text-foreground text-sm font-sans-medium"
style={{ backgroundColor: c.bg, borderColor: c.border, color: c.text }}
placeholder={
sendChannel === "EMAIL" ? "email@example.com" : "912345678"
}
placeholderTextColor={c.placeholder}
value={sendRecipient}
onChangeText={setSendRecipient}
keyboardType={sendChannel === "EMAIL" ? "email-address" : "phone-pad"}
autoCapitalize="none"
/>
</View>
<Button
className="h-12 rounded-[6px] bg-primary"
onPress={handleSend}
disabled={sending || !sendRecipient.trim()}
>
{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 {sendChannel === "EMAIL" ? "Email" : "SMS"}
</Text>
</>
)}
</Button>
</Pressable>
</View>
</Pressable>
</Modal>
</ScreenWrapper>
);
}