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

623 lines
21 KiB
TypeScript

import React, { useState, useCallback } from "react";
import {
View,
ScrollView,
ActivityIndicator,
Linking,
Pressable,
Share,
Modal,
} 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 {
Inbox,
Calendar,
Clock,
Hash,
Share2,
Package,
X,
Mail,
Link2,
Info,
Truck,
Send,
AlertCircle,
CheckCircle2,
XCircle,
Hourglass,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api } from "@/lib/api";
import { useColorScheme } from "nativewind";
import { toast } from "@/lib/toast-store";
const STATUS_THEME: Record<
string,
{ label: string; bg: string; text: string; dot: string; pillBg: string }
> = {
DRAFT: {
label: "Draft",
bg: "bg-slate-500/10",
text: "text-slate-600",
dot: "bg-slate-500",
pillBg: "#6b728015",
},
OPEN: {
label: "Open",
bg: "bg-primary/10",
text: "text-primary",
dot: "bg-primary",
pillBg: "#E4621215",
},
UNDER_REVIEW: {
label: "Under Review",
bg: "bg-blue-500/10",
text: "text-blue-600",
dot: "bg-blue-500",
pillBg: "#2563eb15",
},
REVISION_REQUESTED: {
label: "Revision Requested",
bg: "bg-red-500/10",
text: "text-red-600",
dot: "bg-red-500",
pillBg: "#dc262615",
},
CLOSED: {
label: "Closed",
bg: "bg-emerald-500/10",
text: "text-emerald-600",
dot: "bg-emerald-500",
pillBg: "#16a34a15",
},
CANCELLED: {
label: "Cancelled",
bg: "bg-slate-500/10",
text: "text-slate-600",
dot: "bg-slate-500",
pillBg: "#6b728015",
},
};
const CATEGORY_THEME: Record<string, { color: string; bg: string }> = {
EQUIPMENT: { color: "#2563eb", bg: "#2563eb15" },
SERVICE: { color: "#16a34a", bg: "#16a34a15" },
MIXED: { color: "#E46212", bg: "#E4621215" },
};
const INVITE_STATUS_ICON: Record<
string,
{ Icon: React.ComponentType<any>; color: string; label: string }
> = {
PENDING: { Icon: Hourglass, color: "#94a3b8", label: "Pending" },
SENT: { Icon: CheckCircle2, color: "#16a34a", label: "Sent" },
FAILED: { Icon: XCircle, color: "#dc2626", label: "Failed" },
};
function fmtDate(d?: string) {
if (!d) return "—";
return new Date(d).toLocaleDateString();
}
export default function ProformaRequestDetailScreen() {
const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [showShareSheet, setShowShareSheet] = useState(false);
const fetch = useCallback(async () => {
try {
setLoading(true);
const reqId = Array.isArray(id) ? id[0] : id;
if (!reqId) return;
const result = await api.proformaRequests.getById({
params: { id: reqId },
});
setData(result);
} catch (err: any) {
toast.error("Error", "Failed to load proforma request");
} finally {
setLoading(false);
}
}, [id]);
useFocusEffect(
useCallback(() => {
fetch();
}, [fetch]),
);
const handleShare = async (channel: "system" | "email") => {
if (!data?.inviteUrl) {
toast.error("No invite link", "This request has no invite URL yet");
return;
}
try {
if (channel === "email") {
await Linking.openURL(
`mailto:?subject=${encodeURIComponent(
data.title || "Proforma Request",
)}&body=${encodeURIComponent(data.inviteUrl)}`,
);
} else {
await Share.share({
message: `${data.title || "Proforma Request"}\n${data.inviteUrl}`,
});
}
setShowShareSheet(false);
} catch (err: any) {
toast.error("Error", err?.message || "Failed to share");
}
};
if (loading || !data) {
return (
<ScreenWrapper className="bg-background">
<StandardHeader title="Proforma Request" showBack />
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#E46212" />
</View>
</ScreenWrapper>
);
}
const statusKey = (data.status || "DRAFT").toUpperCase();
const theme = STATUS_THEME[statusKey] || STATUS_THEME.DRAFT;
const categoryKey = (data.category || "MIXED").toUpperCase();
const categoryTheme = CATEGORY_THEME[categoryKey] || CATEGORY_THEME.MIXED;
const items: any[] = Array.isArray(data.items) ? data.items : [];
const invites: any[] = Array.isArray(data.invites) ? data.invites : [];
const submissionCount = data.submissionCount ?? 0;
const totalQuantity = items.reduce(
(s: number, i: any) => s + (Number(i.quantity) || 0),
0,
);
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Proforma Request" showBack />
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
>
{/* Hero Card */}
<View className="px-5 mt-6 mb-6">
<View className="bg-card rounded-[14px] border border-border p-5">
<View className="flex-row items-center gap-3 mb-3">
<View
className="h-11 w-11 rounded-[10px] items-center justify-center"
style={{ backgroundColor: categoryTheme.bg }}
>
<Inbox color={categoryTheme.color} size={20} strokeWidth={2} />
</View>
<View className="flex-1">
<Text
className="text-foreground font-sans-bold text-base"
numberOfLines={2}
>
{data.title || "Untitled request"}
</Text>
<View className="flex-row items-center gap-2 mt-1">
<View
className="px-2 py-0.5 rounded-[4px]"
style={{ backgroundColor: categoryTheme.bg }}
>
<Text
className="text-[9px] font-sans-bold uppercase tracking-widest"
style={{ color: categoryTheme.color }}
>
{categoryKey}
</Text>
</View>
</View>
</View>
<View
className="px-2.5 py-1 rounded-[6px] flex-row items-center gap-1.5"
style={{ backgroundColor: theme.pillBg }}
>
<View
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor:
STATUS_THEME[statusKey]?.dot?.replace("bg-", "") ===
"bg-slate-500"
? "#6b7280"
: theme.dot === "bg-slate-500"
? "#6b7280"
: statusKey === "OPEN"
? "#E46212"
: statusKey === "UNDER_REVIEW"
? "#2563eb"
: statusKey === "REVISION_REQUESTED"
? "#dc2626"
: statusKey === "CLOSED"
? "#16a34a"
: "#6b7280",
}}
/>
<Text
className="text-[10px] font-sans-bold uppercase tracking-widest"
style={{
color:
statusKey === "OPEN"
? "#E46212"
: statusKey === "UNDER_REVIEW"
? "#2563eb"
: statusKey === "REVISION_REQUESTED"
? "#dc2626"
: statusKey === "CLOSED"
? "#16a34a"
: "#6b7280",
}}
>
{theme.label}
</Text>
</View>
</View>
{data.description ? (
<Text className="text-muted-foreground text-sm font-sans-medium leading-5">
{data.description}
</Text>
) : null}
<View className="flex-row gap-4 mt-4 pt-4 border-t border-border">
<View className="flex-1">
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-1">
Deadline
</Text>
<View className="flex-row items-center gap-1.5">
<Calendar size={12} color="#94a3b8" strokeWidth={2} />
<Text className="text-foreground font-sans-bold text-sm">
{fmtDate(data.submissionDeadline)}
</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">
Submissions
</Text>
<View className="flex-row items-center gap-1.5">
<Send size={12} color="#94a3b8" strokeWidth={2} />
<Text className="text-foreground font-sans-bold text-sm">
{submissionCount}
</Text>
</View>
</View>
</View>
</View>
</View>
{/* Items */}
{items.length > 0 && (
<View className="px-5 mb-6">
<View className="flex-row items-center justify-between mb-2">
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground">
Requested Items ({items.length})
</Text>
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground">
{totalQuantity} units
</Text>
</View>
<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={1}
>
{item.itemName || `Item ${idx + 1}`}
</Text>
<Text className="text-foreground font-sans-bold text-sm">
{item.quantity || 0} {item.unitOfMeasure || "unit"}
</Text>
</View>
{item.itemDescription ? (
<Text
className="text-muted-foreground text-[11px] font-sans-medium"
numberOfLines={2}
>
{item.itemDescription}
</Text>
) : null}
{item.technicalSpecifications &&
Object.keys(item.technicalSpecifications).length > 0 ? (
<View className="flex-row flex-wrap gap-1.5 mt-1.5">
{Object.entries(
item.technicalSpecifications as Record<string, string>,
).map(([k, v]) => (
<View
key={k}
className="px-1.5 py-0.5 rounded-[3px] bg-primary/5 border border-primary/15"
>
<Text className="text-[9px] font-sans-bold text-primary uppercase tracking-wide">
{k}: {String(v)}
</Text>
</View>
))}
</View>
) : null}
</View>
))}
</View>
</View>
)}
{/* Commercial Terms */}
<View className="px-5 mb-6">
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
Commercial Terms
</Text>
<View className="bg-card rounded-[6px] border border-border overflow-hidden">
{data.paymentTerms ? (
<TermRow icon={Hash} label="Payment Terms" value={data.paymentTerms} />
) : null}
{data.incoterms ? (
<TermRow
icon={Truck}
label="Incoterms"
value={data.incoterms}
divider
/>
) : null}
{data.validityPeriod != null ? (
<TermRow
icon={Clock}
label="Validity Period"
value={`${data.validityPeriod} days`}
divider
/>
) : null}
<TermRow
icon={Info}
label="Tax Included"
value={data.taxIncluded ? "Yes" : "No"}
divider={!!(data.paymentTerms || data.incoterms || data.validityPeriod != null)}
/>
<TermRow
icon={CheckCircle2}
label="Allow Revisions"
value={data.allowRevisions ? "Yes" : "No"}
divider
/>
{data.discountStructure ? (
<TermRow
icon={Package}
label="Discount Structure"
value={data.discountStructure}
divider
multiline
/>
) : null}
</View>
</View>
{/* Invite link */}
{data.inviteUrl ? (
<View className="px-5 mb-6">
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
Invite Link
</Text>
<View className="bg-card rounded-[6px] border border-border p-3.5 gap-2">
<View className="flex-row items-center gap-2">
<Link2 size={14} color="#E46212" strokeWidth={2} />
<Text
className="text-foreground text-xs font-sans-medium flex-1"
numberOfLines={1}
>
{data.inviteUrl}
</Text>
</View>
<Pressable
onPress={() => setShowShareSheet(true)}
className="h-9 rounded-[6px] bg-primary flex-row items-center justify-center gap-1.5"
>
<Share2 size={12} color="#fff" strokeWidth={2.5} />
<Text className="text-white text-[10px] font-sans-bold uppercase tracking-widest">
Share Invite Link
</Text>
</Pressable>
</View>
</View>
) : null}
{/* Invites */}
{invites.length > 0 && (
<View className="px-5 mb-6">
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
Invites ({invites.length})
</Text>
<View className="gap-2">
{invites.map((inv: any, idx: number) => {
const key = (inv.status || "PENDING").toUpperCase();
const invTheme = INVITE_STATUS_ICON[key] || INVITE_STATUS_ICON.PENDING;
const { Icon, color, label } = invTheme;
return (
<View
key={inv.customerId || idx}
className="bg-card rounded-[6px] border border-border p-3.5"
>
<View className="flex-row items-center gap-2 mb-1">
<View className="h-7 w-7 rounded-full bg-primary/10 items-center justify-center">
<Mail size={12} color="#E46212" strokeWidth={2} />
</View>
<View className="flex-1">
<Text
className="text-foreground font-sans-bold text-sm"
numberOfLines={1}
>
{inv.customerName || "Customer"}
</Text>
{inv.sentTo ? (
<Text
className="text-muted-foreground text-[11px] font-sans-medium"
numberOfLines={1}
>
{inv.sentTo}
</Text>
) : null}
</View>
<View className="flex-row items-center gap-1">
<Icon size={12} color={color} strokeWidth={2.5} />
<Text
className="text-[9px] font-sans-bold uppercase tracking-widest"
style={{ color }}
>
{label}
</Text>
</View>
</View>
{inv.sendError ? (
<Text
className="text-red-500 text-[10px] font-sans-medium mt-1"
numberOfLines={2}
>
{inv.sendError}
</Text>
) : null}
</View>
);
})}
</View>
</View>
)}
{/* Notes fallback / no-data state */}
{items.length === 0 && !data.description && (
<View className="px-5 mb-6">
<View className="bg-card rounded-[6px] border border-border p-4 flex-row items-center gap-3">
<AlertCircle size={16} color="#94a3b8" strokeWidth={2} />
<Text className="text-muted-foreground text-xs font-sans-medium flex-1">
This request has no items or description yet.
</Text>
</View>
</View>
)}
</ScrollView>
<Modal
visible={showShareSheet}
transparent
animationType="slide"
onRequestClose={() => setShowShareSheet(false)}
>
<Pressable
onPress={() => setShowShareSheet(false)}
className="flex-1 bg-black/40 justify-end"
>
<Pressable
onPress={() => {}}
className="bg-background rounded-t-3xl p-5 pb-8"
style={{
borderTopWidth: 1,
borderColor: isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.05)",
}}
>
<View className="flex-row items-center justify-between mb-4">
<Text className="text-foreground font-sans-bold text-base">
Share Invite Link
</Text>
<Pressable onPress={() => setShowShareSheet(false)} hitSlop={8}>
<X size={20} color="#64748b" />
</Pressable>
</View>
<ShareOption
icon={Share2}
label="Share via..."
description="Open system share sheet"
onPress={() => handleShare("system")}
/>
<ShareOption
icon={Mail}
label="Email"
description="Open default mail app"
onPress={() => handleShare("email")}
/>
</Pressable>
</Pressable>
</Modal>
</ScreenWrapper>
);
}
function TermRow({
icon: Icon,
label,
value,
divider,
multiline,
}: {
icon: React.ComponentType<any>;
label: string;
value: string;
divider?: boolean;
multiline?: boolean;
}) {
return (
<View
className={`flex-row items-start gap-3 px-4 py-3 ${divider ? "border-t border-border" : ""}`}
>
<View className="h-7 w-7 rounded-full bg-primary/10 items-center justify-center mt-0.5">
<Icon size={12} color="#E46212" strokeWidth={2} />
</View>
<View className="flex-1">
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground">
{label}
</Text>
<Text
className="text-foreground text-sm font-sans-bold mt-0.5"
numberOfLines={multiline ? undefined : 1}
>
{value}
</Text>
</View>
</View>
);
}
function ShareOption({
icon: Icon,
label,
description,
onPress,
}: {
icon: React.ComponentType<any>;
label: string;
description: string;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className="flex-row items-center gap-3 p-3.5 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 size={16} color="#E46212" strokeWidth={2.5} />
</View>
<View className="flex-1">
<Text className="text-foreground font-sans-bold text-sm">{label}</Text>
<Text className="text-muted-foreground text-[11px] font-sans-medium">
{description}
</Text>
</View>
</Pressable>
);
}