498 lines
16 KiB
TypeScript
498 lines
16 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { CommandPalette } from "@/components/CommandPalette";
|
|
import {
|
|
View,
|
|
ScrollView,
|
|
Pressable,
|
|
TextInput,
|
|
ActivityIndicator,
|
|
} from "react-native";
|
|
import { Text } from "@/components/ui/text";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useSirouRouter } from "@sirou/react-native";
|
|
import { useFocusEffect } from "expo-router";
|
|
import { AppRoutes } from "@/lib/routes";
|
|
import {
|
|
Plus,
|
|
FileText,
|
|
Search,
|
|
ChevronRight,
|
|
Inbox,
|
|
} from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { StandardHeader } from "@/components/StandardHeader";
|
|
import { EmptyState } from "@/components/EmptyState";
|
|
import { api } from "@/lib/api";
|
|
import { useAuthStore } from "@/lib/auth-store";
|
|
import { toast } from "@/lib/toast-store";
|
|
import { useColorScheme } from "nativewind";
|
|
import { getPlaceholderColor } from "@/lib/colors";
|
|
import { hasPermission, PERMISSION_MAP } from "@/lib/permissions";
|
|
|
|
type Tab = "proforma" | "request";
|
|
|
|
interface ProformaItem {
|
|
id: string;
|
|
proformaNumber: string;
|
|
customerName: string;
|
|
customerEmail: string;
|
|
customerPhone: string;
|
|
amount: any;
|
|
currency: string;
|
|
issueDate: string;
|
|
dueDate: string;
|
|
description: string;
|
|
notes: string;
|
|
taxAmount: any;
|
|
discountAmount: any;
|
|
pdfPath: string;
|
|
userId: string;
|
|
items: any[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface ProformaRequest {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
category: "EQUIPMENT" | "SERVICE" | "MIXED";
|
|
status:
|
|
| "DRAFT"
|
|
| "OPEN"
|
|
| "UNDER_REVIEW"
|
|
| "REVISION_REQUESTED"
|
|
| "CLOSED"
|
|
| "CANCELLED";
|
|
submissionDeadline: string;
|
|
items: { id: string; itemName: string; quantity: number; unitOfMeasure: string }[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
const REQUEST_STATUS_COLORS: Record<string, string> = {
|
|
DRAFT: "#6b7280",
|
|
OPEN: "#E46212",
|
|
UNDER_REVIEW: "#2563eb",
|
|
REVISION_REQUESTED: "#dc2626",
|
|
CLOSED: "#16a34a",
|
|
CANCELLED: "#6b7280",
|
|
};
|
|
|
|
const REQUEST_STATUS_BG: Record<string, string> = {
|
|
DRAFT: "#6b728015",
|
|
OPEN: "#E4621215",
|
|
UNDER_REVIEW: "#2563eb15",
|
|
REVISION_REQUESTED: "#dc262615",
|
|
CLOSED: "#16a34a15",
|
|
CANCELLED: "#6b728015",
|
|
};
|
|
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
EQUIPMENT: "#2563eb",
|
|
SERVICE: "#16a34a",
|
|
MIXED: "#E46212",
|
|
};
|
|
|
|
export default function ProformaScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const permissions = useAuthStore((s) => s.permissions);
|
|
const { colorScheme } = useColorScheme();
|
|
const isDark = colorScheme === "dark";
|
|
const [tab, setTab] = useState<Tab>("proforma");
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
|
|
// Proforma state
|
|
const [proformas, setProformas] = useState<ProformaItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
|
|
// Request state
|
|
const [requests, setRequests] = useState<ProformaRequest[]>([]);
|
|
const [requestsLoading, setRequestsLoading] = useState(false);
|
|
const [reqPage, setReqPage] = useState(1);
|
|
const [reqHasMore, setReqHasMore] = useState(true);
|
|
const [reqLoadingMore, setReqLoadingMore] = useState(false);
|
|
|
|
const canCreateProformas = hasPermission(
|
|
permissions,
|
|
PERMISSION_MAP["proforma:create"],
|
|
);
|
|
|
|
const fetchProformas = useCallback(async (pageNum: number) => {
|
|
const { isAuthenticated } = useAuthStore.getState();
|
|
if (!isAuthenticated) return;
|
|
try {
|
|
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
|
|
const response = await api.proforma.getAll({
|
|
query: { page: pageNum, limit: 10 },
|
|
});
|
|
const newProformas = response.data;
|
|
setProformas((prev) =>
|
|
pageNum === 1 ? newProformas : [...prev, ...newProformas],
|
|
);
|
|
setHasMore(response.meta.hasNextPage);
|
|
setPage(pageNum);
|
|
} catch (err: any) {
|
|
console.error("[Proforma] Fetch error:", err);
|
|
setHasMore(false);
|
|
} finally {
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchRequests = useCallback(async (pageNum: number) => {
|
|
const { isAuthenticated } = useAuthStore.getState();
|
|
if (!isAuthenticated) return;
|
|
try {
|
|
pageNum === 1 ? setRequestsLoading(true) : setReqLoadingMore(true);
|
|
const response = await api.proformaRequests.getAll({
|
|
query: { page: pageNum, limit: 10 },
|
|
});
|
|
const newRequests = response.data;
|
|
setRequests((prev) =>
|
|
pageNum === 1 ? newRequests : [...prev, ...newRequests],
|
|
);
|
|
setReqHasMore(response.meta.hasNextPage);
|
|
setReqPage(pageNum);
|
|
} catch (err: any) {
|
|
console.error("[ProformaRequests] Fetch error:", err);
|
|
toast.error("Error", "Failed to fetch proforma requests.");
|
|
} finally {
|
|
setRequestsLoading(false);
|
|
setReqLoadingMore(false);
|
|
}
|
|
}, []);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (tab === "proforma") fetchProformas(1);
|
|
else fetchRequests(1);
|
|
}, [tab, fetchProformas, fetchRequests]),
|
|
);
|
|
|
|
const loadMore = () => {
|
|
if (tab === "proforma" && hasMore && !loadingMore && !loading) {
|
|
fetchProformas(page + 1);
|
|
}
|
|
if (
|
|
tab === "request" &&
|
|
reqHasMore &&
|
|
!reqLoadingMore &&
|
|
!requestsLoading
|
|
) {
|
|
fetchRequests(reqPage + 1);
|
|
}
|
|
};
|
|
|
|
const filteredProformas = useMemo(() => {
|
|
if (!search.trim()) return proformas;
|
|
const q = search.toLowerCase();
|
|
const searchNum = parseFloat(q);
|
|
return proformas.filter((p) => {
|
|
if (p.customerName?.toLowerCase().includes(q)) return true;
|
|
if (p.proformaNumber?.toLowerCase().includes(q)) return true;
|
|
const pAmount =
|
|
typeof p.amount === "object"
|
|
? parseFloat(p.amount.value)
|
|
: parseFloat(p.amount);
|
|
if (!isNaN(searchNum) && !isNaN(pAmount)) {
|
|
if (pAmount === searchNum) return true;
|
|
if (String(pAmount).includes(q)) return true;
|
|
}
|
|
return false;
|
|
});
|
|
}, [proformas, search]);
|
|
|
|
const filteredRequests = useMemo(() => {
|
|
if (!search.trim()) return requests;
|
|
const q = search.toLowerCase();
|
|
return requests.filter((r) => {
|
|
if (r.title?.toLowerCase().includes(q)) return true;
|
|
if (r.description?.toLowerCase().includes(q)) return true;
|
|
if (r.category?.toLowerCase().includes(q)) return true;
|
|
if (r.status?.toLowerCase().includes(q)) return true;
|
|
return false;
|
|
});
|
|
}, [requests, search]);
|
|
|
|
const renderProformaCard = (item: ProformaItem) => {
|
|
const amountVal =
|
|
typeof item.amount === "object" ? item.amount.value : item.amount;
|
|
const issuedStr = item.issueDate
|
|
? new Date(item.issueDate).toLocaleDateString()
|
|
: "";
|
|
const dueStr = item.dueDate ? new Date(item.dueDate).toLocaleDateString() : "";
|
|
const itemsCount = Array.isArray(item.items) ? item.items.length : 0;
|
|
|
|
return (
|
|
<Pressable
|
|
key={item.id}
|
|
onPress={() => nav.go("proforma/[id]", { id: item.id })}
|
|
className="mb-2"
|
|
>
|
|
<Card className="rounded-xl border-border bg-card overflow-hidden">
|
|
<View className="flex-row items-center px-3 py-3">
|
|
<View className="w-10 h-10 rounded-lg bg-primary/10 items-center justify-center mr-3">
|
|
<FileText color="#E46212" size={18} strokeWidth={2} />
|
|
</View>
|
|
<View className="flex-1">
|
|
<View className="flex-row items-center justify-between">
|
|
<Text
|
|
className="text-foreground font-sans-bold text-sm flex-1"
|
|
numberOfLines={1}
|
|
>
|
|
{item.proformaNumber || "Proforma"}
|
|
</Text>
|
|
<Text className="text-foreground font-sans-bold text-sm ml-2">
|
|
{item.currency || "ETB"}{" "}
|
|
{(amountVal || 0).toLocaleString("en-US", {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
variant="muted"
|
|
className="text-[11px] font-sans-medium mt-0.5"
|
|
numberOfLines={1}
|
|
>
|
|
{item.customerName || "Customer"} · Issued {issuedStr}
|
|
{dueStr ? ` · Due ${dueStr}` : ""} · {itemsCount} item
|
|
{itemsCount !== 1 ? "s" : ""}
|
|
</Text>
|
|
</View>
|
|
<ChevronRight size={16} strokeWidth={2} color="#94a3b8" />
|
|
</View>
|
|
</Card>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
const renderRequestCard = (req: ProformaRequest) => {
|
|
const statusColor = REQUEST_STATUS_COLORS[req.status] || "#6b7280";
|
|
const statusBg = REQUEST_STATUS_BG[req.status] || "#6b728015";
|
|
const categoryColor = CATEGORY_COLORS[req.category] || "#6b7280";
|
|
const deadlineStr = req.submissionDeadline
|
|
? new Date(req.submissionDeadline).toLocaleDateString()
|
|
: "";
|
|
const itemsCount = Array.isArray(req.items) ? req.items.length : 0;
|
|
|
|
return (
|
|
<Pressable
|
|
key={req.id}
|
|
onPress={() => nav.go("proforma-requests/[id]", { id: req.id })}
|
|
className="mb-2"
|
|
>
|
|
<Card className="rounded-xl border-border bg-card overflow-hidden">
|
|
<View className="flex-row items-center px-3 py-3">
|
|
<View className="w-10 h-10 rounded-lg bg-primary/10 items-center justify-center mr-3">
|
|
<Inbox color="#E46212" size={18} strokeWidth={2} />
|
|
</View>
|
|
<View className="flex-1">
|
|
<View className="flex-row items-center justify-between">
|
|
<Text
|
|
className="text-foreground font-sans-bold text-sm flex-1"
|
|
numberOfLines={1}
|
|
>
|
|
{req.title || "Untitled request"}
|
|
</Text>
|
|
<View
|
|
className="px-2 py-0.5 rounded-[4px] ml-2"
|
|
style={{ backgroundColor: statusBg }}
|
|
>
|
|
<Text
|
|
className="text-[8px] font-sans-bold uppercase tracking-widest"
|
|
style={{ color: statusColor }}
|
|
>
|
|
{req.status.replace(/_/g, " ")}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<Text
|
|
variant="muted"
|
|
className="text-[11px] font-sans-medium mt-0.5"
|
|
numberOfLines={1}
|
|
>
|
|
{itemsCount} item{itemsCount !== 1 ? "s" : ""} · Deadline{" "}
|
|
{deadlineStr || "—"}
|
|
</Text>
|
|
<View className="flex-row items-center gap-1.5 mt-1">
|
|
<View
|
|
className="px-1.5 py-0.5 rounded-[3px]"
|
|
style={{ backgroundColor: `${categoryColor}1A` }}
|
|
>
|
|
<Text
|
|
className="text-[8px] font-sans-bold uppercase tracking-widest"
|
|
style={{ color: categoryColor }}
|
|
>
|
|
{req.category}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
const isLoading =
|
|
tab === "proforma" ? loading && page === 1 : requestsLoading && reqPage === 1;
|
|
if (isLoading) {
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<StandardHeader
|
|
title="Proforma"
|
|
showBack
|
|
showSearch
|
|
onSearchPress={() => setSearchOpen(true)}
|
|
/>
|
|
<View className="flex-1 items-center justify-center">
|
|
<ActivityIndicator size="large" color="#E46212" />
|
|
</View>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
const items = tab === "proforma" ? filteredProformas : filteredRequests;
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<ScrollView
|
|
className="flex-1"
|
|
contentContainerStyle={{ paddingBottom: 150 }}
|
|
showsVerticalScrollIndicator={false}
|
|
onScroll={({ nativeEvent }) => {
|
|
const isCloseToBottom =
|
|
nativeEvent.layoutMeasurement.height +
|
|
nativeEvent.contentOffset.y >=
|
|
nativeEvent.contentSize.height - 20;
|
|
if (isCloseToBottom) loadMore();
|
|
}}
|
|
scrollEventThrottle={400}
|
|
>
|
|
<StandardHeader
|
|
title="Proforma"
|
|
showBack
|
|
showSearch
|
|
onSearchPress={() => setSearchOpen(true)}
|
|
/>
|
|
<View className="px-[16px] pt-6">
|
|
<View className="flex-row items-center bg-card border border-border rounded-xl px-3 h-11 mb-3">
|
|
<Search size={16} color="#94a3b8" strokeWidth={2} />
|
|
<TextInput
|
|
className="flex-1 ml-2 text-foreground text-sm"
|
|
placeholder={
|
|
tab === "proforma"
|
|
? "Search by name, number, or amount..."
|
|
: "Search by title, description, status..."
|
|
}
|
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
|
value={search}
|
|
onChangeText={setSearch}
|
|
autoCapitalize="none"
|
|
/>
|
|
</View>
|
|
|
|
<Button
|
|
className="mb-4 h-10 rounded-lg bg-primary"
|
|
onPress={() =>
|
|
tab === "proforma"
|
|
? nav.go("proforma/create")
|
|
: nav.go("proforma-requests/create")
|
|
}
|
|
>
|
|
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
|
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
|
{tab === "proforma" ? "Create New Proforma" : "Create Request"}
|
|
</Text>
|
|
</Button>
|
|
|
|
{/* Tabs */}
|
|
<View className="flex-row bg-card border border-border rounded-xl p-1 mb-4">
|
|
<Pressable
|
|
onPress={() => {
|
|
if (tab !== "proforma") {
|
|
setTab("proforma");
|
|
setSearch("");
|
|
}
|
|
}}
|
|
className={`flex-1 py-2 rounded-[8px] items-center ${
|
|
tab === "proforma" ? "bg-primary" : ""
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`text-[11px] font-sans-bold uppercase tracking-widest ${
|
|
tab === "proforma" ? "text-white" : "text-muted-foreground"
|
|
}`}
|
|
>
|
|
Proforma
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => {
|
|
if (tab !== "request") {
|
|
setTab("request");
|
|
setSearch("");
|
|
}
|
|
}}
|
|
className={`flex-1 py-2 rounded-[8px] items-center ${
|
|
tab === "request" ? "bg-primary" : ""
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`text-[11px] font-sans-bold uppercase tracking-widest ${
|
|
tab === "request" ? "text-white" : "text-muted-foreground"
|
|
}`}
|
|
>
|
|
Requests
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View className="gap-2">
|
|
{items.length > 0 ? (
|
|
tab === "proforma"
|
|
? (items as ProformaItem[]).map(renderProformaCard)
|
|
: (items as ProformaRequest[]).map(renderRequestCard)
|
|
) : (
|
|
<EmptyState
|
|
title={
|
|
search
|
|
? "No matching results"
|
|
: tab === "proforma"
|
|
? "No proformas yet"
|
|
: "No proforma requests yet"
|
|
}
|
|
description={
|
|
!search && tab === "request"
|
|
? "Tap Create Request to publish your first RFQ."
|
|
: undefined
|
|
}
|
|
centered
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{(loadingMore || reqLoadingMore) && (
|
|
<View className="py-4">
|
|
<ActivityIndicator color="#E46212" />
|
|
</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
|
|
<CommandPalette
|
|
visible={searchOpen}
|
|
onClose={() => setSearchOpen(false)}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|