623 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|