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

487 lines
18 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,
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>
);
}