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

762 lines
27 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,
Pressable,
Modal,
Dimensions,
StyleSheet,
Image,
} 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 { EmptyState } from "@/components/EmptyState";
import {
FileText,
Calendar,
Clock,
User,
Hash,
AlertCircle,
Edit,
Mail,
MessageSquare,
MoreVertical,
X,
Package,
Share2,
Download,
TrendingUp,
TrendingDown,
Check,
Trash2,
} 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 { useColorScheme } from "nativewind";
import { ActionModal } from "@/components/ActionModal";
import { SendHorizonal } from "lucide-react-native";
import ticketImage from "@/assets/ticket.png";
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
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 })}`;
}
const STATUS_THEME: Record<
string,
{ label: string; bg: string; text: string; dot: string }
> = {
PAID: {
label: "Paid",
bg: "bg-emerald-500/10",
text: "text-emerald-600",
dot: "bg-emerald-500",
},
PENDING: {
label: "Pending",
bg: "bg-amber-500/10",
text: "text-amber-600",
dot: "bg-amber-500",
},
DRAFT: {
label: "Draft",
bg: "bg-blue-500/10",
text: "text-blue-600",
dot: "bg-blue-500",
},
CANCELLED: {
label: "Cancelled",
bg: "bg-slate-500/10",
text: "text-slate-600",
dot: "bg-slate-500",
},
DEFAULT: {
label: "Unknown",
bg: "bg-slate-500/10",
text: "text-slate-500",
dot: "bg-slate-500",
},
};
export default function ProformaDetailScreen() {
const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [loading, setLoading] = useState(true);
const [proforma, setProforma] = useState<any>(null);
const [activeTab, setActiveTab] = useState<"details" | "items">("details");
const [showMoreSheet, setShowMoreSheet] = useState(false);
const [showSendSheet, setShowSendSheet] = useState(false);
const [sharing, setSharing] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
useFocusEffect(
useCallback(() => {
fetchProforma();
}, [id]),
);
const fetchProforma = async () => {
try {
setLoading(true);
const pid = Array.isArray(id) ? id[0] : id;
if (!pid) return;
const data = await api.proforma.getById({ params: { id: pid } });
setProforma(data);
} catch {
toast.error("Error", "Failed to load proforma details");
} finally {
setLoading(false);
}
};
const handleGetPdf = async () => {
try {
const { token } = useAuthStore.getState();
const pid = Array.isArray(id) ? id[0] : id;
await Linking.openURL(`${BASE_URL}proforma/${pid}/pdf?token=${token}`);
} catch {
toast.error("Error", "Failed to open PDF");
}
};
const handleShare = async (channel: "email" | "sms") => {
try {
setSharing(true);
const pid = Array.isArray(id) ? id[0] : id;
await api.proforma.shareLink({ body: { proformaId: pid, channel } });
toast.success(
"Sent",
`Proforma shared via ${channel === "email" ? "email" : "SMS"}`,
);
setShowSendSheet(false);
} catch (err: any) {
toast.error("Error", err?.message || "Failed to share proforma");
} finally {
setSharing(false);
}
};
const handleDelete = () => setShowDeleteModal(true);
const confirmDelete = async () => {
try {
setDeleting(true);
const pid = Array.isArray(id) ? id[0] : id;
await api.proforma.delete({ params: { id: pid } });
toast.success("Deleted", "Proforma has been removed.");
setShowDeleteModal(false);
nav.back();
} catch (err: any) {
toast.error("Error", err?.message || "Failed to delete proforma");
} finally {
setDeleting(false);
}
};
if (loading) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Proforma" showBack />
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#E46212" size="large" />
</View>
</ScreenWrapper>
);
}
if (!proforma) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Proforma" showBack />
<View className="flex-1 items-center justify-center px-8">
<AlertCircle size={48} color="#ef4444" className="mb-4" />
<Text className="text-muted-foreground text-center font-sans-medium">
Proforma not found.
</Text>
</View>
</ScreenWrapper>
);
}
const currency = proforma.currency || "ETB";
const amount = safeVal(proforma.amount);
const tax = safeVal(proforma.taxAmount);
const discount = safeVal(proforma.discountAmount);
const subtotal = amount + discount - tax;
const items: any[] = proforma.items || [];
const statusKey = (proforma.status || "DRAFT").toUpperCase();
const theme = STATUS_THEME[statusKey] || STATUS_THEME.DEFAULT;
const issueDate = proforma.issueDate ? new Date(proforma.issueDate) : null;
const dueDate = proforma.dueDate ? new Date(proforma.dueDate) : null;
const formatLongDate = (d: Date) =>
d.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
});
const customerName = (
proforma.customerName?.replace("Customer Name: ", "") || "Walking Client"
).trim();
const ActionOption = ({
icon,
label,
description,
onPress,
destructive,
}: {
icon: React.ReactNode;
label: string;
description: string;
onPress?: () => void;
destructive?: boolean;
}) => (
<Pressable
onPress={onPress}
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">
{icon}
</View>
<View className="flex-1">
<Text
className={`text-[14px] font-sans-bold ${
destructive ? "text-red-500" : "text-foreground"
}`}
>
{label}
</Text>
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
{description}
</Text>
</View>
</Pressable>
);
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title="Proforma 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={{ marginBottom: -60, zIndex: 2 }}
>
<Image
source={ticketImage}
style={{ width: 150, height: 150 }}
resizeMode="contain"
/>
</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">
{amount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
<Text className="text-foreground text-[20px] font-sans-bold">
{currency}
</Text>
</Text>
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-1">
{proforma.proformaNumber
? `Proforma ${proforma.proformaNumber}`
: `Proforma #${(proforma.id || "").slice(0, 8).toUpperCase()}`}
</Text>
{/* Status badge */}
<View className={`px-2.5 py-1 rounded-[4px] mt-4 ${theme.bg}`}>
<View className="flex-row items-center gap-1.5">
<View className={`w-1.5 h-1.5 rounded-full ${theme.dot}`} />
<Text
className={`text-[9px] font-sans-bold uppercase tracking-widest ${theme.text}`}
>
{theme.label}
</Text>
</View>
</View>
<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">
Issued
</Text>
<Text className="text-foreground text-[12px] font-sans-bold">
{issueDate ? formatLongDate(issueDate) : "—"}
</Text>
</View>
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[12px] font-sans-medium">
Due
</Text>
<Text className="text-foreground text-[12px] font-sans-bold">
{dueDate ? formatLongDate(dueDate) : "—"}
</Text>
</View>
</View>
</View>
</View>
{/* Tabs */}
<View className="px-5 pt-12">
<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("items")} className="pb-2.5">
<Text
className={`text-[14px] font-sans-bold ${
activeTab === "items"
? "text-foreground"
: "text-muted-foreground"
}`}
>
Items
</Text>
{activeTab === "items" && (
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
)}
</Pressable>
</View>
</View>
{/* Tab content */}
{activeTab === "details" ? (
<View className="px-5 pt-5 gap-6">
{/* Customer */}
{proforma.customerName && (
<View>
<Text className="font-sans-bold text-xs uppercase tracking-widest text-muted-foreground mb-3">
Customer
</Text>
<View className="flex-row items-center gap-3">
<View className="h-10 w-10 rounded-full bg-primary/10 items-center justify-center">
<User color="#E46212" size={18} strokeWidth={2} />
</View>
<View className="flex-1">
<Text className="text-foreground font-sans-bold text-sm">
{customerName}
</Text>
{(proforma.customerEmail || proforma.customerPhone) && (
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
{proforma.customerEmail || proforma.customerPhone}
</Text>
)}
</View>
</View>
</View>
)}
{/* Proforma Details */}
<View>
<Text className="font-sans-bold text-xs uppercase tracking-widest text-muted-foreground mb-3">
Proforma Details
</Text>
<View className="gap-4">
<View className="flex-row items-center gap-3">
<Hash size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Proforma Number
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{proforma.proformaNumber ||
`PRF${(proforma.id || "").slice(0, 8).toUpperCase()}`}
</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
<Calendar size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Issue Date
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{issueDate ? issueDate.toLocaleString() : "—"}
</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
<Clock size={15} color="#64748b" />
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Due Date
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{dueDate ? dueDate.toLocaleString() : "—"}
</Text>
</View>
</View>
{proforma.description && (
<View className="flex-row items-start gap-3">
<FileText
size={15}
color="#64748b"
style={{ marginTop: 2 }}
/>
<View className="flex-1">
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
Description
</Text>
<Text className="text-foreground font-sans-medium text-sm leading-5">
{proforma.description}
</Text>
</View>
</View>
)}
</View>
</View>
{/* Note (border only, no bg) */}
{proforma.notes && (
<View>
<Text className="text-foreground text-sm font-sans-bold mb-2">
Note
</Text>
<View className="rounded-[10px] border border-border p-4">
<Text className="text-foreground font-sans-medium text-[13px] leading-5">
{proforma.notes}
</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 = safeVal(item.quantity || 1);
const unitPrice = safeVal(item.unitPrice);
const lineTotal = safeVal(item.total || qty * unitPrice);
return (
<View
key={item.id || 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 || "No item"}
</Text>
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
{qty} ×{" "}
{unitPrice.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{currency}
</Text>
</View>
<Text className="text-foreground text-[14px] font-sans-bold">
{lineTotal.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}{" "}
{currency}
</Text>
</View>
);
})}
</View>
{/* Summary */}
<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">
{fmt(subtotal, currency)}
</Text>
</View>
{tax > 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">
+{fmt(tax, currency)}
</Text>
</View>
)}
{discount > 0 && (
<View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-[14px] font-sans-medium">
Discount
</Text>
<Text className="text-foreground text-[14px] font-sans-bold">
-{fmt(discount, currency)}
</Text>
</View>
)}
<View className="border-t border-border/60 pt-2.5 flex-row justify-between items-center">
<Text className="text-foreground font-sans-black text-base">
Total
</Text>
<Text className="text-primary font-sans-black text-base">
{fmt(amount, currency)}
</Text>
</View>
</View>
</View>
) : (
<EmptyState
title="No items yet"
description="This proforma doesn't have any items yet."
/>
)}
</View>
)}
</ScrollView>
{/* Sticky bottom bar — Send + Download PDF (like invoice detail) */}
<View
className="flex-row gap-3 px-5 py-3 border-t border-border"
style={{ backgroundColor: isDark ? "#0a0505" : "#ffffff" }}
>
<Pressable
onPress={() => setShowSendSheet(true)}
className="flex-1 h-12 rounded-[8px] border border-border items-center justify-center flex-row gap-2 bg-card"
>
<SendHorizonal color="#0f172a" size={16} strokeWidth={2.5} />
<Text className="text-foreground text-[13px] font-sans-bold">
Send
</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"
>
<Download color="#0f172a" size={16} strokeWidth={2.5} />
<Text className="text-foreground text-[13px] font-sans-bold">
Download PDF
</Text>
</Pressable>
</View>
{/* More bottom sheet */}
<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]">
Proforma
</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 Proforma"
description="Update details, items, or dates"
onPress={() => {
setShowMoreSheet(false);
nav.go("proforma/edit", { id: proforma.id });
}}
/>
<ActionOption
icon={<Trash2 color="#ef4444" size={18} strokeWidth={2} />}
label="Delete Proforma"
description="Permanently remove this record"
onPress={() => {
setShowMoreSheet(false);
handleDelete();
}}
destructive
/>
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
{/* Send bottom sheet (Email / SMS) */}
<Modal
visible={showSendSheet}
transparent
animationType="slide"
onRequestClose={() => setShowSendSheet(false)}
>
<Pressable
className="flex-1 bg-black/40"
onPress={() => setShowSendSheet(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]">
Send Proforma
</Text>
<Pressable
onPress={() => setShowSendSheet(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 }}
>
<Text className="text-muted-foreground text-[12px] font-sans-medium mb-4">
Send a public, shortened link via yaltopia.com to your
customer's email or phone.
</Text>
<ActionOption
icon={<Mail color="#E46212" size={18} strokeWidth={2} />}
label="Send as Email"
description="Public accessible shortened link via yaltopia.com"
onPress={() => handleShare("email")}
/>
<ActionOption
icon={
<MessageSquare color="#E46212" size={18} strokeWidth={2} />
}
label="Send as SMS"
description="Public accessible shortened link via yaltopia.com"
onPress={() => handleShare("sms")}
/>
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
<ActionModal
visible={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={confirmDelete}
title="Delete Proforma"
description="Are you sure you want to permanently delete this proforma? This action cannot be reversed."
confirmText="Delete"
confirmVariant="destructive"
icon={Trash2}
iconColor="#ef4444"
loading={deleting}
/>
</ScreenWrapper>
);
}