512 lines
16 KiB
TypeScript
512 lines
16 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";
|
|
import { ActionModal } from "@/components/ActionModal";
|
|
|
|
// 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);
|
|
const [showDeleteModal, setShowDeleteModal] = 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 () => {
|
|
setShowDeleteModal(true);
|
|
};
|
|
|
|
const confirmDelete = 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");
|
|
setShowDeleteModal(false);
|
|
nav.back();
|
|
} catch (error) {
|
|
console.error("[InvoiceDetail] Delete Error:", error);
|
|
toast.error("Error", "Failed to delete invoice");
|
|
setShowDeleteModal(false);
|
|
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">
|
|
<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-3 border border-primary/10">
|
|
<View className="flex-row items-center gap-3 ">
|
|
<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-regular text-base"
|
|
>
|
|
{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>
|
|
{items.length === 0 ? (
|
|
<View className="flex-1 justify-center items-center">
|
|
<Text variant="muted">No items found</Text>
|
|
</View>
|
|
) : (
|
|
<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-3 border-border/60">
|
|
<View className="flex-row justify-between mb-1">
|
|
<Text className="text-muted-foreground font-medium">
|
|
Subtotal
|
|
</Text>
|
|
<Text className="text-foreground font-bold">
|
|
{subtotalValue.toLocaleString()} {invoice.currency}
|
|
</Text>
|
|
</View>
|
|
<View className="pt-2 border-t border-dashed border-border flex-row justify-between items-center">
|
|
<Text className="text-foreground font-black text-lg">
|
|
Grand Total
|
|
</Text>
|
|
<Text className="text-primary font-black text-lg">
|
|
{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-10 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-10 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-10 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>
|
|
|
|
<ActionModal
|
|
visible={showDeleteModal}
|
|
onClose={() => setShowDeleteModal(false)}
|
|
onConfirm={confirmDelete}
|
|
title="Delete Invoice"
|
|
description="Are you sure you want to delete this invoice? This will remove all associated data and cannot be recovered."
|
|
confirmText="Delete"
|
|
confirmVariant="destructive"
|
|
icon={Trash2}
|
|
iconColor="#ef4444"
|
|
loading={loading}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|