Yaltopia-Tickets-App/app/invoices/[id].tsx
2026-05-14 22:29:28 +03:00

411 lines
15 KiB
TypeScript

import React, { useState, useEffect } from "react";
import {
View,
ScrollView,
ActivityIndicator,
Alert,
Linking,
useColorScheme,
Pressable,
Platform,
PermissionsAndroid,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
FileText,
Calendar,
Share2,
Download,
Trash2,
Package,
Clock,
ExternalLink,
ChevronRight,
User,
CreditCard,
Hash,
AlertCircle,
MessageSquare,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api, BASE_URL } from "@/lib/api";
import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store";
// Android only SMS module
let SmsAndroid: any = null;
if (Platform.OS === "android") {
try {
const smsModule = require("react-native-get-sms-android");
SmsAndroid = smsModule.default || smsModule;
} catch (e) {
console.log("[InvoiceDetail] SMS module unavailable");
}
}
export default function InvoiceDetailScreen() {
const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [loading, setLoading] = useState(true);
const [invoice, setInvoice] = useState<any>(null);
const [scanningSms, setScanningSms] = useState(false);
useEffect(() => {
fetchInvoice();
}, [id]);
const fetchInvoice = async () => {
try {
setLoading(true);
const invoiceId = Array.isArray(id) ? id[0] : id;
if (!invoiceId) throw new Error("No ID provided");
const data = await api.invoices.getById({ params: { id: invoiceId } });
setInvoice(data);
} catch (error: any) {
console.error("[InvoiceDetail] Error:", error);
toast.error("Error", "Failed to load invoice details");
} finally {
setLoading(false);
}
};
const handleScanSms = async () => {
if (Platform.OS !== "android") {
toast.error("Not Supported", "SMS scanning is only available on Android.");
return;
}
setScanningSms(true);
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_SMS
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
toast.error("Permission Denied", "We need SMS access to verify payments.");
setScanningSms(false);
return;
}
toast.info("Scanning SMS", "Searching for bank messages from the last 30 minutes...");
// Simulate logic if native module is missing (Expo Go)
if (!SmsAndroid) {
setTimeout(() => {
toast.error("No Match", "No matching banking SMS found in the last 30 minutes.");
setScanningSms(false);
}, 2000);
return;
}
const thirtyMinsAgo = Date.now() - 30 * 60 * 1000;
const filter = {
box: "inbox",
minDate: thirtyMinsAgo,
maxCount: 20,
};
SmsAndroid.list(
JSON.stringify(filter),
(fail: string) => {
toast.error("Scan Failed", fail);
setScanningSms(false);
},
(count: number, smsList: string) => {
const messages = JSON.parse(smsList);
const amountStr = amountValue.toString();
const custName = (invoice.customerName || "").toUpperCase();
// Search for amount or customer name in SMS body
const match = messages.find((m: any) => {
const body = m.body.toUpperCase();
return body.includes(amountStr) || (custName && body.includes(custName));
});
if (match) {
Alert.alert(
"Payment Found!",
`We found a matching SMS proof for ${amountValue} ${invoice.currency}. Would you like to attach this to the invoice?`,
[
{ text: "No", style: "cancel" },
{
text: "Attach SMS",
onPress: () => toast.success("Attached", "SMS proof linked to invoice successfully.")
}
]
);
} else {
toast.error("No Match", "Could not find any matching banking SMS in the last 30 minutes.");
}
setScanningSms(false);
}
);
} catch (err) {
toast.error("Error", "Something went wrong during SMS scan.");
setScanningSms(false);
}
};
const handleGetPdf = async () => {
try {
const { token } = useAuthStore.getState();
const pdfUrl = `${BASE_URL}invoices/${id}/pdf?token=${token}`;
await Linking.openURL(pdfUrl);
} catch (error) {
console.error("[InvoiceDetail] PDF Error:", error);
toast.error("Error", "Failed to open PDF");
}
};
const handleDelete = async () => {
Alert.alert(
"Delete Invoice",
"Are you sure you want to delete this invoice? This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
setLoading(true);
const invoiceId = Array.isArray(id) ? id[0] : id;
await api.invoices.delete({
params: { id: invoiceId as string },
});
toast.success("Success", "Invoice deleted successfully");
nav.back();
} catch (error) {
console.error("[InvoiceDetail] Delete Error:", error);
toast.error("Error", "Failed to delete invoice");
setLoading(false);
}
},
},
],
);
};
if (loading) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice" showBack />
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" />
</View>
</ScreenWrapper>
);
}
if (!invoice) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice" showBack />
<View className="flex-1 justify-center items-center">
<AlertCircle size={48} color="#ef4444" className="mb-4" />
<Text variant="h4" className="mb-1">
Invoice Not Found
</Text>
<Text variant="muted">
The requested document could not be retrieved.
</Text>
</View>
</ScreenWrapper>
);
}
// Robust data extraction
const originalData = invoice.scannedData?.originalData || {};
const items = (invoice.items?.length > 0 ? invoice.items : originalData.items) || [];
const taxAmountValue = Number(
typeof invoice.taxAmount === "object"
? invoice.taxAmount?.value
: invoice.taxAmount || originalData.taxAmount || 0,
);
const discountValue = Number(
typeof invoice.discountAmount === "object"
? invoice.discountAmount?.value
: invoice.discountAmount || originalData.discountAmount || 0,
);
let amountValue = Number(
typeof invoice.amount === "object" ? invoice.amount.value : invoice.amount,
);
if (items.length > 0) {
const itemsTotal = items.reduce(
(acc: number, item: any) => acc + (Number(item.total?.value || item.total) || 0),
0,
);
if (itemsTotal > 0 && (amountValue === taxAmountValue || amountValue < itemsTotal)) {
amountValue = itemsTotal + taxAmountValue - discountValue;
}
}
const subtotalValue = amountValue - taxAmountValue + discountValue;
const statusColors = {
PAID: { bg: "bg-emerald-500/10", text: "text-emerald-500", dot: "bg-emerald-500" },
PENDING: { bg: "bg-amber-500/10", text: "text-amber-500", dot: "bg-amber-500" },
DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" },
DEFAULT: { bg: "bg-slate-500/10", text: "text-slate-500", dot: "bg-slate-500" },
};
const status = (invoice.status || "PENDING").toUpperCase();
const colors = statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title="Invoice Details"
showBack
rightAction="edit"
onRightActionPress={() => nav.go("invoices/edit", { id: invoice.id })}
/>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
>
<View className="px-5 pt-4">
<View className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}>
<View className={`w-2 h-2 rounded-full ${colors.dot}`} />
<Text className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}>
{status}
</Text>
</View>
<Text variant="muted" className="text-xs font-bold uppercase tracking-wider mb-1">
Total Amount
</Text>
<View className="flex-row items-end gap-2 mb-6">
<Text variant="h1" className="text-4xl font-black text-foreground">
{Number(amountValue).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Text>
<Text className="text-xl font-bold text-primary mb-2">
{invoice.currency || "ETB"}
</Text>
</View>
<View className="flex-row gap-3 mb-6">
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Calendar size={16} color="#ea580c" className="mb-2" />
<Text variant="muted" className="text-[10px] uppercase font-bold tracking-tighter mb-0.5">
Date
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(invoice.issueDate || invoice.createdAt).toLocaleDateString()}
</Text>
</View>
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Clock size={16} color="#ef4444" className="mb-2" />
<Text variant="muted" className="text-[10px] uppercase font-bold tracking-tighter mb-0.5">
Due
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(invoice.dueDate).toLocaleDateString()}
</Text>
</View>
</View>
</View>
<View className="px-5 mb-6">
<View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10">
<View className="flex-row items-center gap-3 mb-4">
<View className="h-10 w-10 rounded-full bg-primary/20 items-center justify-center">
<User color="#ea580c" size={20} />
</View>
<View>
<Text variant="muted" className="text-[10px] uppercase font-bold">
Client
</Text>
<Text variant="p" className="text-foreground font-bold text-lg">
{invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"}
</Text>
</View>
</View>
</View>
</View>
<View className="px-5 mb-6">
<Text variant="h4" className="font-bold mb-4 px-1">
Items
</Text>
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
{items.map((item: any, idx: number) => (
<View key={idx} className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}>
<View className="flex-row justify-between items-start mb-1">
<Text className="text-foreground font-bold flex-1 mr-4">{item.description}</Text>
<Text className="text-foreground font-black">
{Number(item.total?.value || item.total || 0).toLocaleString()}
</Text>
</View>
<Text className="text-muted-foreground text-xs">
{item.quantity} x {Number(item.unitPrice?.value || item.unitPrice || 0).toLocaleString()} {invoice.currency}
</Text>
</View>
))}
</Card>
</View>
<View className="px-5 mb-6">
<Card className="bg-card rounded-[6px] p-5 border-border/60">
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">Subtotal</Text>
<Text className="text-foreground font-bold">{subtotalValue.toLocaleString()} {invoice.currency}</Text>
</View>
<View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center">
<Text className="text-foreground font-black text-xl">Grand Total</Text>
<Text className="text-primary font-black text-2xl">{amountValue.toLocaleString()} {invoice.currency}</Text>
</View>
</Card>
</View>
<View className="px-5 gap-3">
<View className="flex-row gap-3">
<Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
disabled={scanningSms}
onPress={handleScanSms}
>
{scanningSms ? (
<ActivityIndicator color="white" />
) : (
<>
<MessageSquare color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
Scan SMS
</Text>
</>
)}
</Button>
<Button variant="outline" className="flex-1 h-14 rounded-[6px] bg-card border border-border" onPress={handleGetPdf}>
<Download color={isDark ? "#f1f5f9" : "#0f172a"} size={18} strokeWidth={2.5} />
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">PDF</Text>
</Button>
</View>
<Button variant="ghost" className="h-14 rounded-[6px] border border-rose-500/10" onPress={handleDelete}>
<Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">Delete</Text>
</Button>
</View>
</ScrollView>
</ScreenWrapper>
);
}