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

897 lines
31 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,
Linking,
useColorScheme,
Pressable,
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 {
Calendar,
Share2,
Download,
Trash2,
Package,
Clock,
User,
Hash,
AlertCircle,
Mail,
MessageSquare,
X,
MoreVertical,
FileText,
Receipt,
CreditCard,
ChevronRight,
Check,
Edit,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
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";
import { UploadIcon } from "lucide-react-native";
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
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 [showDeleteModal, setShowDeleteModal] = useState(false);
const [showShareSheet, setShowShareSheet] = useState(false);
const [showMoreSheet, setShowMoreSheet] = useState(false);
const [sharing, setSharing] = useState(false);
const [activeTab, setActiveTab] = useState<"details" | "activity">("details");
useFocusEffect(
useCallback(() => {
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 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 handleShare = async (channel: "email" | "sms") => {
try {
setSharing(true);
const invoiceId = Array.isArray(id) ? id[0] : id;
await api.invoices.shareLink({
body: { invoiceId, channel },
});
toast.success(
"Sent",
`Invoice shared via ${channel === "email" ? "email" : "SMS"}`,
);
setShowShareSheet(false);
} catch (err: any) {
toast.error("Error", err?.message || "Failed to share invoice");
} finally {
setSharing(false);
}
};
const handleDelete = () => 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 discountPercent =
subtotalValue > 0 ? Math.round((discountValue / subtotalValue) * 100) : 0;
const status = (invoice.status || "PENDING").toUpperCase();
const isPaid = status === "PAID";
const statusLabel: Record<string, string> = {
PAID: "Paid",
PENDING: "Pending",
DRAFT: "Draft",
CANCELLED: "Cancelled",
};
const customerName = (
invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"
).trim();
const paymentDate = invoice.dueDate
? new Date(invoice.dueDate)
: invoice.issueDate
? new Date(invoice.issueDate)
: new Date(invoice.createdAt);
const formatLongDate = (d: Date) =>
d.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
});
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title="Invoice Details"
showBack
right={
<Pressable
onPress={() => setShowMoreSheet(true)}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<MoreVertical color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
</Pressable>
}
/>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 24 }}
showsVerticalScrollIndicator={false}
>
{/* Hero Card — illustration overflows the top */}
<View className="px-5 pt-3">
<View
className="items-center"
style={{ marginTop: 8, marginBottom: -40, zIndex: 2 }}
>
<View style={{ width: 110, height: 92 }}>
<View
style={{
position: "absolute",
top: 6,
left: 22,
width: 72,
height: 84,
borderRadius: 10,
backgroundColor: "#E46212",
transform: [{ rotate: "8deg" }],
opacity: 0.92,
}}
/>
<View
style={{
position: "absolute",
top: 2,
left: 8,
width: 72,
height: 84,
borderRadius: 10,
backgroundColor: "#0f172a",
transform: [{ rotate: "-6deg" }],
opacity: 0.95,
}}
/>
<View
style={{
position: "absolute",
top: 4,
left: 22,
width: 72,
height: 84,
borderRadius: 10,
backgroundColor: isDark ? "#1F1F1F" : "#ffffff",
borderWidth: 1,
borderColor: isDark ? "rgba(255,255,255,0.08)" : "#EDD5D1",
alignItems: "center",
paddingTop: 12,
}}
>
<Receipt
size={22}
color={isDark ? "#f1f5f9" : "#251615"}
strokeWidth={1.6}
/>
<View
style={{
marginTop: 8,
width: 36,
height: 3,
borderRadius: 2,
backgroundColor: isDark
? "rgba(255,255,255,0.18)"
: "rgba(0,0,0,0.08)",
}}
/>
<View
style={{
marginTop: 4,
width: 22,
height: 3,
borderRadius: 2,
backgroundColor: isDark
? "rgba(255,255,255,0.12)"
: "rgba(0,0,0,0.06)",
}}
/>
<View
style={{
position: "absolute",
bottom: 10,
right: -6,
width: 22,
height: 26,
borderRadius: 5,
backgroundColor: "#E46212",
alignItems: "center",
justifyContent: "center",
}}
>
<Text className="text-white font-sans-black text-[12px]">
$
</Text>
</View>
</View>
</View>
</View>
<View
className="rounded-[14px] pt-14 pb-6 px-6 items-center bg-primary/5"
style={{
borderWidth: 1,
borderColor: isDark
? "rgba(255,255,255,0.06)"
: "rgba(0,0,0,0.05)",
}}
>
<Text className="text-foreground text-[34px] font-sans-black tracking-tight leading-tight">
{Number(amountValue).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
<Text className="text-foreground text-[20px] font-sans-bold">
{invoice.currency || "USD"}
</Text>
</Text>
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-1">
No.{" "}
{invoice.invoiceNumber ||
`INV${(invoice.id || "").slice(0, 8).toUpperCase()}`}
</Text>
<View className="w-full mt-5 gap-3">
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[12px] font-sans-medium">
Status
</Text>
<View
className="px-2.5 py-1 rounded-[4px] flex-row items-center gap-1.5"
style={{
backgroundColor: isPaid
? "rgba(34, 197, 94, 0.12)"
: "rgba(234, 179, 8, 0.12)",
}}
>
{isPaid ? (
<Check size={11} color="#16a34a" strokeWidth={3} />
) : (
<Clock size={11} color="#ca8a04" strokeWidth={2.5} />
)}
<Text
className="text-[10px] font-sans-bold tracking-widest"
style={{
color: isPaid ? "#16a34a" : "#ca8a04",
}}
>
{statusLabel[status] || "Pending"}
</Text>
</View>
</View>
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[12px] font-sans-medium">
{isPaid ? "Payment Date" : "Due Date"}
</Text>
<Text className="text-foreground text-[12px] font-sans-bold">
{isPaid
? `Paid at ${formatLongDate(paymentDate)}`
: formatLongDate(paymentDate)}
</Text>
</View>
</View>
</View>
</View>
{/* Tabs */}
<View className="px-5 pt-5">
<View className="flex-row gap-6 border-b border-border">
<Pressable
onPress={() => setActiveTab("details")}
className="pb-2.5"
>
<Text
className={`text-[14px] font-sans-bold ${
activeTab === "details"
? "text-foreground"
: "text-muted-foreground"
}`}
>
Details
</Text>
{activeTab === "details" && (
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
)}
</Pressable>
<Pressable
onPress={() => setActiveTab("activity")}
className="pb-2.5"
>
<Text
className={`text-[14px] font-sans-bold ${
activeTab === "activity"
? "text-foreground"
: "text-muted-foreground"
}`}
>
Activity Log
</Text>
{activeTab === "activity" && (
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
)}
</Pressable>
</View>
</View>
{activeTab === "details" ? (
<View className="px-5 pt-5">
<View className="gap-5">
<View>
<Text className="text-[11px] font-sans-bold tracking-widest text-muted-foreground mb-1.5">
Billed to
</Text>
<Text className="text-foreground text-[14px] font-sans-bold">
{invoice.customerEmail || "—"}
</Text>
</View>
<View>
<Text className="text-[11px] font-sans-bold tracking-widest text-muted-foreground mb-1.5">
Billing details
</Text>
<Text className="text-foreground text-[14px] font-sans-bold">
{customerName}
</Text>
</View>
<View>
<Text className="text-[11px] font-sans-bold tracking-widest text-muted-foreground mb-1.5">
Invoice Number
</Text>
<Text className="text-foreground text-[14px] font-sans-bold">
{invoice.invoiceNumber ||
`INV${(invoice.id || "").slice(0, 8).toUpperCase()}`}
</Text>
</View>
<View>
<Text className="text-[11px] font-sans-bold tracking-widest text-muted-foreground mb-1.5">
Notes
</Text>
<Text
className="text-foreground text-[14px] font-sans-bold"
numberOfLines={1}
>
{invoice.notes || "-"}
</Text>
</View>
</View>
{items.length > 0 ? (
<View className="mt-8">
<Text className="text-foreground text-[16px] font-sans-black tracking-tight mb-3">
Product
</Text>
<View>
{items.map((item: any, idx: number) => {
const qty = Number(
item.quantity?.value || item.quantity || 1,
);
const unitPrice = Number(
item.unitPrice?.value || item.unitPrice || 0,
);
const lineTotal = Number(
item.total?.value || item.total || qty * unitPrice,
);
return (
<View
key={idx}
className={`flex-row items-center gap-3 py-3 ${
idx < items.length - 1 ? "border-b border-border" : ""
}`}
>
<View className="h-12 w-12 rounded-[8px] bg-muted items-center justify-center overflow-hidden">
<Package
size={20}
color="#94a3b8"
strokeWidth={1.5}
/>
</View>
<View className="flex-1">
<Text
className="text-foreground text-[14px] font-sans-bold"
numberOfLines={1}
>
{item.description || `Item ${idx + 1}`}
</Text>
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
{qty} ×{" "}
{unitPrice.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{invoice.currency || "USD"}
</Text>
</View>
<View className="items-end">
<Text className="text-foreground text-[14px] font-sans-bold">
{lineTotal.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{invoice.currency || "USD"}
</Text>
</View>
<Pressable hitSlop={8} className="px-1">
<MoreVertical size={16} color="#94a3b8" />
</Pressable>
</View>
);
})}
</View>
</View>
) : null}
<View className="mt-6 gap-2.5">
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[14px] font-sans-medium">
Subtotal
</Text>
<Text className="text-foreground text-[14px] font-sans-bold">
{subtotalValue.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{invoice.currency || "USD"}
</Text>
</View>
{discountValue > 0 ? (
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[14px] font-sans-medium">
Discount {discountPercent > 0 ? `${discountPercent}%` : ""}
</Text>
<Text className="text-foreground text-[14px] font-sans-bold">
-{" "}
{discountValue.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{invoice.currency || "USD"}
</Text>
</View>
) : null}
{taxAmountValue > 0 ? (
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[14px] font-sans-medium">
Tax
</Text>
<Text className="text-foreground text-[14px] font-sans-bold">
+{" "}
{taxAmountValue.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{invoice.currency || "USD"}
</Text>
</View>
) : null}
<View className="flex-row justify-between items-center pt-2 border-t border-border">
<Text className="text-foreground text-[15px] font-sans-black">
Total
</Text>
<Text className="text-foreground text-[15px] font-sans-black">
{amountValue.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{invoice.currency || "USD"}
</Text>
</View>
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[13px] font-sans-medium">
Amount due
</Text>
<Text className="text-foreground text-[15px] font-sans-black">
{amountValue.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{invoice.currency || "USD"}
</Text>
</View>
</View>
</View>
) : (
<View className="px-5 pt-5">
{items.length > 0 ? (
<View>
<Text className="text-foreground text-[16px] font-sans-black tracking-tight mb-3">
Items
</Text>
<View>
{items.map((item: any, idx: number) => {
const qty = Number(
item.quantity?.value || item.quantity || 1,
);
const unitPrice = Number(
item.unitPrice?.value || item.unitPrice || 0,
);
const lineTotal = Number(
item.total?.value || item.total || qty * unitPrice,
);
return (
<View
key={idx}
className={`flex-row items-center gap-3 py-3 ${
idx < items.length - 1 ? "border-b border-border" : ""
}`}
>
<View className="h-12 w-12 rounded-[8px] bg-muted items-center justify-center overflow-hidden">
<Package
size={20}
color="#94a3b8"
strokeWidth={1.5}
/>
</View>
<View className="flex-1">
<Text
className="text-foreground text-[14px] font-sans-bold"
numberOfLines={1}
>
{item.description || `Item ${idx + 1}`}
</Text>
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
{qty} ×{" "}
{unitPrice.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{invoice.currency || "USD"}
</Text>
</View>
<Text className="text-foreground text-[14px] font-sans-bold">
{lineTotal.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{invoice.currency || "USD"}
</Text>
</View>
);
})}
</View>
</View>
) : (
<View className="px-5 pt-12 items-center">
<View className="h-14 w-14 rounded-full bg-muted items-center justify-center mb-3">
<Clock size={22} color="#94a3b8" />
</View>
<Text className="text-foreground text-[14px] font-sans-bold mb-1">
No activity yet
</Text>
<Text className="text-muted-foreground text-[12px] font-sans-medium text-center">
Events related to this invoice will show up here.
</Text>
</View>
)}
</View>
)}
</ScrollView>
<View
className="flex-row gap-3 px-5 py-3 border-t border-border"
style={{ backgroundColor: isDark ? "#0a0505" : "#ffffff" }}
>
<Pressable
onPress={() => setShowShareSheet(true)}
className="flex-1 h-12 rounded-[8px] border border-border items-center justify-center flex-row gap-2 bg-card"
>
<Share2 color="#0f172a" size={16} strokeWidth={2.5} />
<Text className="text-foreground text-[13px] font-sans-bold">
Send Invoice
</Text>
</Pressable>
<Pressable
onPress={handleGetPdf}
className="flex-1 h-12 rounded-[8px] border border-border items-center justify-center flex-row gap-2 bg-card"
>
<UploadIcon color="#0f172a" size={16} strokeWidth={2.5} />
<Text className="text-foreground text-[13px] font-sans-bold">
Export
</Text>
</Pressable>
</View>
<Modal
visible={showShareSheet}
transparent
animationType="slide"
onRequestClose={() => setShowShareSheet(false)}
>
<Pressable
className="flex-1 bg-black/40"
onPress={() => setShowShareSheet(false)}
>
<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()}
>
<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]">
Share
</Text>
<Pressable
onPress={() => setShowShareSheet(false)}
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>
<ScrollView
className="px-5"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 40 }}
>
<ActionOption
icon={<Mail color="#E46212" size={18} strokeWidth={2} />}
label="Send as Email"
description="Public accessible shortened link via yaltopia.com"
onPress={() => handleShare("email")}
loading={sharing}
/>
<ActionOption
icon={
<MessageSquare color="#E46212" size={18} strokeWidth={2} />
}
label="Send as SMS"
description="Public accessible shortened link via yaltopia.com"
onPress={() => handleShare("sms")}
loading={sharing}
/>
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
<Modal
visible={showMoreSheet}
transparent
animationType="slide"
onRequestClose={() => setShowMoreSheet(false)}
>
<Pressable
className="flex-1 bg-black/40"
onPress={() => setShowMoreSheet(false)}
>
<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.5 }}
onPress={(e) => e.stopPropagation()}
>
<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]">
Invoice
</Text>
<Pressable
onPress={() => setShowMoreSheet(false)}
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>
<ScrollView
className="px-5"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 40 }}
>
<ActionOption
icon={<Edit color="#E46212" size={18} strokeWidth={2} />}
label="Edit Invoice"
description="Update details, items, or dates"
onPress={() => {
setShowMoreSheet(false);
nav.go("invoices/edit", { id: invoice.id });
}}
/>
<ActionOption
icon={<Trash2 color="#ef4444" size={18} strokeWidth={2} />}
label="Delete Invoice"
description="Permanently remove this record"
onPress={() => {
setShowMoreSheet(false);
setShowDeleteModal(true);
}}
danger
/>
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
<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>
);
}
function ActionOption({
icon,
label,
description,
onPress,
danger,
loading,
}: {
icon: React.ReactNode;
label: string;
description: string;
onPress?: () => void;
danger?: boolean;
loading?: boolean;
}) {
return (
<Pressable
onPress={onPress}
disabled={loading}
className="flex-row items-center gap-3.5 p-4 mb-2 rounded-[6px] border border-border bg-card"
>
<View className="h-9 w-9 rounded-full bg-primary/10 items-center justify-center">
{loading ? <ActivityIndicator color="#E46212" size="small" /> : icon}
</View>
<View className="flex-1">
<Text
className={`font-sans-bold text-sm ${danger ? "text-red-500" : "text-foreground"}`}
>
{label}
</Text>
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-px">
{description}
</Text>
</View>
</Pressable>
);
}