487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
import React, { useState, useCallback } from "react";
|
||
import {
|
||
View,
|
||
ScrollView,
|
||
ActivityIndicator,
|
||
Alert,
|
||
Linking,
|
||
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 {
|
||
FileText,
|
||
Calendar,
|
||
Download,
|
||
Trash2,
|
||
Package,
|
||
Clock,
|
||
User,
|
||
Hash,
|
||
AlertCircle,
|
||
Edit,
|
||
Mail,
|
||
MessageSquare,
|
||
Globe,
|
||
MoreVertical,
|
||
X,
|
||
} 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";
|
||
|
||
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 [showActions, setShowActions] = 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 handleDelete = () => {
|
||
Alert.alert("Delete Proforma", "This cannot be undone.", [
|
||
{ text: "Cancel", style: "cancel" },
|
||
{
|
||
text: "Delete",
|
||
style: "destructive",
|
||
onPress: async () => {
|
||
try {
|
||
setLoading(true);
|
||
const pid = Array.isArray(id) ? id[0] : id;
|
||
await api.proforma.delete({ params: { id: pid } });
|
||
toast.success("Success", "Proforma deleted");
|
||
nav.back();
|
||
} catch {
|
||
toast.error("Error", "Failed to delete proforma");
|
||
setLoading(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;
|
||
|
||
return (
|
||
<ScreenWrapper className="bg-background">
|
||
<Stack.Screen options={{ headerShown: false }} />
|
||
<StandardHeader
|
||
title="Proforma"
|
||
showBack
|
||
rightAction="edit"
|
||
onRightActionPress={() => nav.go("proforma/edit", { id: proforma.id })}
|
||
/>
|
||
|
||
<ScrollView
|
||
className="flex-1"
|
||
contentContainerStyle={{ paddingBottom: 120 }}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
{/* Hero — Amount + Status */}
|
||
<View className="px-5 pt-6 mb-6">
|
||
<View className="flex-row items-start justify-between mb-1">
|
||
<View className="flex-1 mr-4">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-1.5">
|
||
Total Amount
|
||
</Text>
|
||
<View className="flex-row items-baseline gap-1.5">
|
||
<Text className="text-3xl font-sans-black text-foreground tracking-tight">
|
||
{amount.toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
||
</Text>
|
||
<Text className="text-base font-sans-bold text-primary">
|
||
{currency}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<View className={`px-2.5 py-1 rounded-[4px] ${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>
|
||
</View>
|
||
|
||
{/* Period Dates */}
|
||
<View className="px-5 mb-6">
|
||
<View className="bg-card rounded-[6px] border border-border p-4">
|
||
<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.5">
|
||
Issued
|
||
</Text>
|
||
<View className="flex-row items-center gap-1.5">
|
||
<Calendar size={13} color="#94a3b8" strokeWidth={2} />
|
||
<Text className="text-foreground font-sans-bold text-sm">
|
||
{proforma.issueDate ? new Date(proforma.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.5">
|
||
Due
|
||
</Text>
|
||
<View className="flex-row items-center gap-1.5">
|
||
<Clock size={13} color="#94a3b8" strokeWidth={2} />
|
||
<Text className="text-foreground font-sans-bold text-sm">
|
||
{proforma.dueDate ? new Date(proforma.dueDate).toLocaleDateString() : "—"}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Customer */}
|
||
{proforma.customerName && (
|
||
<View className="px-5 mb-6">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||
Customer
|
||
</Text>
|
||
<View className="bg-card rounded-[6px] border border-border p-4">
|
||
<View className="flex-row items-center gap-3 mb-2">
|
||
<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-foreground font-sans-bold text-base">
|
||
{proforma.customerName}
|
||
</Text>
|
||
{(proforma.customerEmail || proforma.customerPhone) && (
|
||
<Text className="text-muted-foreground text-xs font-sans-medium mt-0.5">
|
||
{[proforma.customerEmail, proforma.customerPhone].filter(Boolean).join(" · ")}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
<View className="flex-row items-center gap-3 pt-2.5 border-t border-border">
|
||
<Hash size={12} color="#94a3b8" strokeWidth={2} />
|
||
<Text className="text-muted-foreground text-xs font-sans-medium">
|
||
#{proforma.id?.slice(0, 8) || "—"}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* 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={2}
|
||
>
|
||
{item.description || `Item ${idx + 1}`}
|
||
</Text>
|
||
<Text className="text-foreground font-sans-bold text-sm">
|
||
{fmt(safeVal(item.total || item.unitPrice) * safeVal(item.quantity || 1), 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>
|
||
)}
|
||
|
||
{items.length === 0 && (
|
||
<View className="px-5 mb-6">
|
||
<View className="bg-card rounded-[6px] border border-border p-8 items-center">
|
||
<Package size={32} color="#cbd5e1" className="mb-2" />
|
||
<Text className="text-muted-foreground text-sm font-sans-medium">No items</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* Summary */}
|
||
<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] border border-border p-4 gap-2.5">
|
||
<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(amount, currency)}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Description */}
|
||
{proforma.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] border border-border p-3.5">
|
||
<Text className="text-foreground text-sm font-sans-medium leading-5">
|
||
{proforma.description}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
|
||
{/* Notes */}
|
||
{proforma.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] border border-border p-3.5">
|
||
<Text className="text-foreground text-sm font-sans-medium leading-5">
|
||
{proforma.notes}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
|
||
{/* Actions Trigger */}
|
||
<View className="px-5 mb-6">
|
||
<Pressable
|
||
onPress={() => setShowActions(true)}
|
||
className="bg-primary h-10 rounded-[6px] flex-row items-center justify-center gap-2"
|
||
>
|
||
<MoreVertical color="white" size={16} strokeWidth={2.5} />
|
||
<Text className="text-white font-sans-bold text-[11px] uppercase tracking-widest">
|
||
Actions
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
</ScrollView>
|
||
|
||
{/* Actions Bottom Sheet */}
|
||
<Modal
|
||
visible={showActions}
|
||
transparent
|
||
animationType="slide"
|
||
onRequestClose={() => setShowActions(false)}
|
||
>
|
||
<Pressable className="flex-1 bg-black/40" onPress={() => setShowActions(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()}
|
||
>
|
||
{/* Header */}
|
||
<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]">Actions</Text>
|
||
<Pressable
|
||
onPress={() => setShowActions(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 }}
|
||
>
|
||
{/* PDF */}
|
||
<ActionOption
|
||
icon={<Download color="#E46212" size={18} strokeWidth={2} />}
|
||
label="Download PDF"
|
||
description="Save proforma as PDF document"
|
||
onPress={() => { setShowActions(false); handleGetPdf(); }}
|
||
/>
|
||
|
||
{/* Delete */}
|
||
<ActionOption
|
||
icon={<Trash2 color="#ef4444" size={18} strokeWidth={2} />}
|
||
label="Delete Proforma"
|
||
description="Permanently remove this proforma"
|
||
onPress={() => { setShowActions(false); handleDelete(); }}
|
||
danger
|
||
/>
|
||
|
||
{/* Send as Email */}
|
||
<View className="border-t border-border/60 pt-3 mb-3">
|
||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||
Send
|
||
</Text>
|
||
</View>
|
||
<ActionOption
|
||
icon={<Mail color="#E46212" size={18} strokeWidth={2} />}
|
||
label="Send as Email"
|
||
description="Public accessible shortened link via yaltopia.com"
|
||
/>
|
||
<ActionOption
|
||
icon={<MessageSquare color="#E46212" size={18} strokeWidth={2} />}
|
||
label="Send as SMS"
|
||
description="Public accessible shortened link via yaltopia.com"
|
||
/>
|
||
</ScrollView>
|
||
</Pressable>
|
||
</View>
|
||
</Pressable>
|
||
</Modal>
|
||
</ScreenWrapper>
|
||
);
|
||
}
|
||
|
||
function ActionOption({
|
||
icon,
|
||
label,
|
||
description,
|
||
onPress,
|
||
danger,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
description: string;
|
||
onPress?: () => void;
|
||
danger?: boolean;
|
||
}) {
|
||
return (
|
||
<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={`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>
|
||
);
|
||
}
|