ui
This commit is contained in:
parent
e79ad09043
commit
b6bc3d2d9c
5
app.json
5
app.json
|
|
@ -22,7 +22,10 @@
|
||||||
"com.googleusercontent.apps.1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi"
|
"com.googleusercontent.apps.1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"NSAppTransportSecurity": {
|
||||||
|
"NSAllowsArbitraryLoads": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,16 @@ import {
|
||||||
DollarSign,
|
DollarSign,
|
||||||
FileText,
|
FileText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Receipt,
|
|
||||||
Wallet,
|
Wallet,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Banknote,
|
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
Building2,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { getProviderLogo, isCash } from "@/lib/payment-providers";
|
|
||||||
|
|
||||||
interface NewsItem {
|
interface NewsItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -49,6 +47,8 @@ interface Payment {
|
||||||
currency: string;
|
currency: string;
|
||||||
paymentDate: string;
|
paymentDate: string;
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
|
financialInstitution?: string;
|
||||||
|
financialInstitutionLogoUrl?: string;
|
||||||
isFlagged: boolean;
|
isFlagged: boolean;
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
receiverName?: string;
|
receiverName?: string;
|
||||||
|
|
@ -222,11 +222,6 @@ export default function HomeScreen() {
|
||||||
label="Proforma"
|
label="Proforma"
|
||||||
onPress={() => nav.go("proforma")}
|
onPress={() => nav.go("proforma")}
|
||||||
/>
|
/>
|
||||||
<QuickActionInline
|
|
||||||
icon={<Receipt color="white" size={18} strokeWidth={1.5} />}
|
|
||||||
label="Receipt"
|
|
||||||
onPress={() => nav.go("add-receipt")}
|
|
||||||
/>
|
|
||||||
<QuickActionInline
|
<QuickActionInline
|
||||||
icon={
|
icon={
|
||||||
<ShieldCheck color="white" size={18} strokeWidth={1.5} />
|
<ShieldCheck color="white" size={18} strokeWidth={1.5} />
|
||||||
|
|
@ -241,6 +236,13 @@ export default function HomeScreen() {
|
||||||
label="Declaration"
|
label="Declaration"
|
||||||
onPress={() => nav.go("declarations/index")}
|
onPress={() => nav.go("declarations/index")}
|
||||||
/>
|
/>
|
||||||
|
<QuickActionInline
|
||||||
|
icon={
|
||||||
|
<Building2 color="white" size={18} strokeWidth={1.5} />
|
||||||
|
}
|
||||||
|
label="Company"
|
||||||
|
onPress={() => nav.go("company-details")}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -273,8 +275,7 @@ export default function HomeScreen() {
|
||||||
const dateStr = new Date(
|
const dateStr = new Date(
|
||||||
pay.paymentDate,
|
pay.paymentDate,
|
||||||
).toLocaleDateString();
|
).toLocaleDateString();
|
||||||
const logo = getProviderLogo(pay.paymentMethod);
|
const logoUrl = pay.financialInstitutionLogoUrl;
|
||||||
const cash = isCash(pay.paymentMethod);
|
|
||||||
const hasFlag = pay.isFlagged;
|
const hasFlag = pay.isFlagged;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|
@ -283,10 +284,10 @@ export default function HomeScreen() {
|
||||||
>
|
>
|
||||||
<Card className="rounded-xl border-border bg-card overflow-hidden">
|
<Card className="rounded-xl border-border bg-card overflow-hidden">
|
||||||
<View className="flex-row items-center px-3 py-3">
|
<View className="flex-row items-center px-3 py-3">
|
||||||
{logo ? (
|
{logoUrl ? (
|
||||||
<View className="w-10 h-10 items-center justify-center mr-3 overflow-hidden">
|
<View className="w-10 h-10 rounded-lg items-center justify-center mr-3 overflow-hidden bg-white">
|
||||||
<Image
|
<Image
|
||||||
source={logo}
|
source={{ uri: logoUrl }}
|
||||||
className="w-7 h-7"
|
className="w-7 h-7"
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
|
|
@ -294,11 +295,7 @@ export default function HomeScreen() {
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
className={`w-10 h-10 rounded-lg items-center justify-center mr-3 ${
|
className={`w-10 h-10 rounded-lg items-center justify-center mr-3 ${
|
||||||
hasFlag
|
hasFlag ? "bg-red-500/10" : "bg-primary/10"
|
||||||
? "bg-red-500/10"
|
|
||||||
: cash
|
|
||||||
? "bg-green-500/10"
|
|
||||||
: "bg-primary/10"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{hasFlag ? (
|
{hasFlag ? (
|
||||||
|
|
@ -307,12 +304,6 @@ export default function HomeScreen() {
|
||||||
size={18}
|
size={18}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
) : cash ? (
|
|
||||||
<Banknote
|
|
||||||
color="#16a34a"
|
|
||||||
size={18}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Wallet
|
<Wallet
|
||||||
color="#E46212"
|
color="#E46212"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { FileText, Plus, Search } from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { CreateMethodSheet } from "@/components/CreateMethodSheet";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { getPlaceholderColor } from "@/lib/colors";
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ export default function InvoicesTabScreen() {
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [showCreateSheet, setShowCreateSheet] = useState(false);
|
||||||
|
|
||||||
const fetchPage = useCallback(async (pageNum: number, replace = false) => {
|
const fetchPage = useCallback(async (pageNum: number, replace = false) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -171,7 +173,7 @@ export default function InvoicesTabScreen() {
|
||||||
{/* Create button */}
|
{/* Create button */}
|
||||||
<Button
|
<Button
|
||||||
className="mb-4 h-10 rounded-lg bg-primary"
|
className="mb-4 h-10 rounded-lg bg-primary"
|
||||||
onPress={() => nav.go("invoices/create")}
|
onPress={() => setShowCreateSheet(true)}
|
||||||
>
|
>
|
||||||
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
||||||
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
||||||
|
|
@ -275,6 +277,20 @@ export default function InvoicesTabScreen() {
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<CreateMethodSheet
|
||||||
|
visible={showCreateSheet}
|
||||||
|
onClose={() => setShowCreateSheet(false)}
|
||||||
|
title="Create Invoice"
|
||||||
|
onSelectScan={() => {
|
||||||
|
setShowCreateSheet(false);
|
||||||
|
nav.go("scan");
|
||||||
|
}}
|
||||||
|
onSelectManual={() => {
|
||||||
|
setShowCreateSheet(false);
|
||||||
|
nav.go("invoices/create");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,18 +21,17 @@ import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Banknote,
|
|
||||||
FileText,
|
FileText,
|
||||||
Clock,
|
Clock,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { CreateMethodSheet } from "@/components/CreateMethodSheet";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { getPlaceholderColor } from "@/lib/colors";
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
import { getProviderLogo, isCash } from "@/lib/payment-providers";
|
|
||||||
|
|
||||||
type Tab = "payment" | "request";
|
type Tab = "payment" | "request";
|
||||||
|
|
||||||
|
|
@ -43,6 +42,8 @@ interface Payment {
|
||||||
currency: string;
|
currency: string;
|
||||||
paymentDate: string;
|
paymentDate: string;
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
|
financialInstitution?: string;
|
||||||
|
financialInstitutionLogoUrl?: string;
|
||||||
isFlagged: boolean;
|
isFlagged: boolean;
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
receiverName?: string;
|
receiverName?: string;
|
||||||
|
|
@ -102,6 +103,7 @@ export default function PaymentsScreen() {
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [showCreateSheet, setShowCreateSheet] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
// Request state
|
// Request state
|
||||||
|
|
@ -232,8 +234,7 @@ export default function PaymentsScreen() {
|
||||||
|
|
||||||
const renderPaymentItem = (pay: Payment) => {
|
const renderPaymentItem = (pay: Payment) => {
|
||||||
const dateStr = new Date(pay.paymentDate).toLocaleDateString();
|
const dateStr = new Date(pay.paymentDate).toLocaleDateString();
|
||||||
const logo = getProviderLogo(pay.paymentMethod);
|
const logoUrl = pay.financialInstitutionLogoUrl;
|
||||||
const cash = isCash(pay.paymentMethod);
|
|
||||||
const hasFlag = pay.isFlagged;
|
const hasFlag = pay.isFlagged;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -243,24 +244,22 @@ export default function PaymentsScreen() {
|
||||||
>
|
>
|
||||||
<Card className="rounded-xl border-border bg-card overflow-hidden mb-2">
|
<Card className="rounded-xl border-border bg-card overflow-hidden mb-2">
|
||||||
<View className="flex-row items-center px-3 py-3">
|
<View className="flex-row items-center px-3 py-3">
|
||||||
{logo ? (
|
{logoUrl ? (
|
||||||
<View className="w-10 h-10 items-center justify-center mr-3 overflow-hidden">
|
<View className="w-10 h-10 rounded-lg items-center justify-center mr-3 overflow-hidden bg-white">
|
||||||
<Image source={logo} className="w-7 h-7" resizeMode="contain" />
|
<Image
|
||||||
|
source={{ uri: logoUrl }}
|
||||||
|
className="w-7 h-7"
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
className={`w-10 h-10 rounded-lg items-center justify-center mr-3 ${
|
className={`w-10 h-10 rounded-lg items-center justify-center mr-3 ${
|
||||||
hasFlag
|
hasFlag ? "bg-red-500/10" : "bg-primary/10"
|
||||||
? "bg-red-500/10"
|
|
||||||
: cash
|
|
||||||
? "bg-green-500/10"
|
|
||||||
: "bg-primary/10"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{hasFlag ? (
|
{hasFlag ? (
|
||||||
<AlertTriangle color="#EF435E" size={18} strokeWidth={2} />
|
<AlertTriangle color="#EF435E" size={18} strokeWidth={2} />
|
||||||
) : cash ? (
|
|
||||||
<Banknote color="#16a34a" size={18} strokeWidth={2} />
|
|
||||||
) : (
|
) : (
|
||||||
<Wallet color="#E46212" size={18} strokeWidth={2} />
|
<Wallet color="#E46212" size={18} strokeWidth={2} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -389,11 +388,13 @@ export default function PaymentsScreen() {
|
||||||
{/* Create button */}
|
{/* Create button */}
|
||||||
<Button
|
<Button
|
||||||
className="mb-4 h-10 rounded-lg bg-primary"
|
className="mb-4 h-10 rounded-lg bg-primary"
|
||||||
onPress={() =>
|
onPress={() => {
|
||||||
tab === "payment"
|
if (tab === "request") {
|
||||||
? nav.go("payments/create")
|
nav.go("payment-requests/create");
|
||||||
: nav.go("payment-requests/create")
|
} else {
|
||||||
}
|
setShowCreateSheet(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
||||||
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
||||||
|
|
@ -475,6 +476,20 @@ export default function PaymentsScreen() {
|
||||||
visible={searchOpen}
|
visible={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CreateMethodSheet
|
||||||
|
visible={showCreateSheet}
|
||||||
|
onClose={() => setShowCreateSheet(false)}
|
||||||
|
title={tab === "payment" ? "Create Payment" : "Create Request"}
|
||||||
|
onSelectScan={() => {
|
||||||
|
setShowCreateSheet(false);
|
||||||
|
nav.go("scan");
|
||||||
|
}}
|
||||||
|
onSelectManual={() => {
|
||||||
|
setShowCreateSheet(false);
|
||||||
|
nav.go(tab === "payment" ? "payments/create" : "payment-requests/create");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,18 @@ export default function ScanScreen() {
|
||||||
toast.success("Success!", `Extracted data from ${label} successfully.`);
|
toast.success("Success!", `Extracted data from ${label} successfully.`);
|
||||||
|
|
||||||
const ocr = scanResult.data || {};
|
const ocr = scanResult.data || {};
|
||||||
setScanData(ocr);
|
const id =
|
||||||
|
scanType === "invoice" ? scanResult.invoiceId : scanResult.paymentId;
|
||||||
|
if (!id) {
|
||||||
|
throw new Error(
|
||||||
|
"Scan succeeded but no record ID was returned. Please try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setScanData({
|
||||||
|
type: scanType === "invoice" ? "invoice" : "payment",
|
||||||
|
id,
|
||||||
|
data: ocr,
|
||||||
|
});
|
||||||
|
|
||||||
setPreviewUri(null);
|
setPreviewUri(null);
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
|
|
@ -151,7 +162,7 @@ export default function ScanScreen() {
|
||||||
if (scanType === "invoice") {
|
if (scanType === "invoice") {
|
||||||
nav.go("invoices/create");
|
nav.go("invoices/create");
|
||||||
} else {
|
} else {
|
||||||
nav.go("add-receipt");
|
nav.go("payments/create");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
|
||||||
|
|
@ -1,887 +0,0 @@
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Pressable,
|
|
||||||
TextInput,
|
|
||||||
StyleSheet,
|
|
||||||
ActivityIndicator,
|
|
||||||
Switch,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { useColorScheme } from "nativewind";
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Calendar,
|
|
||||||
ChevronDown,
|
|
||||||
DollarSign,
|
|
||||||
Send,
|
|
||||||
CalendarSearch,
|
|
||||||
Clock,
|
|
||||||
User,
|
|
||||||
Phone,
|
|
||||||
Building2,
|
|
||||||
Hash,
|
|
||||||
Banknote,
|
|
||||||
Upload,
|
|
||||||
} from "@/lib/icons";
|
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
|
||||||
import { AppRoutes } from "@/lib/routes";
|
|
||||||
import { api, BASE_URL } from "@/lib/api";
|
|
||||||
import { toast } from "@/lib/toast-store";
|
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
|
||||||
import * as ImagePicker from "expo-image-picker";
|
|
||||||
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
|
||||||
import { CalendarGrid } from "@/components/CalendarGrid";
|
|
||||||
import { TimerPickerModal } from "react-native-timer-picker";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { getPlaceholderColor } from "@/lib/colors";
|
|
||||||
import { getScanData } from "@/lib/scan-cache";
|
|
||||||
import { FormFlow } from "@/components/FormFlow";
|
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
|
||||||
input: {
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: "500",
|
|
||||||
borderRadius: 6,
|
|
||||||
borderWidth: 1,
|
|
||||||
textAlignVertical: "center",
|
|
||||||
},
|
|
||||||
inputCenter: {
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "500",
|
|
||||||
borderRadius: 6,
|
|
||||||
borderWidth: 1,
|
|
||||||
textAlign: "center",
|
|
||||||
textAlignVertical: "center",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function useInputColors() {
|
|
||||||
const { colorScheme } = useColorScheme();
|
|
||||||
const dark = colorScheme === "dark";
|
|
||||||
return {
|
|
||||||
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
|
||||||
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
|
||||||
text: dark ? "#f1f5f9" : "#0f172a",
|
|
||||||
placeholder: "rgba(100,116,139,0.45)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
placeholder,
|
|
||||||
numeric = false,
|
|
||||||
center = false,
|
|
||||||
flex,
|
|
||||||
multiline = false,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChangeText: (v: string) => void;
|
|
||||||
placeholder: string;
|
|
||||||
numeric?: boolean;
|
|
||||||
center?: boolean;
|
|
||||||
flex?: number;
|
|
||||||
multiline?: boolean;
|
|
||||||
}) {
|
|
||||||
const c = useInputColors();
|
|
||||||
return (
|
|
||||||
<View style={flex != null ? { flex } : undefined}>
|
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
center ? S.inputCenter : S.input,
|
|
||||||
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
|
|
||||||
multiline
|
|
||||||
? { height: 80, paddingTop: 10, textAlignVertical: "top" }
|
|
||||||
: {},
|
|
||||||
]}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor={c.placeholder}
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChangeText}
|
|
||||||
keyboardType={numeric ? "numeric" : "default"}
|
|
||||||
multiline={multiline}
|
|
||||||
autoCorrect={false}
|
|
||||||
autoCapitalize="none"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Label({ children }: { children: string }) {
|
|
||||||
return (
|
|
||||||
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currencies = ["ETB", "USD", "EUR", "GBP", "KES", "ZAR"];
|
|
||||||
const paymentMethods = [
|
|
||||||
"Telebirr",
|
|
||||||
"CBE",
|
|
||||||
"Dashen",
|
|
||||||
"DECSI",
|
|
||||||
"Bank Transfer",
|
|
||||||
"Cash",
|
|
||||||
"Other",
|
|
||||||
];
|
|
||||||
const providers = ["telebirr", "cbe", "dashen", "decsi", "other"];
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
{ key: "payment", label: "Payment Details" },
|
|
||||||
{ key: "transaction", label: "Transaction" },
|
|
||||||
{ key: "merchant", label: "Merchant" },
|
|
||||||
{ key: "sender", label: "Sender" },
|
|
||||||
{ key: "verification", label: "Verification" },
|
|
||||||
{ key: "summary", label: "Summary" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AddReceiptScreen() {
|
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
|
||||||
const [step, setStep] = useState(0);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
const [scanFailures, setScanFailures] = useState(0);
|
|
||||||
const token = useAuthStore((s) => s.token);
|
|
||||||
|
|
||||||
const [amount, setAmount] = useState("");
|
|
||||||
const [currency, setCurrency] = useState("ETB");
|
|
||||||
const [paymentDate, setPaymentDate] = useState(
|
|
||||||
new Date().toISOString().split("T")[0],
|
|
||||||
);
|
|
||||||
const [paymentTime, setPaymentTime] = useState(
|
|
||||||
new Date().toLocaleTimeString("en-US", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const [paymentMethod, setPaymentMethod] = useState("Telebirr");
|
|
||||||
const [transactionId, setTransactionId] = useState("");
|
|
||||||
const [referenceNumber, setReferenceNumber] = useState("");
|
|
||||||
const [merchantName, setMerchantName] = useState("");
|
|
||||||
const [merchantId, setMerchantId] = useState("");
|
|
||||||
const [provider, setProvider] = useState("telebirr");
|
|
||||||
const [senderName, setSenderName] = useState("");
|
|
||||||
const [senderPhone, setSenderPhone] = useState("");
|
|
||||||
const [verifyWithProvider, setVerifyWithProvider] = useState(false);
|
|
||||||
const [verifyWithVerifierApi, setVerifyWithVerifierApi] = useState(false);
|
|
||||||
|
|
||||||
const [showCurrency, setShowCurrency] = useState(false);
|
|
||||||
const [showPaymentMethod, setShowPaymentMethod] = useState(false);
|
|
||||||
const [showProvider, setShowProvider] = useState(false);
|
|
||||||
const [showPaymentDate, setShowPaymentDate] = useState(false);
|
|
||||||
const [showPaymentTime, setShowPaymentTime] = useState(false);
|
|
||||||
|
|
||||||
const { colorScheme } = useColorScheme();
|
|
||||||
const isDark = colorScheme === "dark";
|
|
||||||
const c = useInputColors();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const scanData = getScanData();
|
|
||||||
if (!scanData) return;
|
|
||||||
if (scanData.amount != null) setAmount(String(scanData.amount));
|
|
||||||
if (scanData.currency) setCurrency(scanData.currency);
|
|
||||||
if (scanData.paymentDate) {
|
|
||||||
try {
|
|
||||||
setPaymentDate(
|
|
||||||
new Date(scanData.paymentDate).toISOString().split("T")[0],
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
if (scanData.paymentTime) setPaymentTime(scanData.paymentTime);
|
|
||||||
if (scanData.paymentMethod) setPaymentMethod(scanData.paymentMethod);
|
|
||||||
if (scanData.transactionId) setTransactionId(scanData.transactionId);
|
|
||||||
if (scanData.referenceNumber) setReferenceNumber(scanData.referenceNumber);
|
|
||||||
if (scanData.merchantName) setMerchantName(scanData.merchantName);
|
|
||||||
if (scanData.merchantId) setMerchantId(scanData.merchantId);
|
|
||||||
if (scanData.provider) setProvider(scanData.provider);
|
|
||||||
if (scanData.senderName) setSenderName(scanData.senderName);
|
|
||||||
if (scanData.senderPhone) setSenderPhone(scanData.senderPhone.replace(/^\+251|\++/g, ""));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
if (step === 0) {
|
|
||||||
if (!amount || parseFloat(amount) <= 0) {
|
|
||||||
toast.error(
|
|
||||||
"Validation Error",
|
|
||||||
"Amount is required and must be greater than 0",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setStep((s) => s + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!amount || parseFloat(amount) <= 0) {
|
|
||||||
toast.error(
|
|
||||||
"Validation Error",
|
|
||||||
"Amount is required and must be greater than 0",
|
|
||||||
);
|
|
||||||
throw new Error("Amount is required");
|
|
||||||
}
|
|
||||||
if (!transactionId) {
|
|
||||||
toast.error("Validation Error", "Transaction ID is required");
|
|
||||||
throw new Error("Transaction ID is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
amount: parseFloat(amount),
|
|
||||||
currency,
|
|
||||||
paymentDate,
|
|
||||||
paymentTime,
|
|
||||||
paymentMethod,
|
|
||||||
transactionId,
|
|
||||||
referenceNumber,
|
|
||||||
merchantName,
|
|
||||||
merchantId,
|
|
||||||
provider,
|
|
||||||
senderName,
|
|
||||||
senderPhone: senderPhone ? (senderPhone.startsWith("+") ? senderPhone : `+251${senderPhone}`) : undefined,
|
|
||||||
verifyWithProvider,
|
|
||||||
verifyWithVerifierApi,
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.scan.paymentReceiptManual({ body: payload });
|
|
||||||
toast.success("Success", "Receipt added successfully!");
|
|
||||||
nav.back();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("[AddReceipt] Error:", error);
|
|
||||||
const msg =
|
|
||||||
error?.response?.data?.message ||
|
|
||||||
error?.data?.message ||
|
|
||||||
error?.message ||
|
|
||||||
"Failed to add receipt";
|
|
||||||
toast.error("Error", msg);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePickImage = async () => {
|
|
||||||
try {
|
|
||||||
const { status } =
|
|
||||||
await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
||||||
if (status !== "granted") {
|
|
||||||
toast.error(
|
|
||||||
"Permission Denied",
|
|
||||||
"We need access to your gallery to upload receipts.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
|
||||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
||||||
allowsEditing: true,
|
|
||||||
quality: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
|
||||||
const uri = result.assets[0].uri;
|
|
||||||
await handleProcessImage(uri);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error("[AddReceipt] Pick Image Error:", e);
|
|
||||||
toast.error("Picker Failed", "Could not launch gallery picker.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProcessImage = async (uri: string) => {
|
|
||||||
setScanning(true);
|
|
||||||
toast.info("Processing...", "Uploading receipt to AI extraction engine.");
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
const fileExt = uri.split(".").pop() || "jpg";
|
|
||||||
const fileName = `receipt-${Date.now()}.${fileExt}`;
|
|
||||||
const type = `image/${fileExt === "jpg" ? "jpeg" : fileExt}`;
|
|
||||||
|
|
||||||
formData.append("file", {
|
|
||||||
uri: Platform.OS === "android" ? uri : uri.replace("file://", ""),
|
|
||||||
name: fileName,
|
|
||||||
type: type,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const response = await fetch(`${BASE_URL}scan/payment-receipt`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ message: "Scan processing failed." }));
|
|
||||||
throw new Error(err.message || "AI extraction failed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const scanResult = await response.json();
|
|
||||||
|
|
||||||
if (!scanResult.success) {
|
|
||||||
throw new Error(
|
|
||||||
scanResult.message || "AI extraction was unsuccessful.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Success!", "Data extracted successfully.");
|
|
||||||
|
|
||||||
const ocr = scanResult.data || {};
|
|
||||||
if (ocr.amount != null) setAmount(String(ocr.amount));
|
|
||||||
if (ocr.currency) setCurrency(ocr.currency);
|
|
||||||
if (ocr.paymentDate) {
|
|
||||||
try {
|
|
||||||
setPaymentDate(new Date(ocr.paymentDate).toISOString().split("T")[0]);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
if (ocr.paymentTime) setPaymentTime(ocr.paymentTime);
|
|
||||||
if (ocr.paymentMethod) setPaymentMethod(ocr.paymentMethod);
|
|
||||||
if (ocr.transactionId) setTransactionId(ocr.transactionId);
|
|
||||||
if (ocr.referenceNumber) setReferenceNumber(ocr.referenceNumber);
|
|
||||||
if (ocr.merchantName) setMerchantName(ocr.merchantName);
|
|
||||||
if (ocr.merchantId) setMerchantId(ocr.merchantId);
|
|
||||||
if (ocr.provider) setProvider(ocr.provider);
|
|
||||||
if (ocr.senderName) setSenderName(ocr.senderName);
|
|
||||||
if (ocr.senderPhone) setSenderPhone(ocr.senderPhone.replace(/^\+251|\++/g, ""));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handleSubmit();
|
|
||||||
} catch {
|
|
||||||
const nextCount = scanFailures + 1;
|
|
||||||
setScanFailures(nextCount);
|
|
||||||
if (nextCount >= 2) {
|
|
||||||
toast.info("Scan failed, fill details below", "");
|
|
||||||
} else {
|
|
||||||
toast.error("Extraction failed, try again", "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("[AddReceipt] Extraction Error:", err);
|
|
||||||
|
|
||||||
const nextCount = scanFailures + 1;
|
|
||||||
setScanFailures(nextCount);
|
|
||||||
|
|
||||||
if (nextCount >= 2) {
|
|
||||||
toast.info("Scan failed, fill details below", "");
|
|
||||||
} else {
|
|
||||||
toast.error("Extraction failed, try again", "");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formattedAmount = (parseFloat(amount) || 0).toLocaleString("en-US", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScreenWrapper className="bg-background">
|
|
||||||
<FormFlow
|
|
||||||
steps={STEPS}
|
|
||||||
currentStep={step}
|
|
||||||
onNext={handleNext}
|
|
||||||
onBack={() => setStep(step - 1)}
|
|
||||||
onComplete={handleSubmit}
|
|
||||||
loading={submitting}
|
|
||||||
completeLabel="Add Receipt"
|
|
||||||
>
|
|
||||||
{step === 0 && (
|
|
||||||
<>
|
|
||||||
<Pressable
|
|
||||||
onPress={handlePickImage}
|
|
||||||
disabled={scanning}
|
|
||||||
className="bg-primary/10 border border-primary/20 rounded-[8px] p-4 flex-row items-center gap-3.5 mb-5"
|
|
||||||
>
|
|
||||||
{scanning ? (
|
|
||||||
<ActivityIndicator color="#ea580c" size="small" />
|
|
||||||
) : (
|
|
||||||
<Upload color="#ea580c" size={20} strokeWidth={2.5} />
|
|
||||||
)}
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-primary font-sans-black text-xs">
|
|
||||||
Scan from Gallery
|
|
||||||
</Text>
|
|
||||||
<Text className="text-muted-foreground text-[9px] font-sans-bold mt-0.5">
|
|
||||||
Upload image to auto-fill form
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Label>Payment Details</Label>
|
|
||||||
<View className="bg-card rounded-[6px] py-4 gap-4">
|
|
||||||
<View className="flex-row gap-4">
|
|
||||||
<Field
|
|
||||||
label="Amount"
|
|
||||||
value={amount}
|
|
||||||
onChangeText={setAmount}
|
|
||||||
placeholder="0.00"
|
|
||||||
numeric
|
|
||||||
flex={1}
|
|
||||||
/>
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
|
||||||
Currency
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setShowCurrency(true)}
|
|
||||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
|
||||||
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className="text-xs font-sans-bold"
|
|
||||||
style={{ color: c.text }}
|
|
||||||
>
|
|
||||||
{currency}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-row gap-4">
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
|
||||||
Payment Date
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setShowPaymentDate(true)}
|
|
||||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
|
||||||
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className="text-xs font-sans-medium"
|
|
||||||
style={{ color: c.text }}
|
|
||||||
>
|
|
||||||
{paymentDate}
|
|
||||||
</Text>
|
|
||||||
<CalendarSearch
|
|
||||||
size={14}
|
|
||||||
color="#ea580c"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
|
||||||
Payment Time
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setShowPaymentTime(true)}
|
|
||||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
|
||||||
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className="text-xs font-sans-medium"
|
|
||||||
style={{ color: c.text }}
|
|
||||||
>
|
|
||||||
{paymentTime || "Select"}
|
|
||||||
</Text>
|
|
||||||
<Clock size={14} color="#ea580c" strokeWidth={2.5} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-row gap-4">
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
|
||||||
Payment Method
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setShowPaymentMethod(true)}
|
|
||||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
|
||||||
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className="text-xs font-sans-bold"
|
|
||||||
style={{ color: c.text }}
|
|
||||||
>
|
|
||||||
{paymentMethod}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
|
||||||
Provider
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setShowProvider(true)}
|
|
||||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
|
||||||
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className="text-xs font-sans-bold"
|
|
||||||
style={{ color: c.text }}
|
|
||||||
>
|
|
||||||
{provider}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<>
|
|
||||||
<Label>Transaction Info</Label>
|
|
||||||
<View className="bg-card rounded-[6px] py-4 gap-4">
|
|
||||||
<Field
|
|
||||||
label="Transaction ID"
|
|
||||||
value={transactionId}
|
|
||||||
onChangeText={setTransactionId}
|
|
||||||
placeholder="e.g. DAE9TDSO6T"
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Reference Number"
|
|
||||||
value={referenceNumber}
|
|
||||||
onChangeText={setReferenceNumber}
|
|
||||||
placeholder="e.g. REF-001"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<>
|
|
||||||
<Label>Merchant Details</Label>
|
|
||||||
<View className="bg-card rounded-[6px] py-4 gap-4">
|
|
||||||
<Field
|
|
||||||
label="Merchant Name"
|
|
||||||
value={merchantName}
|
|
||||||
onChangeText={setMerchantName}
|
|
||||||
placeholder="e.g. Acme Corp"
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Merchant ID"
|
|
||||||
value={merchantId}
|
|
||||||
onChangeText={setMerchantId}
|
|
||||||
placeholder="e.g. MER-123"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<>
|
|
||||||
<Label>Sender Info</Label>
|
|
||||||
<View className="bg-card rounded-[6px] py-4 gap-4">
|
|
||||||
<View className="flex-row gap-4">
|
|
||||||
<Field
|
|
||||||
label="Sender Name"
|
|
||||||
value={senderName}
|
|
||||||
onChangeText={setSenderName}
|
|
||||||
placeholder="e.g. John Doe"
|
|
||||||
flex={1}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
|
||||||
Sender Phone
|
|
||||||
</Text>
|
|
||||||
<View className="flex-row items-center h-11 px-3 border border-border rounded-[6px]" style={{ backgroundColor: c.bg, borderColor: c.border }}>
|
|
||||||
<Text className="text-foreground font-sans-bold text-xs">+251</Text>
|
|
||||||
<TextInput
|
|
||||||
className="flex-1 ml-2 text-foreground text-xs font-sans-medium"
|
|
||||||
placeholder="912345678"
|
|
||||||
placeholderTextColor={c.placeholder}
|
|
||||||
value={senderPhone}
|
|
||||||
onChangeText={setSenderPhone}
|
|
||||||
keyboardType="phone-pad"
|
|
||||||
maxLength={9}
|
|
||||||
style={{ textAlignVertical: "center" }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 4 && (
|
|
||||||
<>
|
|
||||||
<Label>Verification</Label>
|
|
||||||
<View className="bg-card rounded-[6px] py-4 gap-6">
|
|
||||||
<View className="flex-row items-center justify-between">
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-[14px] font-sans-bold text-foreground">
|
|
||||||
Verify with Provider
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[12px] text-muted-foreground mt-0.5">
|
|
||||||
Check payment status with the provider
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={verifyWithProvider}
|
|
||||||
onValueChange={setVerifyWithProvider}
|
|
||||||
trackColor={{ false: "#334155", true: "#ea580c" }}
|
|
||||||
thumbColor={verifyWithProvider ? "#fff" : "#64748b"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row items-center justify-between">
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-[14px] font-sans-bold text-foreground">
|
|
||||||
Verify with Verifier API
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[12px] text-muted-foreground mt-0.5">
|
|
||||||
Run verification through the verifier service
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={verifyWithVerifierApi}
|
|
||||||
onValueChange={setVerifyWithVerifierApi}
|
|
||||||
trackColor={{ false: "#334155", true: "#ea580c" }}
|
|
||||||
thumbColor={verifyWithVerifierApi ? "#fff" : "#64748b"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 5 && (
|
|
||||||
<>
|
|
||||||
<Label>Summary</Label>
|
|
||||||
<View className="bg-card rounded-[6px] mt-4 p-4 border border-border gap-3">
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Amount
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{currency} {formattedAmount}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Payment Date
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{paymentDate}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Payment Time
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{paymentTime}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Payment Method
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{paymentMethod}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Provider
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{provider}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Transaction ID
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{transactionId}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{referenceNumber ? (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Reference
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{referenceNumber}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{merchantName ? (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Merchant
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{merchantName}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{merchantId ? (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Merchant ID
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{merchantId}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{senderName ? (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Sender
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{senderName}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{senderPhone ? (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
Sender Phone
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
+251{senderPhone}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<View className="border-t border-border/40 my-1" />
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[16px] font-sans-bold text-foreground">
|
|
||||||
Total
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[16px] font-sans-bold text-primary">
|
|
||||||
{currency} {formattedAmount}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FormFlow>
|
|
||||||
|
|
||||||
<PickerModal
|
|
||||||
visible={showCurrency}
|
|
||||||
onClose={() => setShowCurrency(false)}
|
|
||||||
title="Select Currency"
|
|
||||||
>
|
|
||||||
{currencies.map((curr) => (
|
|
||||||
<SelectOption
|
|
||||||
key={curr}
|
|
||||||
label={curr}
|
|
||||||
value={curr}
|
|
||||||
selected={currency === curr}
|
|
||||||
onSelect={(v) => {
|
|
||||||
setCurrency(v);
|
|
||||||
setShowCurrency(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</PickerModal>
|
|
||||||
|
|
||||||
<PickerModal
|
|
||||||
visible={showPaymentMethod}
|
|
||||||
onClose={() => setShowPaymentMethod(false)}
|
|
||||||
title="Select Payment Method"
|
|
||||||
>
|
|
||||||
{paymentMethods.map((method) => (
|
|
||||||
<SelectOption
|
|
||||||
key={method}
|
|
||||||
label={method}
|
|
||||||
value={method}
|
|
||||||
selected={paymentMethod === method}
|
|
||||||
onSelect={(v) => {
|
|
||||||
setPaymentMethod(v);
|
|
||||||
setShowPaymentMethod(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</PickerModal>
|
|
||||||
|
|
||||||
<PickerModal
|
|
||||||
visible={showProvider}
|
|
||||||
onClose={() => setShowProvider(false)}
|
|
||||||
title="Select Provider"
|
|
||||||
>
|
|
||||||
{providers.map((prov) => (
|
|
||||||
<SelectOption
|
|
||||||
key={prov}
|
|
||||||
label={prov}
|
|
||||||
value={prov}
|
|
||||||
selected={provider === prov}
|
|
||||||
onSelect={(v) => {
|
|
||||||
setProvider(v);
|
|
||||||
setShowProvider(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</PickerModal>
|
|
||||||
|
|
||||||
<PickerModal
|
|
||||||
visible={showPaymentDate}
|
|
||||||
onClose={() => setShowPaymentDate(false)}
|
|
||||||
title="Select Payment Date"
|
|
||||||
>
|
|
||||||
<CalendarGrid
|
|
||||||
selectedDate={paymentDate}
|
|
||||||
onSelect={(v) => {
|
|
||||||
setPaymentDate(v);
|
|
||||||
setShowPaymentDate(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PickerModal>
|
|
||||||
|
|
||||||
<TimerPickerModal
|
|
||||||
visible={showPaymentTime}
|
|
||||||
setIsVisible={setShowPaymentTime}
|
|
||||||
closeOnOverlayPress
|
|
||||||
LinearGradient={LinearGradient}
|
|
||||||
modalTitle="Select Time"
|
|
||||||
onCancel={() => setShowPaymentTime(false)}
|
|
||||||
onConfirm={(pickedDuration) => {
|
|
||||||
const hours = String(pickedDuration.hours ?? 0).padStart(2, "0");
|
|
||||||
const minutes = String(pickedDuration.minutes ?? 0).padStart(2, "0");
|
|
||||||
setPaymentTime(`${hours}:${minutes}`);
|
|
||||||
setShowPaymentTime(false);
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
theme: isDark ? "dark" : "light",
|
|
||||||
|
|
||||||
modalTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
width: "80%",
|
|
||||||
marginHorizontal: 16,
|
|
||||||
},
|
|
||||||
confirmButton: {
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingHorizontal: 40,
|
|
||||||
},
|
|
||||||
cancelButton: {
|
|
||||||
borderRadius: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#EDD5D1",
|
|
||||||
paddingHorizontal: 40,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
hideSeconds
|
|
||||||
/>
|
|
||||||
</ScreenWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Edit,
|
Edit,
|
||||||
|
|
@ -20,6 +21,7 @@ import {
|
||||||
Globe,
|
Globe,
|
||||||
MapPin,
|
MapPin,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Users,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
|
@ -222,6 +224,16 @@ export default function CompanyDetailsScreen() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-2 h-12 rounded-[10px] bg-primary"
|
||||||
|
onPress={() => nav.go("team/index")}
|
||||||
|
>
|
||||||
|
<Users color="white" size={18} strokeWidth={2} />
|
||||||
|
<Text className="text-white font-sans-bold text-sm tracking-widest ml-2">
|
||||||
|
Manage Team
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import React, { useState, useCallback, useMemo } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
TextInput,
|
|
||||||
Modal,
|
Modal,
|
||||||
Dimensions,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
|
@ -22,19 +20,13 @@ import {
|
||||||
Tag,
|
Tag,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
FileText,
|
Pencil,
|
||||||
Wallet,
|
Trash2,
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
X,
|
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
import { useColorScheme } from "nativewind";
|
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
|
||||||
|
|
||||||
export default function CustomerDetailScreen() {
|
export default function CustomerDetailScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
|
@ -42,14 +34,8 @@ export default function CustomerDetailScreen() {
|
||||||
|
|
||||||
const [data, setData] = useState<any>(null);
|
const [data, setData] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [proformaItems, setProformaItems] = useState<any[]>([]);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [paymentRequestItems, setPaymentRequestItems] = useState<any[]>([]);
|
|
||||||
const [showProformaSheet, setShowProformaSheet] = useState(false);
|
|
||||||
const [showPaymentRequestSheet, setShowPaymentRequestSheet] = useState(false);
|
|
||||||
const [sheetLoading, setSheetLoading] = useState(false);
|
|
||||||
const [proformaSearch, setProformaSearch] = useState("");
|
|
||||||
const [paymentReqSearch, setPaymentReqSearch] = useState("");
|
|
||||||
|
|
||||||
const fetch = useCallback(async () => {
|
const fetch = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -68,56 +54,21 @@ export default function CustomerDetailScreen() {
|
||||||
|
|
||||||
useFocusEffect(useCallback(() => { fetch(); }, [fetch]));
|
useFocusEffect(useCallback(() => { fetch(); }, [fetch]));
|
||||||
|
|
||||||
const openProformaSheet = async () => {
|
const handleDelete = async () => {
|
||||||
setSheetLoading(true);
|
|
||||||
setShowProformaSheet(true);
|
|
||||||
setProformaSearch("");
|
|
||||||
try {
|
try {
|
||||||
const res = await api.proforma.getAll({ query: { page: 1, limit: 50 } });
|
setDeleting(true);
|
||||||
setProformaItems(res?.data || []);
|
const cId = Array.isArray(id) ? id[0] : id;
|
||||||
} catch {
|
await api.customers.delete({ params: { id: cId } });
|
||||||
setProformaItems([]);
|
toast.success("Deleted", "Customer has been deleted");
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("Error", err?.message || "Failed to delete customer");
|
||||||
} finally {
|
} finally {
|
||||||
setSheetLoading(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPaymentRequestSheet = async () => {
|
|
||||||
setSheetLoading(true);
|
|
||||||
setShowPaymentRequestSheet(true);
|
|
||||||
setPaymentReqSearch("");
|
|
||||||
try {
|
|
||||||
const res = await api.paymentRequests.getAll({ query: { page: 1, limit: 50 } });
|
|
||||||
setPaymentRequestItems(res?.data || []);
|
|
||||||
} catch {
|
|
||||||
setPaymentRequestItems([]);
|
|
||||||
} finally {
|
|
||||||
setSheetLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredProformas = useMemo(() => {
|
|
||||||
if (!proformaSearch.trim()) return proformaItems;
|
|
||||||
const q = proformaSearch.toLowerCase();
|
|
||||||
return proformaItems.filter(
|
|
||||||
(p: any) =>
|
|
||||||
(p.proformaNumber || "")?.toLowerCase().includes(q) ||
|
|
||||||
(p.customerName || "")?.toLowerCase().includes(q) ||
|
|
||||||
(String(p.amount || "")).includes(q),
|
|
||||||
);
|
|
||||||
}, [proformaItems, proformaSearch]);
|
|
||||||
|
|
||||||
const filteredPaymentRequests = useMemo(() => {
|
|
||||||
if (!paymentReqSearch.trim()) return paymentRequestItems;
|
|
||||||
const q = paymentReqSearch.toLowerCase();
|
|
||||||
return paymentRequestItems.filter(
|
|
||||||
(r: any) =>
|
|
||||||
(r.paymentRequestNumber || "")?.toLowerCase().includes(q) ||
|
|
||||||
(r.customerName || "")?.toLowerCase().includes(q) ||
|
|
||||||
(String(r.amount || "")).includes(q),
|
|
||||||
);
|
|
||||||
}, [paymentRequestItems, paymentReqSearch]);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
|
|
@ -148,9 +99,6 @@ export default function CustomerDetailScreen() {
|
||||||
const isCompany = data?.type === "COMPANY";
|
const isCompany = data?.type === "COMPANY";
|
||||||
const d = data || {};
|
const d = data || {};
|
||||||
|
|
||||||
const goProformaCreate = () => nav.go("proforma/create");
|
|
||||||
const goPaymentRequestCreate = () => nav.go("payment-requests/create");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<Stack.Screen options={{ headerShown: false }} />
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
|
@ -283,218 +231,87 @@ export default function CustomerDetailScreen() {
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<View className="px-5 mb-6 gap-3">
|
<View className="px-5 mb-6 gap-3">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={openProformaSheet}
|
onPress={() => {
|
||||||
className="bg-primary h-10 rounded-[6px] flex-row items-center justify-center gap-2"
|
const cId = Array.isArray(id) ? id[0] : id;
|
||||||
|
nav.go("customers/edit", { id: cId });
|
||||||
|
}}
|
||||||
|
className="bg-primary h-11 rounded-[6px] flex-row items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<FileText color="white" size={15} strokeWidth={2.5} />
|
<Pencil color="white" size={16} strokeWidth={2.5} />
|
||||||
<Text className="text-white font-sans-bold text-[11px] uppercase tracking-widest">
|
<Text className="text-white font-sans-bold text-[11px] uppercase tracking-widest">
|
||||||
Proformas
|
Edit Customer
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={openPaymentRequestSheet}
|
onPress={() => setShowDeleteModal(true)}
|
||||||
className="bg-primary h-10 rounded-[6px] flex-row items-center justify-center gap-2"
|
className="bg-red-500 h-11 rounded-[6px] flex-row items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Wallet color="white" size={15} strokeWidth={2.5} />
|
<Trash2 color="white" size={16} strokeWidth={2.5} />
|
||||||
<Text className="text-white font-sans-bold text-[11px] uppercase tracking-widest">
|
<Text className="text-white font-sans-bold text-[11px] uppercase tracking-widest">
|
||||||
Payment Requests
|
Delete Customer
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Proforma Bottom Sheet */}
|
{/* Delete Confirmation Modal */}
|
||||||
<ProformaSheet
|
<Modal
|
||||||
visible={showProformaSheet}
|
visible={showDeleteModal}
|
||||||
onClose={() => setShowProformaSheet(false)}
|
transparent
|
||||||
loading={sheetLoading}
|
animationType="fade"
|
||||||
items={filteredProformas}
|
onRequestClose={() => setShowDeleteModal(false)}
|
||||||
search={proformaSearch}
|
>
|
||||||
onSearchChange={setProformaSearch}
|
<Pressable
|
||||||
onCreateNew={goProformaCreate}
|
className="flex-1 bg-black/50 items-center justify-center px-8"
|
||||||
onSelectItem={(id: string) => {
|
onPress={() => setShowDeleteModal(false)}
|
||||||
setShowProformaSheet(false);
|
>
|
||||||
nav.go("proforma/[id]", { id });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Payment Request Bottom Sheet */}
|
|
||||||
<ProformaSheet
|
|
||||||
visible={showPaymentRequestSheet}
|
|
||||||
onClose={() => setShowPaymentRequestSheet(false)}
|
|
||||||
loading={sheetLoading}
|
|
||||||
items={filteredPaymentRequests}
|
|
||||||
search={paymentReqSearch}
|
|
||||||
onSearchChange={setPaymentReqSearch}
|
|
||||||
onCreateNew={goPaymentRequestCreate}
|
|
||||||
onSelectItem={(id: string) => {
|
|
||||||
setShowPaymentRequestSheet(false);
|
|
||||||
nav.go("payment-requests/[id]", { id });
|
|
||||||
}}
|
|
||||||
type="payment"
|
|
||||||
/>
|
|
||||||
</ScreenWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProformaSheet({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
loading,
|
|
||||||
items,
|
|
||||||
search,
|
|
||||||
onSearchChange,
|
|
||||||
onCreateNew,
|
|
||||||
onSelectItem,
|
|
||||||
type = "proforma",
|
|
||||||
}: {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
loading: boolean;
|
|
||||||
items: any[];
|
|
||||||
search: string;
|
|
||||||
onSearchChange: (v: string) => void;
|
|
||||||
onCreateNew: () => void;
|
|
||||||
onSelectItem: (id: string) => void;
|
|
||||||
type?: "proforma" | "payment";
|
|
||||||
}) {
|
|
||||||
const { colorScheme } = useColorScheme();
|
|
||||||
const isDark = colorScheme === "dark";
|
|
||||||
const label = type === "proforma" ? "Proforma" : "Payment Request";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent
|
|
||||||
animationType="slide"
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
|
|
||||||
<View className="flex-1 justify-end">
|
|
||||||
<Pressable
|
<Pressable
|
||||||
className="bg-card rounded-t-[36px] overflow-hidden border-t-[3px] border-border/20"
|
className="bg-card rounded-2xl p-6 w-full border border-border"
|
||||||
style={{ maxHeight: SCREEN_HEIGHT * 0.8 }}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
onPress={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<View className="items-center mb-5">
|
||||||
<View className="px-6 pb-4 pt-4 flex-row justify-between items-center">
|
<View className="w-14 h-14 rounded-full bg-red-500/10 items-center justify-center mb-4">
|
||||||
<View className="w-10" />
|
<Trash2 color="#EF4444" size={24} strokeWidth={2} />
|
||||||
<Text className="text-foreground font-sans-bold text-[18px]">
|
|
||||||
{label}s
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={onClose}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<View className="px-5 pb-4">
|
|
||||||
<View className="bg-background rounded-[6px] border border-border flex-row items-center px-3.5 py-2.5">
|
|
||||||
<Search size={15} color="#94a3b8" strokeWidth={2} />
|
|
||||||
<TextInput
|
|
||||||
className="flex-1 ml-2.5 text-foreground font-sans-medium text-sm"
|
|
||||||
placeholder={`Search ${label}s...`}
|
|
||||||
placeholderTextColor="#94a3b8"
|
|
||||||
value={search}
|
|
||||||
onChangeText={onSearchChange}
|
|
||||||
/>
|
|
||||||
{search.length > 0 && (
|
|
||||||
<Pressable onPress={() => onSearchChange("")}>
|
|
||||||
<X size={14} color="#94a3b8" strokeWidth={2.5} />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground text-center">
|
||||||
|
Delete Customer?
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-sm font-sans-medium text-center mt-2 leading-5">
|
||||||
|
This will permanently delete{" "}
|
||||||
|
<Text className="font-sans-bold text-foreground">
|
||||||
|
{d.displayName}
|
||||||
|
</Text>{" "}
|
||||||
|
and all associated data. This action cannot be undone.
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Create New */}
|
<View className="gap-3">
|
||||||
<View className="px-5 pb-5">
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onCreateNew}
|
onPress={handleDelete}
|
||||||
className="bg-primary rounded-[6px] py-3.5 flex-row items-center justify-center gap-2"
|
disabled={deleting}
|
||||||
|
className="bg-red-500 h-12 rounded-[6px] items-center justify-center"
|
||||||
>
|
>
|
||||||
<Plus size={16} color="white" strokeWidth={2.5} />
|
{deleting ? (
|
||||||
<Text className="text-white font-sans-bold text-sm">
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
Create New {label}
|
) : (
|
||||||
|
<Text className="text-white font-sans-bold text-sm">
|
||||||
|
Yes, Delete
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowDeleteModal(false)}
|
||||||
|
className="bg-secondary h-12 rounded-[6px] items-center justify-center border border-border"
|
||||||
|
>
|
||||||
|
<Text className="text-foreground font-sans-bold text-sm">
|
||||||
|
Cancel
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
<ScrollView
|
|
||||||
className="px-5"
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
contentContainerStyle={{ paddingBottom: 40 }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<View className="py-8 items-center">
|
|
||||||
<ActivityIndicator color="#E46212" size="small" />
|
|
||||||
</View>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<View className="py-8 items-center">
|
|
||||||
<Text className="text-muted-foreground text-sm font-sans-medium">
|
|
||||||
{search ? `No ${label}s match your search` : `No ${label}s found`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
items.map((item: any) => {
|
|
||||||
const num = type === "proforma"
|
|
||||||
? item.proformaNumber
|
|
||||||
: item.paymentRequestNumber;
|
|
||||||
const status = (item.status || "DRAFT").toUpperCase();
|
|
||||||
const st: Record<string, { label: string; bg: string; text: string }> = {
|
|
||||||
PAID: { label: "Paid", bg: "bg-emerald-500/10", text: "text-emerald-600" },
|
|
||||||
PENDING: { label: "Pending", bg: "bg-amber-500/10", text: "text-amber-600" },
|
|
||||||
DRAFT: { label: "Draft", bg: "bg-blue-500/10", text: "text-blue-600" },
|
|
||||||
CANCELLED: { label: "Cancelled", bg: "bg-slate-500/10", text: "text-slate-600" },
|
|
||||||
};
|
|
||||||
const s = st[status] || st.DRAFT;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={item.id}
|
|
||||||
onPress={() => onSelectItem(item.id)}
|
|
||||||
className="bg-card rounded-[6px] border border-border p-4 mb-3"
|
|
||||||
>
|
|
||||||
<View className="flex-row items-start justify-between mb-1.5">
|
|
||||||
<View className="flex-1 mr-3">
|
|
||||||
<Text className="text-foreground font-sans-bold text-sm">
|
|
||||||
{num || item.id?.slice(0, 8) || "—"}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-muted-foreground text-xs font-sans-medium mt-0.5" numberOfLines={1}>
|
|
||||||
{item.customerName || "—"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-foreground font-sans-bold text-sm">
|
|
||||||
{item.amount != null ? Number(item.amount).toLocaleString("en-US", { minimumFractionDigits: 2 }) : "—"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<View className={`px-2 py-0.5 rounded-[3px] ${s.bg}`}>
|
|
||||||
<Text className={`text-[8px] font-sans-bold uppercase tracking-widest ${s.text}`}>
|
|
||||||
{s.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{item.issueDate && (
|
|
||||||
<Text className="text-muted-foreground text-[10px] font-sans-medium">
|
|
||||||
{new Date(item.issueDate).toLocaleDateString()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</Pressable>
|
||||||
</Pressable>
|
</Modal>
|
||||||
</Modal>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
525
app/customers/edit.tsx
Normal file
525
app/customers/edit.tsx
Normal file
|
|
@ -0,0 +1,525 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { View, Pressable, TextInput, StyleSheet, ActivityIndicator } from "react-native";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { FormFlow } from "@/components/FormFlow";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
input: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
textAlignVertical: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function useInputColors() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
placeholder: "rgba(100,116,139,0.45)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
numeric = false,
|
||||||
|
flex,
|
||||||
|
multiline = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
numeric?: boolean;
|
||||||
|
flex?: number;
|
||||||
|
multiline?: boolean;
|
||||||
|
}) {
|
||||||
|
const c = useInputColors();
|
||||||
|
return (
|
||||||
|
<View style={flex != null ? { flex } : undefined}>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
S.input,
|
||||||
|
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
|
||||||
|
multiline
|
||||||
|
? { height: 80, paddingTop: 10, textAlignVertical: "top" }
|
||||||
|
: {},
|
||||||
|
]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={c.placeholder}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType={numeric ? "numeric" : "default"}
|
||||||
|
multiline={multiline}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPES = ["INDIVIDUAL", "COMPANY"] as const;
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: "type", label: "Type" },
|
||||||
|
{ key: "details", label: "Details" },
|
||||||
|
{ key: "documents", label: "Documents" },
|
||||||
|
{ key: "notes", label: "Notes" },
|
||||||
|
{ key: "summary", label: "Summary" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function stripPhone(p?: string | null): string {
|
||||||
|
if (!p) return "";
|
||||||
|
return p.replace(/^\+?251/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditCustomerScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
const c = useInputColors();
|
||||||
|
|
||||||
|
const [type, setType] = useState<"INDIVIDUAL" | "COMPANY">("INDIVIDUAL");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [companyName, setCompanyName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [tin, setTin] = useState("");
|
||||||
|
const [vatReg, setVatReg] = useState("");
|
||||||
|
const [businessLicense, setBusinessLicense] = useState("");
|
||||||
|
const [address, setAddress] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoadingData(true);
|
||||||
|
const cId = Array.isArray(id) ? id[0] : id;
|
||||||
|
if (!cId) return;
|
||||||
|
const data = await api.customers.getById({ params: { id: cId } });
|
||||||
|
setType(data.type || "INDIVIDUAL");
|
||||||
|
setDisplayName(data.displayName || "");
|
||||||
|
setFirstName(data.firstName || "");
|
||||||
|
setLastName(data.lastName || "");
|
||||||
|
setCompanyName(data.companyName || "");
|
||||||
|
setEmail(data.email || "");
|
||||||
|
setPhone(stripPhone(data.phone));
|
||||||
|
setTin(data.tin || "");
|
||||||
|
setVatReg(data.vatRegistrationNumber || "");
|
||||||
|
setBusinessLicense(data.businessLicenseNumber || "");
|
||||||
|
setAddress(data.address || "");
|
||||||
|
setNotes(data.notes || "");
|
||||||
|
} catch {
|
||||||
|
toast.error("Error", "Failed to load customer");
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (step === 0 && !displayName.trim()) {
|
||||||
|
toast.error("Validation", "Display name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 1 && type === "INDIVIDUAL" && !firstName.trim()) {
|
||||||
|
toast.error("Validation", "First name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 1 && type === "COMPANY" && !companyName.trim()) {
|
||||||
|
toast.error("Validation", "Company name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(step + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
type,
|
||||||
|
displayName,
|
||||||
|
email: email || undefined,
|
||||||
|
phone: phone ? `+251${phone.replace(/^\+/, "")}` : undefined,
|
||||||
|
tin: tin || undefined,
|
||||||
|
vatRegistrationNumber: vatReg || undefined,
|
||||||
|
businessLicenseNumber: businessLicense || undefined,
|
||||||
|
address: address || undefined,
|
||||||
|
firstName: firstName || undefined,
|
||||||
|
lastName: lastName || undefined,
|
||||||
|
companyName: companyName || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(body).forEach((k) => body[k] === undefined && delete body[k]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const cId = Array.isArray(id) ? id[0] : id;
|
||||||
|
await api.customers.update({ params: { id: cId }, body });
|
||||||
|
toast.success("Success", "Customer updated successfully!");
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("Error", err?.message || "Failed to update customer");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingData) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#E46212" />
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<FormFlow
|
||||||
|
steps={STEPS}
|
||||||
|
currentStep={step}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={() => setStep(step - 1)}
|
||||||
|
onComplete={handleSubmit}
|
||||||
|
loading={submitting}
|
||||||
|
completeLabel="Update Customer"
|
||||||
|
>
|
||||||
|
{step === 0 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Customer Type
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<View className="flex-row gap-2">
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<Pressable
|
||||||
|
key={t}
|
||||||
|
onPress={() => setType(t)}
|
||||||
|
className={`flex-1 py-3 rounded-[6px] items-center border ${
|
||||||
|
type === t
|
||||||
|
? "bg-primary border-primary"
|
||||||
|
: "bg-card border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-[11px] font-sans-bold uppercase tracking-widest ${
|
||||||
|
type === t ? "text-white" : "text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "INDIVIDUAL" ? "Individual" : "Company"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Field
|
||||||
|
label="Display Name"
|
||||||
|
value={displayName}
|
||||||
|
onChangeText={setDisplayName}
|
||||||
|
placeholder="e.g. John Doe or Acme Corp"
|
||||||
|
/>
|
||||||
|
{type === "INDIVIDUAL" && (
|
||||||
|
<Field
|
||||||
|
label="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
{type === "INDIVIDUAL" ? "Personal Details" : "Company Details"}
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
{type === "INDIVIDUAL" ? (
|
||||||
|
<>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="First Name"
|
||||||
|
value={firstName}
|
||||||
|
onChangeText={setFirstName}
|
||||||
|
placeholder="John"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Last Name"
|
||||||
|
value={lastName}
|
||||||
|
onChangeText={setLastName}
|
||||||
|
placeholder="Doe"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
Phone
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center h-11 px-3 border border-border rounded-[6px]" style={{ backgroundColor: c.bg, borderColor: c.border }}>
|
||||||
|
<Text className="text-foreground font-sans-bold text-xs">+251</Text>
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-2 text-foreground text-xs font-sans-medium"
|
||||||
|
placeholder="912345678"
|
||||||
|
placeholderTextColor={c.placeholder}
|
||||||
|
value={phone}
|
||||||
|
onChangeText={setPhone}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
maxLength={9}
|
||||||
|
style={{ textAlignVertical: "center" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label="Company Name"
|
||||||
|
value={companyName}
|
||||||
|
onChangeText={setCompanyName}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
/>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="info@acme.com"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
Phone
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center h-11 px-3 border border-border rounded-[6px]" style={{ backgroundColor: c.bg, borderColor: c.border }}>
|
||||||
|
<Text className="text-foreground font-sans-bold text-xs">+251</Text>
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-2 text-foreground text-xs font-sans-medium"
|
||||||
|
placeholder="912345678"
|
||||||
|
placeholderTextColor={c.placeholder}
|
||||||
|
value={phone}
|
||||||
|
onChangeText={setPhone}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
maxLength={9}
|
||||||
|
style={{ textAlignVertical: "center" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Field
|
||||||
|
label="Address"
|
||||||
|
value={address}
|
||||||
|
onChangeText={setAddress}
|
||||||
|
placeholder="e.g. Bole Road, Addis Ababa"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Documents
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<Field
|
||||||
|
label="TIN Number"
|
||||||
|
value={tin}
|
||||||
|
onChangeText={setTin}
|
||||||
|
placeholder="e.g. 1234567890"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="VAT Registration"
|
||||||
|
value={vatReg}
|
||||||
|
onChangeText={setVatReg}
|
||||||
|
placeholder="e.g. VAT-12345"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Business License"
|
||||||
|
value={businessLicense}
|
||||||
|
onChangeText={setBusinessLicense}
|
||||||
|
placeholder="e.g. BL-2024-001"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<Field
|
||||||
|
label="Notes"
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="Any additional notes..."
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Summary
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 border border-border gap-3">
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
Type
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{type === "INDIVIDUAL" ? "Individual" : "Company"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
Display Name
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{firstName ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
First Name
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{firstName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{lastName ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
Last Name
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{lastName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{companyName ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
Company
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{companyName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{email ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
Email
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{email}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{phone ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
Phone
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
+251{phone}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{tin ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
TIN
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{tin}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{vatReg ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
VAT Reg
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{vatReg}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{businessLicense ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
Business License
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{businessLicense}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{address ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
Address
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{notes ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
Notes
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{notes}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</FormFlow>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,8 @@ import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
import { FormFlow } from "@/components/FormFlow";
|
import { FormFlow } from "@/components/FormFlow";
|
||||||
import { getPlaceholderColor } from "@/lib/colors";
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
|
import { getScanData } from "@/lib/scan-cache";
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
|
@ -187,6 +189,9 @@ export default function CreateDeclarationScreen() {
|
||||||
// Pickers & Modals
|
// Pickers & Modals
|
||||||
const [showTypePicker, setShowTypePicker] = useState(false);
|
const [showTypePicker, setShowTypePicker] = useState(false);
|
||||||
const [showPeriodPicker, setShowPeriodPicker] = useState(false);
|
const [showPeriodPicker, setShowPeriodPicker] = useState(false);
|
||||||
|
const [showPeriodStart, setShowPeriodStart] = useState(false);
|
||||||
|
const [showPeriodEnd, setShowPeriodEnd] = useState(false);
|
||||||
|
const [showDueDate, setShowDueDate] = useState(false);
|
||||||
const [showInvoicePicker, setShowInvoicePicker] = useState(false);
|
const [showInvoicePicker, setShowInvoicePicker] = useState(false);
|
||||||
const [invoices, setInvoices] = useState<any[]>([]);
|
const [invoices, setInvoices] = useState<any[]>([]);
|
||||||
const [invoiceSearch, setInvoiceSearch] = useState("");
|
const [invoiceSearch, setInvoiceSearch] = useState("");
|
||||||
|
|
@ -200,6 +205,29 @@ export default function CreateDeclarationScreen() {
|
||||||
{ key: "review", label: "Review" },
|
{ key: "review", label: "Review" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scan = getScanData();
|
||||||
|
if (scan?.type === "declaration" && scan.data) {
|
||||||
|
const d = scan.data;
|
||||||
|
if (d.type) setType(d.type);
|
||||||
|
if (d.header?.declarationNumber) setDeclarationNumber(d.header.declarationNumber);
|
||||||
|
if (d.header?.title) setTitle(d.header.title);
|
||||||
|
if (d.suggestedTitle) setTitle(d.suggestedTitle);
|
||||||
|
if (d.suggestedPeriodStart) {
|
||||||
|
const start = new Date(d.suggestedPeriodStart).toISOString().split("T")[0];
|
||||||
|
setPeriodStart(start);
|
||||||
|
}
|
||||||
|
if (d.suggestedPeriodEnd) {
|
||||||
|
const end = new Date(d.suggestedPeriodEnd).toISOString().split("T")[0];
|
||||||
|
setPeriodEnd(end);
|
||||||
|
}
|
||||||
|
if (d.header?.tin) setTin(d.header.tin);
|
||||||
|
if (d.header?.taxAccountNumber) setTaxAccountNumber(d.header.taxAccountNumber);
|
||||||
|
if (d.suggestedFilename) setSuggestedFilename(d.suggestedFilename);
|
||||||
|
toast.success("Scan Complete", "Declaration data extracted from scan.");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openInvoicePicker = async () => {
|
const openInvoicePicker = async () => {
|
||||||
setLoadingInvoices(true);
|
setLoadingInvoices(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -411,28 +439,23 @@ export default function CreateDeclarationScreen() {
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<View className="flex-row gap-4">
|
<View className="flex-row gap-4">
|
||||||
<Field
|
<PickerField
|
||||||
label="Period Start"
|
label="Period Start"
|
||||||
value={periodStart}
|
value={periodStart || "Select"}
|
||||||
onChangeText={setPeriodStart}
|
onPress={() => setShowPeriodStart(true)}
|
||||||
placeholder="2024-01-01"
|
|
||||||
required
|
required
|
||||||
flex={1}
|
|
||||||
/>
|
/>
|
||||||
<Field
|
<PickerField
|
||||||
label="Period End"
|
label="Period End"
|
||||||
value={periodEnd}
|
value={periodEnd || "Select"}
|
||||||
onChangeText={setPeriodEnd}
|
onPress={() => setShowPeriodEnd(true)}
|
||||||
placeholder="2024-01-31"
|
|
||||||
required
|
required
|
||||||
flex={1}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Field
|
<PickerField
|
||||||
label="Due Date"
|
label="Due Date"
|
||||||
value={dueDate}
|
value={dueDate || "Select"}
|
||||||
onChangeText={setDueDate}
|
onPress={() => setShowDueDate(true)}
|
||||||
placeholder="2024-02-15 (optional)"
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -785,6 +808,45 @@ export default function CreateDeclarationScreen() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</PickerModal>
|
</PickerModal>
|
||||||
|
<PickerModal
|
||||||
|
visible={showPeriodStart}
|
||||||
|
onClose={() => setShowPeriodStart(false)}
|
||||||
|
title="Period Start"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={periodStart}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setPeriodStart(v);
|
||||||
|
setShowPeriodStart(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
<PickerModal
|
||||||
|
visible={showPeriodEnd}
|
||||||
|
onClose={() => setShowPeriodEnd(false)}
|
||||||
|
title="Period End"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={periodEnd}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setPeriodEnd(v);
|
||||||
|
setShowPeriodEnd(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
<PickerModal
|
||||||
|
visible={showDueDate}
|
||||||
|
onClose={() => setShowDueDate(false)}
|
||||||
|
title="Due Date"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={dueDate}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setDueDate(v);
|
||||||
|
setShowDueDate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,13 @@ import { api } from "@/lib/api";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Plus, Search, FileText, Calendar, ChevronRight } from "@/lib/icons";
|
import { Plus, Search, FileText } from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { getPlaceholderColor } from "@/lib/colors";
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import { CreateMethodSheet } from "@/components/CreateMethodSheet";
|
||||||
|
|
||||||
const TYPE_OPTIONS = ["All", "VAT", "WITHHOLDING_TAX"];
|
const TYPE_OPTIONS = ["All", "VAT", "WITHHOLDING_TAX"];
|
||||||
const STATUS_OPTIONS = ["All", "DRAFT", "SUBMITTED", "PAID", "CANCELLED"];
|
const STATUS_OPTIONS = ["All", "DRAFT", "SUBMITTED", "PAID", "CANCELLED"];
|
||||||
|
|
@ -69,6 +70,7 @@ export default function DeclarationsScreen() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState("All");
|
const [typeFilter, setTypeFilter] = useState("All");
|
||||||
const [statusFilter, setStatusFilter] = useState("All");
|
const [statusFilter, setStatusFilter] = useState("All");
|
||||||
|
const [showCreateSheet, setShowCreateSheet] = useState(false);
|
||||||
|
|
||||||
const fetchPage = useCallback(
|
const fetchPage = useCallback(
|
||||||
async (pageNum: number, replace = false) => {
|
async (pageNum: number, replace = false) => {
|
||||||
|
|
@ -178,7 +180,7 @@ export default function DeclarationsScreen() {
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="mb-4 h-10 rounded-lg bg-primary"
|
className="mb-4 h-10 rounded-lg bg-primary"
|
||||||
onPress={() => nav.go("declarations/create")}
|
onPress={() => setShowCreateSheet(true)}
|
||||||
>
|
>
|
||||||
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
||||||
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
||||||
|
|
@ -297,6 +299,20 @@ export default function DeclarationsScreen() {
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<CreateMethodSheet
|
||||||
|
visible={showCreateSheet}
|
||||||
|
onClose={() => setShowCreateSheet(false)}
|
||||||
|
title="Create Declaration"
|
||||||
|
onSelectScan={() => {
|
||||||
|
setShowCreateSheet(false);
|
||||||
|
nav.go("declarations/scan");
|
||||||
|
}}
|
||||||
|
onSelectManual={() => {
|
||||||
|
setShowCreateSheet(false);
|
||||||
|
nav.go("declarations/create");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
289
app/declarations/scan.tsx
Normal file
289
app/declarations/scan.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Pressable,
|
||||||
|
Platform,
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
StyleSheet,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Zap,
|
||||||
|
ScanLine,
|
||||||
|
Check,
|
||||||
|
RefreshCw,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { BASE_URL } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { setScanData } from "@/lib/scan-cache";
|
||||||
|
|
||||||
|
const NAV_BG = "#ffffff";
|
||||||
|
|
||||||
|
export default function DeclarationScanScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const [torch, setTorch] = useState(false);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [previewUri, setPreviewUri] = useState<string | null>(null);
|
||||||
|
const cameraRef = useRef<CameraView>(null);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const token = useAuthStore((s) => s.token);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({ tabBarStyle: { display: "none" } });
|
||||||
|
return () => {
|
||||||
|
navigation.setOptions({
|
||||||
|
tabBarStyle: {
|
||||||
|
display: "flex",
|
||||||
|
backgroundColor: NAV_BG,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
elevation: 10,
|
||||||
|
height: 75,
|
||||||
|
paddingBottom: Platform.OS === "ios" ? 30 : 10,
|
||||||
|
paddingTop: 10,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 25,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
borderRadius: 32,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
const handleCapture = async () => {
|
||||||
|
if (!cameraRef.current || scanning) return;
|
||||||
|
try {
|
||||||
|
const photo = await cameraRef.current.takePictureAsync({
|
||||||
|
quality: 0.8,
|
||||||
|
base64: false,
|
||||||
|
});
|
||||||
|
if (photo?.uri) {
|
||||||
|
setPreviewUri(photo.uri);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[DeclarationScan] Capture Error:", err);
|
||||||
|
toast.error("Capture Failed", "Could not take a photo.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProcess = async () => {
|
||||||
|
if (!previewUri || scanning) return;
|
||||||
|
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
toast.info("Processing...", "Uploading declaration for AI extraction.");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const fileExt = previewUri.split(".").pop() || "jpg";
|
||||||
|
const fileName = `declaration-${Date.now()}.${fileExt}`;
|
||||||
|
const type = `image/${fileExt === "jpg" ? "jpeg" : fileExt}`;
|
||||||
|
|
||||||
|
formData.append("file", {
|
||||||
|
uri:
|
||||||
|
Platform.OS === "android"
|
||||||
|
? previewUri
|
||||||
|
: previewUri.replace("file://", ""),
|
||||||
|
name: fileName,
|
||||||
|
type: type,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}declarations/scan`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ message: "Scan processing failed." }));
|
||||||
|
throw new Error(err.message || "Extraction failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanResult = await response.json();
|
||||||
|
console.log("[DeclarationScan] Extracted data:", scanResult);
|
||||||
|
|
||||||
|
toast.success("Success!", "Extracted data from declaration.");
|
||||||
|
|
||||||
|
setScanData({
|
||||||
|
type: "declaration",
|
||||||
|
data: scanResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPreviewUri(null);
|
||||||
|
setScanning(false);
|
||||||
|
|
||||||
|
nav.go("declarations/create");
|
||||||
|
return;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[DeclarationScan] Processing Error:", err);
|
||||||
|
toast.error(
|
||||||
|
"Processing Failed",
|
||||||
|
err.message || "Declaration extraction failed.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return <View className="flex-1 bg-black" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background items-center justify-center">
|
||||||
|
<Text variant="h2" className="text-center mb-2">
|
||||||
|
Camera Access
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-center mb-10 leading-6 px-10">
|
||||||
|
We need your permission to use the camera to scan declarations
|
||||||
|
automatically.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
className="w-3/4 h-12 rounded-[12px] bg-primary px-10"
|
||||||
|
onPress={requestPermission}
|
||||||
|
>
|
||||||
|
<Text className="text-white font-sans-bold tracking-widest">
|
||||||
|
Enable Camera
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
className="mt-4 border border-border w-3/4 rounded-[12px] py-3 flex-row justify-center items-center"
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground font-sans-bold">Go Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-black">
|
||||||
|
{previewUri ? (
|
||||||
|
<View className="flex-1">
|
||||||
|
<Image
|
||||||
|
source={{ uri: previewUri }}
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
<View className="flex-1 justify-between p-10 pt-16 bg-black/20">
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text className="text-white font-sans-black uppercase tracking-widest text-lg shadow-xl">
|
||||||
|
Preview
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setPreviewUri(null)}
|
||||||
|
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
|
||||||
|
>
|
||||||
|
<X color="white" size={24} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4 items-center justify-center pb-10">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1 h-16 rounded-2xl bg-black/40 border border-white/20"
|
||||||
|
onPress={() => setPreviewUri(null)}
|
||||||
|
disabled={scanning}
|
||||||
|
>
|
||||||
|
<RefreshCw color="white" size={20} className="mr-2" />
|
||||||
|
<Text className="text-white font-sans-bold uppercase tracking-widest text-xs">
|
||||||
|
Retake
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="flex-1 h-16 rounded-2xl bg-primary shadow-2xl"
|
||||||
|
onPress={handleProcess}
|
||||||
|
disabled={scanning}
|
||||||
|
>
|
||||||
|
{scanning ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check color="white" size={24} className="mr-2" />
|
||||||
|
<Text className="text-white font-sans-bold uppercase tracking-widest text-xs">
|
||||||
|
Extract
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<CameraView
|
||||||
|
ref={cameraRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
facing="back"
|
||||||
|
enableTorch={torch}
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-between p-10 pt-16">
|
||||||
|
{/* Top bar */}
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setTorch(!torch)}
|
||||||
|
className={`h-12 w-12 rounded-full items-center justify-center border border-white/20 ${torch ? "bg-primary" : "bg-black/40"}`}
|
||||||
|
>
|
||||||
|
<Zap
|
||||||
|
color="white"
|
||||||
|
size={20}
|
||||||
|
fill={torch ? "white" : "transparent"}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
|
||||||
|
>
|
||||||
|
<X color="white" size={24} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Scan Frame */}
|
||||||
|
<View className="items-center">
|
||||||
|
<View className="w-[300px] h-[500px] border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
|
||||||
|
<View className="w-[280px] h-[380px] border border-white/10 rounded-2xl" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Capture Button */}
|
||||||
|
<View className="items-center gap-6">
|
||||||
|
<View className="items-center gap-4">
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCapture}
|
||||||
|
disabled={scanning}
|
||||||
|
className="h-16 w-16 rounded-full bg-primary items-center justify-center border-4 border-white/30 shadow-2xl"
|
||||||
|
>
|
||||||
|
<ScanLine color="white" size={32} />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-white/50 text-[10px] font-sans-black uppercase tracking-widest">
|
||||||
|
Tap to Capture
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CameraView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,31 +2,57 @@ import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Pressable,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
useColorScheme,
|
StyleSheet,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { ArrowLeft, User, Mail, Check } from "@/lib/icons";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
input: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 13,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
textAlignVertical: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function useInputColors() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
placeholder: "rgba(100,116,139,0.45)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function EditProfileScreen() {
|
export default function EditProfileScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const { user, updateUser } = useAuthStore();
|
const { user, updateUser } = useAuthStore();
|
||||||
const isDark = useColorScheme() === "dark";
|
const { colorScheme } = useColorScheme();
|
||||||
const iconColor = isDark ? "#94a3b8" : "#64748b";
|
const isDark = colorScheme === "dark";
|
||||||
|
const c = useInputColors();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [firstName, setFirstName] = useState(user?.firstName || "");
|
const [firstName, setFirstName] = useState(user?.firstName || "");
|
||||||
const [lastName, setLastName] = useState(user?.lastName || "");
|
const [lastName, setLastName] = useState(user?.lastName || "");
|
||||||
|
|
||||||
|
const initials = `${user?.firstName?.[0] || ""}${user?.lastName?.[0] || ""}`.toUpperCase();
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!firstName.trim() || !lastName.trim()) {
|
if (!firstName.trim() || !lastName.trim()) {
|
||||||
toast.error("Error", "First and last name are required");
|
toast.error("Error", "First and last name are required");
|
||||||
|
|
@ -53,66 +79,57 @@ export default function EditProfileScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
<StandardHeader title="Edit Profile" showBack />
|
||||||
<Pressable
|
|
||||||
onPress={() => nav.back()}
|
|
||||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
|
||||||
>
|
|
||||||
<ArrowLeft color={isDark ? "#fff" : "#0f172a"} size={20} />
|
|
||||||
</Pressable>
|
|
||||||
<Text className="text-foreground text-[17px] font-sans-bold tracking-tight">
|
|
||||||
Edit Profile
|
|
||||||
</Text>
|
|
||||||
<View className="w-10" />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 20,
|
||||||
paddingTop: 32,
|
paddingTop: 12,
|
||||||
paddingBottom: 40,
|
paddingBottom: 40,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="gap-6">
|
<View className="items-center mb-8">
|
||||||
|
<View className="h-24 w-24 rounded-full items-center justify-center mb-4" style={{ backgroundColor: "#ea580c" }}>
|
||||||
|
<Text className="text-white text-3xl font-sans-bold">
|
||||||
|
{initials || "?"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-foreground text-lg font-sans-bold tracking-tight">
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground font-sans-bold mt-0.5">
|
||||||
|
{user?.email}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="rounded-[12px] border border-border bg-card p-5 gap-5">
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-[11px] font-sans-bold uppercase tracking-widest text-muted-foreground mb-2 ml-1">
|
<Text className="text-[11px] font-sans-bold uppercase tracking-widest text-muted-foreground mb-2 ml-1">
|
||||||
First Name
|
First Name
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center rounded-xl px-4 border border-border h-14">
|
<TextInput
|
||||||
<User size={16} color={iconColor} />
|
style={[S.input, { backgroundColor: c.bg, borderColor: c.border, color: c.text }]}
|
||||||
<TextInput
|
placeholder="Enter first name"
|
||||||
className="flex-1 ml-3 text-foreground text-base"
|
placeholderTextColor={c.placeholder}
|
||||||
placeholder="Enter first name"
|
value={firstName}
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
onChangeText={setFirstName}
|
||||||
value={firstName}
|
autoCorrect={false}
|
||||||
onChangeText={setFirstName}
|
/>
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
{firstName.trim().length > 0 && (
|
|
||||||
<Check size={16} color="#10b981" />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-[11px] font-sans-bold uppercase tracking-widest text-muted-foreground mb-2 ml-1">
|
<Text className="text-[11px] font-sans-bold uppercase tracking-widest text-muted-foreground mb-2 ml-1">
|
||||||
Last Name
|
Last Name
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center rounded-xl px-4 border border-border h-14">
|
<TextInput
|
||||||
<User size={16} color={iconColor} />
|
style={[S.input, { backgroundColor: c.bg, borderColor: c.border, color: c.text }]}
|
||||||
<TextInput
|
placeholder="Enter last name"
|
||||||
className="flex-1 ml-3 text-foreground text-base"
|
placeholderTextColor={c.placeholder}
|
||||||
placeholder="Enter last name"
|
value={lastName}
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
onChangeText={setLastName}
|
||||||
value={lastName}
|
autoCorrect={false}
|
||||||
onChangeText={setLastName}
|
/>
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
{lastName.trim().length > 0 && (
|
|
||||||
<Check size={16} color="#10b981" />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{user?.email && (
|
{user?.email && (
|
||||||
|
|
@ -120,40 +137,47 @@ export default function EditProfileScreen() {
|
||||||
<Text className="text-[11px] font-sans-bold uppercase tracking-widest text-muted-foreground mb-2 ml-1">
|
<Text className="text-[11px] font-sans-bold uppercase tracking-widest text-muted-foreground mb-2 ml-1">
|
||||||
Email
|
Email
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center rounded-xl px-4 border border-border/50 h-14 bg-muted/20">
|
<View
|
||||||
<Mail size={16} color={iconColor} />
|
style={[
|
||||||
<Text className="flex-1 ml-3 text-base text-muted-foreground">
|
S.input,
|
||||||
{user.email}
|
{
|
||||||
</Text>
|
backgroundColor: isDark ? "rgba(30,30,30,0.4)" : "rgba(241,245,249,0.1)",
|
||||||
|
borderColor: "transparent",
|
||||||
|
color: isDark ? "#64748b" : "#94a3b8",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text className="text-sm text-muted-foreground">{user.email}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="mt-4 gap-3">
|
<View className="mt-6 gap-3">
|
||||||
<Button
|
<Button
|
||||||
className="h-12 bg-primary rounded-[8px]"
|
className="h-12 bg-primary rounded-[10px]"
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color="white" />
|
<ActivityIndicator color="white" />
|
||||||
) : (
|
) : (
|
||||||
<Text className="text-white font-sans-bold text-sm tracking-widest">
|
<Text className="text-white font-sans-bold text-sm tracking-widest">
|
||||||
Save Changes
|
Save Changes
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={() => nav.back()}
|
|
||||||
className="h-12 rounded-[8px] border border-border items-center justify-center"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<Text className="text-muted-foreground font-sans-bold text-sm tracking-widest">
|
|
||||||
Cancel
|
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
)}
|
||||||
</View>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 rounded-[10px]"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground font-sans-bold text-sm tracking-widest">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Pressable,
|
Pressable,
|
||||||
Modal,
|
Modal,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
Image,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
|
@ -28,11 +29,12 @@ import {
|
||||||
X,
|
X,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
FileText,
|
FileText,
|
||||||
Receipt,
|
|
||||||
CreditCard,
|
CreditCard,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Check,
|
Check,
|
||||||
Edit,
|
Edit,
|
||||||
|
Camera,
|
||||||
|
ArrowUpRight,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
|
@ -40,7 +42,10 @@ import { api, BASE_URL } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { ActionModal } from "@/components/ActionModal";
|
import { ActionModal } from "@/components/ActionModal";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { WebView } from "react-native-webview";
|
||||||
import { UploadIcon } from "lucide-react-native";
|
import { UploadIcon } from "lucide-react-native";
|
||||||
|
import ticketImage from "@/assets/ticket.png";
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
|
@ -56,7 +61,13 @@ export default function InvoiceDetailScreen() {
|
||||||
const [showShareSheet, setShowShareSheet] = useState(false);
|
const [showShareSheet, setShowShareSheet] = useState(false);
|
||||||
const [showMoreSheet, setShowMoreSheet] = useState(false);
|
const [showMoreSheet, setShowMoreSheet] = useState(false);
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"details" | "activity">("details");
|
const [activeTab, setActiveTab] = useState<"details" | "items" | "image">(
|
||||||
|
"details",
|
||||||
|
);
|
||||||
|
const [showImageFullScreen, setShowImageFullScreen] = useState(false);
|
||||||
|
const [imageLoading, setImageLoading] = useState(false);
|
||||||
|
|
||||||
|
const token = useAuthStore((state) => state.token);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|
@ -163,7 +174,11 @@ export default function InvoiceDetailScreen() {
|
||||||
// Robust data extraction
|
// Robust data extraction
|
||||||
const originalData = invoice.scannedData?.originalData || {};
|
const originalData = invoice.scannedData?.originalData || {};
|
||||||
const items =
|
const items =
|
||||||
(invoice.items?.length > 0 ? invoice.items : originalData.items) || [];
|
(invoice.items?.length > 0
|
||||||
|
? invoice.items
|
||||||
|
: invoice.scannedData?.items?.length > 0
|
||||||
|
? invoice.scannedData.items
|
||||||
|
: originalData.items) || [];
|
||||||
|
|
||||||
const taxAmountValue = Number(
|
const taxAmountValue = Number(
|
||||||
typeof invoice.taxAmount === "object"
|
typeof invoice.taxAmount === "object"
|
||||||
|
|
@ -208,6 +223,25 @@ export default function InvoiceDetailScreen() {
|
||||||
CANCELLED: "Cancelled",
|
CANCELLED: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scanned image URL — try several common fields
|
||||||
|
const scannedImageRaw =
|
||||||
|
invoice.scannedData?.imageUrl ||
|
||||||
|
invoice.scannedData?.image ||
|
||||||
|
invoice.scannedData?.imagePath ||
|
||||||
|
invoice.scannedData?.originalData?.imageUrl ||
|
||||||
|
invoice.imageUrl ||
|
||||||
|
invoice.imagePath ||
|
||||||
|
invoice.receiptPath ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const scannedImageUrl = scannedImageRaw
|
||||||
|
? scannedImageRaw.startsWith("http")
|
||||||
|
? scannedImageRaw
|
||||||
|
: `${BASE_URL}${scannedImageRaw.replace(/^\//, "")}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const hasScannedImage = Boolean(invoice?.isScanned && scannedImageUrl);
|
||||||
|
|
||||||
const customerName = (
|
const customerName = (
|
||||||
invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"
|
invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"
|
||||||
).trim();
|
).trim();
|
||||||
|
|
@ -218,6 +252,8 @@ export default function InvoiceDetailScreen() {
|
||||||
? new Date(invoice.issueDate)
|
? new Date(invoice.issueDate)
|
||||||
: new Date(invoice.createdAt);
|
: new Date(invoice.createdAt);
|
||||||
|
|
||||||
|
const paidDate = invoice.paidDate ? new Date(invoice.paidDate) : null;
|
||||||
|
|
||||||
const formatLongDate = (d: Date) =>
|
const formatLongDate = (d: Date) =>
|
||||||
d.toLocaleDateString("en-US", {
|
d.toLocaleDateString("en-US", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|
@ -250,96 +286,13 @@ export default function InvoiceDetailScreen() {
|
||||||
<View className="px-5 pt-3">
|
<View className="px-5 pt-3">
|
||||||
<View
|
<View
|
||||||
className="items-center"
|
className="items-center"
|
||||||
style={{ marginTop: 8, marginBottom: -40, zIndex: 2 }}
|
style={{ marginBottom: -60, zIndex: 2 }}
|
||||||
>
|
>
|
||||||
<View style={{ width: 110, height: 92 }}>
|
<Image
|
||||||
<View
|
source={ticketImage}
|
||||||
style={{
|
style={{ width: 150, height: 150 }}
|
||||||
position: "absolute",
|
resizeMode="contain"
|
||||||
top: 6,
|
/>
|
||||||
left: 22,
|
|
||||||
width: 72,
|
|
||||||
height: 84,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: "#E46212",
|
|
||||||
transform: [{ rotate: "8deg" }],
|
|
||||||
opacity: 0.92,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 2,
|
|
||||||
left: 8,
|
|
||||||
width: 72,
|
|
||||||
height: 84,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: "#0f172a",
|
|
||||||
transform: [{ rotate: "-6deg" }],
|
|
||||||
opacity: 0.95,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 4,
|
|
||||||
left: 22,
|
|
||||||
width: 72,
|
|
||||||
height: 84,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: isDark ? "#1F1F1F" : "#ffffff",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: isDark ? "rgba(255,255,255,0.08)" : "#EDD5D1",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingTop: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Receipt
|
|
||||||
size={22}
|
|
||||||
color={isDark ? "#f1f5f9" : "#251615"}
|
|
||||||
strokeWidth={1.6}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
width: 36,
|
|
||||||
height: 3,
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: isDark
|
|
||||||
? "rgba(255,255,255,0.18)"
|
|
||||||
: "rgba(0,0,0,0.08)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
marginTop: 4,
|
|
||||||
width: 22,
|
|
||||||
height: 3,
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: isDark
|
|
||||||
? "rgba(255,255,255,0.12)"
|
|
||||||
: "rgba(0,0,0,0.06)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 10,
|
|
||||||
right: -6,
|
|
||||||
width: 22,
|
|
||||||
height: 26,
|
|
||||||
borderRadius: 5,
|
|
||||||
backgroundColor: "#E46212",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-white font-sans-black text-[12px]">
|
|
||||||
$
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
|
@ -396,14 +349,25 @@ export default function InvoiceDetailScreen() {
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row justify-between items-center">
|
<View className="flex-row justify-between items-center">
|
||||||
<Text className="text-muted-foreground text-[12px] font-sans-medium">
|
<Text className="text-muted-foreground text-[12px] font-sans-medium">
|
||||||
{isPaid ? "Payment Date" : "Due Date"}
|
Due Date
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-foreground text-[12px] font-sans-bold">
|
<Text className="text-foreground text-[12px] font-sans-bold">
|
||||||
{isPaid
|
{formatLongDate(paymentDate)}
|
||||||
? `Paid at ${formatLongDate(paymentDate)}`
|
|
||||||
: formatLongDate(paymentDate)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{paidDate && (
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text className="text-muted-foreground text-[12px] font-sans-medium">
|
||||||
|
Paid Date
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<Check size={11} color="#16a34a" strokeWidth={3} />
|
||||||
|
<Text className="text-foreground text-[12px] font-sans-bold">
|
||||||
|
{formatLongDate(paidDate)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -428,23 +392,39 @@ export default function InvoiceDetailScreen() {
|
||||||
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
|
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable onPress={() => setActiveTab("items")} className="pb-2.5">
|
||||||
onPress={() => setActiveTab("activity")}
|
|
||||||
className="pb-2.5"
|
|
||||||
>
|
|
||||||
<Text
|
<Text
|
||||||
className={`text-[14px] font-sans-bold ${
|
className={`text-[14px] font-sans-bold ${
|
||||||
activeTab === "activity"
|
activeTab === "items"
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Activity Log
|
Items
|
||||||
</Text>
|
</Text>
|
||||||
{activeTab === "activity" && (
|
{activeTab === "items" && (
|
||||||
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
|
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
{hasScannedImage && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setActiveTab("image")}
|
||||||
|
className="pb-2.5"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-[14px] font-sans-bold ${
|
||||||
|
activeTab === "image"
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Image
|
||||||
|
</Text>
|
||||||
|
{activeTab === "image" && (
|
||||||
|
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -479,17 +459,18 @@ export default function InvoiceDetailScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
{invoice.notes && (
|
||||||
<Text className="text-[11px] font-sans-bold tracking-widest text-muted-foreground mb-1.5">
|
<View>
|
||||||
Notes
|
<Text className="text-foreground text-sm font-sans-bold mb-2">
|
||||||
</Text>
|
Note
|
||||||
<Text
|
</Text>
|
||||||
className="text-foreground text-[14px] font-sans-bold"
|
<View className="rounded-[10px] bg-muted p-4">
|
||||||
numberOfLines={1}
|
<Text className="text-foreground font-sans-medium text-[13px] leading-5">
|
||||||
>
|
{invoice.notes}
|
||||||
{invoice.notes || "-"}
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
|
|
@ -527,7 +508,7 @@ export default function InvoiceDetailScreen() {
|
||||||
className="text-foreground text-[14px] font-sans-bold"
|
className="text-foreground text-[14px] font-sans-bold"
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{item.description || `Item ${idx + 1}`}
|
{item.description || "No item"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
|
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
|
||||||
{qty} ×{" "}
|
{qty} ×{" "}
|
||||||
|
|
@ -619,79 +600,76 @@ export default function InvoiceDetailScreen() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : activeTab === "image" ? (
|
||||||
<View className="px-5 pt-5">
|
<View className="px-5 pt-5">
|
||||||
{items.length > 0 ? (
|
{hasScannedImage ? (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-foreground text-[16px] font-sans-black tracking-tight mb-3">
|
<Text className="text-foreground text-[16px] font-sans-black tracking-tight mb-3">
|
||||||
Items
|
Scanned Document
|
||||||
</Text>
|
</Text>
|
||||||
<View>
|
<Pressable
|
||||||
{items.map((item: any, idx: number) => {
|
onPress={() => setShowImageFullScreen(true)}
|
||||||
const qty = Number(
|
className="rounded-[10px] overflow-hidden border border-border bg-card active:opacity-80"
|
||||||
item.quantity?.value || item.quantity || 1,
|
>
|
||||||
);
|
<WebView
|
||||||
const unitPrice = Number(
|
source={{ uri: scannedImageUrl || "" }}
|
||||||
item.unitPrice?.value || item.unitPrice || 0,
|
style={{
|
||||||
);
|
width: "100%",
|
||||||
const lineTotal = Number(
|
height: 360,
|
||||||
item.total?.value || item.total || qty * unitPrice,
|
backgroundColor: isDark ? "#1F1F1F" : "#ffffff",
|
||||||
);
|
}}
|
||||||
return (
|
originWhitelist={["*"]}
|
||||||
<View
|
mixedContentMode="always"
|
||||||
key={idx}
|
scalesPageToFit
|
||||||
className={`flex-row items-center gap-3 py-3 ${
|
onLoadStart={() => setImageLoading(true)}
|
||||||
idx < items.length - 1 ? "border-b border-border" : ""
|
onLoadEnd={() => setImageLoading(false)}
|
||||||
}`}
|
onError={() => {
|
||||||
>
|
setImageLoading(false);
|
||||||
<View className="h-12 w-12 rounded-[8px] bg-muted items-center justify-center overflow-hidden">
|
toast.error(
|
||||||
<Package
|
"Image Error",
|
||||||
size={20}
|
"Failed to load scanned image.",
|
||||||
color="#94a3b8"
|
);
|
||||||
strokeWidth={1.5}
|
}}
|
||||||
/>
|
renderError={() => (
|
||||||
</View>
|
<View className="flex-1 items-center justify-center p-4">
|
||||||
<View className="flex-1">
|
<Camera size={28} color="#94a3b8" />
|
||||||
<Text
|
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-2 text-center">
|
||||||
className="text-foreground text-[14px] font-sans-bold"
|
Failed to load image
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{item.description || `Item ${idx + 1}`}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
|
|
||||||
{qty} ×{" "}
|
|
||||||
{unitPrice.toLocaleString("en-US", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}{" "}
|
|
||||||
{invoice.currency || "USD"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-foreground text-[14px] font-sans-bold">
|
|
||||||
{lineTotal.toLocaleString("en-US", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}{" "}
|
|
||||||
{invoice.currency || "USD"}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
)}
|
||||||
})}
|
/>
|
||||||
</View>
|
{imageLoading && (
|
||||||
|
<View
|
||||||
|
className="absolute inset-0 items-center justify-center bg-card/40"
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
<ActivityIndicator color="#ea580c" size="large" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="absolute bottom-3 right-3 h-9 w-9 rounded-full bg-black/60 items-center justify-center flex-row">
|
||||||
|
<ArrowUpRight size={16} color="#ffffff" strokeWidth={2.5} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-2 text-center">
|
||||||
|
Tap to view full screen
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="px-5 pt-12 items-center">
|
<View className="px-5 pt-12 items-center">
|
||||||
<View className="h-14 w-14 rounded-full bg-muted items-center justify-center mb-3">
|
<View className="h-14 w-14 rounded-full bg-muted items-center justify-center mb-3">
|
||||||
<Clock size={22} color="#94a3b8" />
|
<Camera size={22} color="#94a3b8" />
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-foreground text-[14px] font-sans-bold mb-1">
|
<Text className="text-foreground text-[14px] font-sans-bold mb-1">
|
||||||
No activity yet
|
No image available
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-muted-foreground text-[12px] font-sans-medium text-center">
|
<Text className="text-muted-foreground text-[12px] font-sans-medium text-center">
|
||||||
Events related to this invoice will show up here.
|
The scanned image could not be found.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
) : null}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
|
@ -841,6 +819,37 @@ export default function InvoiceDetailScreen() {
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Full screen image viewer */}
|
||||||
|
<Modal
|
||||||
|
visible={showImageFullScreen}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowImageFullScreen(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 bg-black"
|
||||||
|
onPress={() => setShowImageFullScreen(false)}
|
||||||
|
>
|
||||||
|
<View className="flex-1">
|
||||||
|
{scannedImageUrl && (
|
||||||
|
<WebView
|
||||||
|
source={{ uri: scannedImageUrl }}
|
||||||
|
style={{ flex: 1, backgroundColor: "#000000" }}
|
||||||
|
originWhitelist={["*"]}
|
||||||
|
mixedContentMode="always"
|
||||||
|
scalesPageToFit
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowImageFullScreen(false)}
|
||||||
|
className="absolute top-12 right-5 h-10 w-10 rounded-full bg-black/60 items-center justify-center border border-white/20"
|
||||||
|
>
|
||||||
|
<X size={18} color="#ffffff" strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<ActionModal
|
<ActionModal
|
||||||
visible={showDeleteModal}
|
visible={showDeleteModal}
|
||||||
onClose={() => setShowDeleteModal(false)}
|
onClose={() => setShowDeleteModal(false)}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import * as ImagePicker from "expo-image-picker";
|
||||||
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
import { CalendarGrid } from "@/components/CalendarGrid";
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
import { ConfirmSubmitModal } from "@/components/ConfirmSubmitModal";
|
||||||
import { getPlaceholderColor } from "@/lib/colors";
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
import { getScanData } from "@/lib/scan-cache";
|
import { getScanData } from "@/lib/scan-cache";
|
||||||
|
|
||||||
|
|
@ -155,11 +156,15 @@ export default function CreateInvoiceScreen() {
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [scanFailures, setScanFailures] = useState(0);
|
const [scanFailures, setScanFailures] = useState(0);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [scanRecordId, setScanRecordId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [invoiceNumber, setInvoiceNumber] = useState("");
|
const [invoiceNumber, setInvoiceNumber] = useState("");
|
||||||
|
const [customerId, setCustomerId] = useState("");
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
const [customerEmail, setCustomerEmail] = useState("");
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
const [customerPhone, setCustomerPhone] = useState("");
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [selectedCustomers, setSelectedCustomers] = useState<any[]>([]);
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [currency, setCurrency] = useState("ETB ");
|
const [currency, setCurrency] = useState("ETB ");
|
||||||
const [type, setType] = useState("SALES");
|
const [type, setType] = useState("SALES");
|
||||||
|
|
@ -198,41 +203,42 @@ export default function CreateInvoiceScreen() {
|
||||||
d.setDate(d.getDate() + 30);
|
d.setDate(d.getDate() + 30);
|
||||||
setDueDate(d.toISOString().split("T")[0]);
|
setDueDate(d.toISOString().split("T")[0]);
|
||||||
|
|
||||||
const scanData = getScanData();
|
const payload = getScanData();
|
||||||
if (scanData) {
|
if (!payload || payload.type !== "invoice" || !payload.id) return;
|
||||||
if (scanData.invoiceNumber) setInvoiceNumber(scanData.invoiceNumber);
|
setScanRecordId(payload.id);
|
||||||
const name =
|
const scanData = payload.data || {};
|
||||||
scanData.customerName
|
if (scanData.invoiceNumber) setInvoiceNumber(scanData.invoiceNumber);
|
||||||
?.trim()
|
const name =
|
||||||
?.replace(/^(Customer Name:|Bill To:)\s*/i, "") || "";
|
scanData.customerName
|
||||||
if (name) setCustomerName(name);
|
?.trim()
|
||||||
if (scanData.customerEmail) setCustomerEmail(scanData.customerEmail);
|
?.replace(/^(Customer Name:|Bill To:)\s*/i, "") || "";
|
||||||
if (scanData.customerPhone) setCustomerPhone(scanData.customerPhone.replace(/^\+251/, ""));
|
if (name) setCustomerName(name);
|
||||||
if (scanData.description) setDescription(scanData.description);
|
if (scanData.customerEmail) setCustomerEmail(scanData.customerEmail);
|
||||||
if (scanData.currency) setCurrency(scanData.currency);
|
if (scanData.customerPhone) setCustomerPhone(scanData.customerPhone.replace(/^\+251/, ""));
|
||||||
if (scanData.taxAmount != null) setTaxAmount(String(scanData.taxAmount));
|
if (scanData.description) setDescription(scanData.description);
|
||||||
if (scanData.issueDate) {
|
if (scanData.currency) setCurrency(scanData.currency);
|
||||||
try {
|
if (scanData.taxAmount != null) setTaxAmount(String(scanData.taxAmount));
|
||||||
setIssueDate(
|
if (scanData.issueDate) {
|
||||||
new Date(scanData.issueDate).toISOString().split("T")[0],
|
try {
|
||||||
);
|
setIssueDate(
|
||||||
} catch (_) {}
|
new Date(scanData.issueDate).toISOString().split("T")[0],
|
||||||
}
|
|
||||||
if (scanData.dueDate) {
|
|
||||||
try {
|
|
||||||
setDueDate(new Date(scanData.dueDate).toISOString().split("T")[0]);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
if (scanData.items && scanData.items.length > 0) {
|
|
||||||
setItems(
|
|
||||||
scanData.items.map((item: any, idx: number) => ({
|
|
||||||
id: idx + 1,
|
|
||||||
description: item.description || "",
|
|
||||||
qty: String(item.quantity || "1"),
|
|
||||||
price: String(item.unitPrice || item.total || ""),
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
}
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (scanData.dueDate) {
|
||||||
|
try {
|
||||||
|
setDueDate(new Date(scanData.dueDate).toISOString().split("T")[0]);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (scanData.items && scanData.items.length > 0) {
|
||||||
|
setItems(
|
||||||
|
scanData.items.map((item: any, idx: number) => ({
|
||||||
|
id: idx + 1,
|
||||||
|
description: item.description || "",
|
||||||
|
qty: String(item.quantity || "1"),
|
||||||
|
price: String(item.unitPrice || item.total || ""),
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -290,6 +296,7 @@ export default function CreateInvoiceScreen() {
|
||||||
throw new Error(scanResult.message || "Extraction failed");
|
throw new Error(scanResult.message || "Extraction failed");
|
||||||
toast.success("Success!", "Data extracted.");
|
toast.success("Success!", "Data extracted.");
|
||||||
const ocr = scanResult.data || {};
|
const ocr = scanResult.data || {};
|
||||||
|
if (scanResult.invoiceId) setScanRecordId(scanResult.invoiceId);
|
||||||
if (ocr.invoiceNumber) setInvoiceNumber(ocr.invoiceNumber);
|
if (ocr.invoiceNumber) setInvoiceNumber(ocr.invoiceNumber);
|
||||||
const name = (ocr.customerName?.trim() || "").replace(
|
const name = (ocr.customerName?.trim() || "").replace(
|
||||||
/^(Customer Name:|Bill To:)\s*/i,
|
/^(Customer Name:|Bill To:)\s*/i,
|
||||||
|
|
@ -379,44 +386,51 @@ export default function CreateInvoiceScreen() {
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await api.invoices.create({
|
const body = {
|
||||||
body: {
|
invoiceNumber,
|
||||||
invoiceNumber,
|
customerName,
|
||||||
customerName,
|
customerEmail,
|
||||||
customerEmail,
|
customerPhone: customerPhone ? `+251${customerPhone}` : "",
|
||||||
customerPhone: customerPhone ? `+251${customerPhone}` : "",
|
amount: Number(total.toFixed(2)),
|
||||||
amount: Number(total.toFixed(2)),
|
currency,
|
||||||
currency,
|
type,
|
||||||
type,
|
status,
|
||||||
status,
|
issueDate: new Date(issueDate).toISOString(),
|
||||||
issueDate: new Date(issueDate).toISOString(),
|
dueDate: new Date(dueDate).toISOString(),
|
||||||
dueDate: new Date(dueDate).toISOString(),
|
description,
|
||||||
description,
|
notes,
|
||||||
notes,
|
taxAmount: parseFloat(taxAmount) || 0,
|
||||||
taxAmount: parseFloat(taxAmount) || 0,
|
discountAmount: parseFloat(discountAmount) || 0,
|
||||||
discountAmount: parseFloat(discountAmount) || 0,
|
isScanned: !!scanRecordId,
|
||||||
isScanned: false,
|
scannedData: scanRecordId
|
||||||
scannedData: { sellerTIN: "123456", items: [] },
|
? undefined
|
||||||
items: validItems.map((item) => ({
|
: { sellerTIN: "123456", items: [] },
|
||||||
description: item.description.trim(),
|
items: validItems.map((item) => ({
|
||||||
quantity: parseFloat(item.qty) || 0,
|
description: item.description.trim(),
|
||||||
unitPrice: parseFloat(item.price) || 0,
|
quantity: parseFloat(item.qty) || 0,
|
||||||
total: Number(
|
unitPrice: parseFloat(item.price) || 0,
|
||||||
(
|
total: Number(
|
||||||
(parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0)
|
(
|
||||||
).toFixed(2),
|
(parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0)
|
||||||
),
|
).toFixed(2),
|
||||||
})),
|
),
|
||||||
},
|
})),
|
||||||
});
|
};
|
||||||
toast.success("Success", "Invoice created!");
|
|
||||||
|
if (scanRecordId) {
|
||||||
|
await api.invoices.update({ params: { id: scanRecordId }, body });
|
||||||
|
toast.success("Success", "Invoice updated!");
|
||||||
|
} else {
|
||||||
|
await api.invoices.create({ body });
|
||||||
|
toast.success("Success", "Invoice created!");
|
||||||
|
}
|
||||||
nav.back();
|
nav.back();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const msg =
|
const msg =
|
||||||
error?.response?.data?.message ||
|
error?.response?.data?.message ||
|
||||||
error?.data?.message ||
|
error?.data?.message ||
|
||||||
error?.message ||
|
error?.message ||
|
||||||
"Failed to create invoice";
|
(scanRecordId ? "Failed to update invoice" : "Failed to create invoice");
|
||||||
toast.error("Error", msg);
|
toast.error("Error", msg);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -435,9 +449,9 @@ export default function CreateInvoiceScreen() {
|
||||||
currentStep={step}
|
currentStep={step}
|
||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
onBack={() => setStep(step - 1)}
|
onBack={() => setStep(step - 1)}
|
||||||
onComplete={handleSubmit}
|
onComplete={() => setShowConfirm(true)}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
completeLabel="Create Invoice"
|
completeLabel={scanRecordId ? "Update Invoice" : "Create Invoice"}
|
||||||
>
|
>
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<View className="gap-5">
|
<View className="gap-5">
|
||||||
|
|
@ -470,11 +484,20 @@ export default function CreateInvoiceScreen() {
|
||||||
Customer Name
|
Customer Name
|
||||||
</Text>
|
</Text>
|
||||||
<CustomerPicker
|
<CustomerPicker
|
||||||
value={customerName}
|
selectedIds={customerId ? [customerId] : []}
|
||||||
onSelect={(c) => {
|
selectedCustomers={selectedCustomers}
|
||||||
setCustomerName(c.name);
|
onSelect={(ids, customers) => {
|
||||||
setCustomerEmail(c.email);
|
setCustomerId(ids[0] || "");
|
||||||
setCustomerPhone(c.phone.replace("+251", ""));
|
setSelectedCustomers(customers);
|
||||||
|
if (customers[0]) {
|
||||||
|
setCustomerName(customers[0].name);
|
||||||
|
setCustomerEmail(customers[0].email);
|
||||||
|
setCustomerPhone(customers[0].phone?.replace("+251", "") || "");
|
||||||
|
} else {
|
||||||
|
setCustomerName("");
|
||||||
|
setCustomerEmail("");
|
||||||
|
setCustomerPhone("");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Select or search for a customer"
|
placeholder="Select or search for a customer"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -160,9 +160,11 @@ export default function EditInvoiceScreen() {
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
const [invoiceNumber, setInvoiceNumber] = useState("");
|
const [invoiceNumber, setInvoiceNumber] = useState("");
|
||||||
|
const [customerId, setCustomerId] = useState("");
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
const [customerEmail, setCustomerEmail] = useState("");
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
const [customerPhone, setCustomerPhone] = useState("");
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [selectedCustomers, setSelectedCustomers] = useState<any[]>([]);
|
||||||
const [currency, setCurrency] = useState("ETB");
|
const [currency, setCurrency] = useState("ETB");
|
||||||
const [type, setType] = useState("SALES");
|
const [type, setType] = useState("SALES");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|
@ -383,11 +385,20 @@ export default function EditInvoiceScreen() {
|
||||||
Customer Name
|
Customer Name
|
||||||
</Text>
|
</Text>
|
||||||
<CustomerPicker
|
<CustomerPicker
|
||||||
value={customerName}
|
selectedIds={customerId ? [customerId] : []}
|
||||||
onSelect={(c) => {
|
selectedCustomers={selectedCustomers}
|
||||||
setCustomerName(c.name);
|
onSelect={(ids, customers) => {
|
||||||
setCustomerEmail(c.email);
|
setCustomerId(ids[0] || "");
|
||||||
setCustomerPhone(c.phone.replace("+251", ""));
|
setSelectedCustomers(customers);
|
||||||
|
if (customers[0]) {
|
||||||
|
setCustomerName(customers[0].name);
|
||||||
|
setCustomerEmail(customers[0].email);
|
||||||
|
setCustomerPhone(customers[0].phone?.replace("+251", "") || "");
|
||||||
|
} else {
|
||||||
|
setCustomerName("");
|
||||||
|
setCustomerEmail("");
|
||||||
|
setCustomerPhone("");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Select or search for a customer"
|
placeholder="Select or search for a customer"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,58 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
||||||
import { View, ActivityIndicator, FlatList, RefreshControl } from "react-native";
|
import { View, ActivityIndicator, FlatList, RefreshControl, Pressable } from "react-native";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { Bell, Clock } from "@/lib/icons";
|
||||||
|
|
||||||
type NotificationItem = {
|
type NotificationItem = {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
message?: string;
|
icon?: string;
|
||||||
|
url?: string;
|
||||||
|
sentAt?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
read?: boolean;
|
isSent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffMins < 1) return "Just now";
|
||||||
|
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? "s" : ""} ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hr${diffHours > 1 ? "s" : ""} ago`;
|
||||||
|
if (diffDays === 1) return "Yesterday";
|
||||||
|
if (diffDays < 7) return `${diffDays} days ago`;
|
||||||
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateGroup(dateString: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const itemDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
|
||||||
|
if (itemDate.getTime() === today.getTime()) return "Today";
|
||||||
|
if (itemDate.getTime() === yesterday.getTime()) return "Yesterday";
|
||||||
|
if (now.getTime() - itemDate.getTime() < 7 * 86400000) return "This Week";
|
||||||
|
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionItem = {
|
||||||
|
type: "header" | "item";
|
||||||
|
key: string;
|
||||||
|
item?: NotificationItem;
|
||||||
|
isLast?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NotificationsScreen() {
|
export default function NotificationsScreen() {
|
||||||
|
|
@ -64,26 +103,85 @@ export default function NotificationsScreen() {
|
||||||
if (!loading && !loadingMore && hasMore) fetchNotifications(page + 1, "more");
|
if (!loading && !loadingMore && hasMore) fetchNotifications(page + 1, "more");
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: NotificationItem }) => {
|
const grouped = useMemo(() => {
|
||||||
const message = item.body ?? item.message ?? "";
|
const groups: Record<string, NotificationItem[]> = {};
|
||||||
const time = item.createdAt
|
for (const item of items) {
|
||||||
? new Date(item.createdAt).toLocaleString()
|
const dateStr = item.sentAt || item.createdAt;
|
||||||
|
const group = dateStr ? getDateGroup(dateStr) : "Other";
|
||||||
|
if (!groups[group]) groups[group] = [];
|
||||||
|
groups[group].push(item);
|
||||||
|
}
|
||||||
|
return Object.entries(groups);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const data: SectionItem[] = [];
|
||||||
|
for (const [title, groupItems] of grouped) {
|
||||||
|
data.push({ type: "header", key: `header-${title}` });
|
||||||
|
groupItems.forEach((item, idx) => {
|
||||||
|
data.push({
|
||||||
|
type: "item",
|
||||||
|
key: item.id,
|
||||||
|
item,
|
||||||
|
isLast: idx === groupItems.length - 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, [grouped]);
|
||||||
|
|
||||||
|
const renderSectionHeader = (title: string) => (
|
||||||
|
<View className="px-5 pt-5 pb-2">
|
||||||
|
<Text className="text-[13px] font-sans-bold text-muted-foreground uppercase tracking-wider">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = ({ item, isLast }: { item: NotificationItem; isLast: boolean }) => {
|
||||||
|
const time = item.sentAt || item.createdAt
|
||||||
|
? formatRelativeTime(item.sentAt || item.createdAt!)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const iconName = item.icon || "bell";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mb-2">
|
<View className={`${!isLast ? "border-b border-border/40" : ""}`}>
|
||||||
<CardContent className="py-3">
|
<View className="flex-row items-center px-5 py-3 bg-card">
|
||||||
<Text className="font-sans-semibold text-foreground">
|
{/* Icon */}
|
||||||
{item.title ?? "Notification"}
|
<View className="w-12 h-12 rounded-full bg-primary/10 items-center justify-center flex-shrink-0">
|
||||||
</Text>
|
<Bell size={20} color="white" strokeWidth={2} />
|
||||||
{message ? (
|
</View>
|
||||||
<Text className="text-muted-foreground mt-1 text-sm">{message}</Text>
|
|
||||||
) : null}
|
{/* Content */}
|
||||||
{time ? (
|
<View className="flex-1 ml-3 min-w-0">
|
||||||
<Text className="text-muted-foreground mt-1 text-xs">{time}</Text>
|
<Text
|
||||||
) : null}
|
className="text-[14px] font-sans-bold text-foreground"
|
||||||
</CardContent>
|
numberOfLines={1}
|
||||||
</Card>
|
>
|
||||||
|
{item.title || "Notification"}
|
||||||
|
</Text>
|
||||||
|
{item.body ? (
|
||||||
|
<Text
|
||||||
|
className="text-muted-foreground text-[13px] font-sans-medium mt-0.5"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.body}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Time + Unread dot */}
|
||||||
|
<View className="items-end ml-2 flex-shrink-0">
|
||||||
|
{time ? (
|
||||||
|
<Text className="text-muted-foreground/60 text-[11px] font-sans-medium">
|
||||||
|
{time}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View className="w-2 h-2 rounded-full bg-blue-500 mt-1.5" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -97,24 +195,29 @@ export default function NotificationsScreen() {
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<ActivityIndicator />
|
<ActivityIndicator size="large" color="#E46212" />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={items}
|
data={sections}
|
||||||
keyExtractor={(i) => i.id}
|
keyExtractor={(i) => i.key}
|
||||||
renderItem={renderItem}
|
renderItem={({ item }) => {
|
||||||
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
if (item.type === "header") {
|
||||||
|
return renderSectionHeader(item.key.replace("header-", ""));
|
||||||
|
}
|
||||||
|
return renderItem({ item: item.item!, isLast: item.isLast! });
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{ paddingBottom: 32 }}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
onEndReachedThreshold={0.4}
|
onEndReachedThreshold={0.4}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="px-[16px] py-6">
|
<View className="px-5 py-12">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No notifications"
|
title="No notifications"
|
||||||
description="You don't have any notifications yet."
|
description="You're all caught up!"
|
||||||
centered
|
centered
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -122,7 +225,7 @@ export default function NotificationsScreen() {
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
loadingMore ? (
|
loadingMore ? (
|
||||||
<View className="py-4">
|
<View className="py-4">
|
||||||
<ActivityIndicator />
|
<ActivityIndicator size="small" color="#E46212" />
|
||||||
</View>
|
</View>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,28 @@
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { View, ScrollView, ActivityIndicator } from "react-native";
|
import { View, ScrollView, ActivityIndicator, Pressable, TextInput, Modal } from "react-native";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router";
|
import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { User, Calendar, Clock, Building2, Hash, Send } from "@/lib/icons";
|
import { User, Calendar, Clock, Building2, Send, Pencil, ChevronRight } from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
|
function useInputColors() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
placeholder: "rgba(100,116,139,0.45)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_THEME: Record<
|
const STATUS_THEME: Record<
|
||||||
string,
|
string,
|
||||||
{ label: string; bg: string; text: string; dot: string }
|
{ label: string; bg: string; text: string; dot: string }
|
||||||
|
|
@ -69,11 +80,16 @@ export default function PaymentRequestDetailScreen() {
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
const isDark = colorScheme === "dark";
|
const isDark = colorScheme === "dark";
|
||||||
|
const c = useInputColors();
|
||||||
|
|
||||||
const [data, setData] = useState<any>(null);
|
const [data, setData] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const [showSendModal, setShowSendModal] = useState(false);
|
||||||
|
const [sendChannel, setSendChannel] = useState("EMAIL");
|
||||||
|
const [sendRecipient, setSendRecipient] = useState("");
|
||||||
|
|
||||||
const fetch = useCallback(async () => {
|
const fetch = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -97,14 +113,33 @@ export default function PaymentRequestDetailScreen() {
|
||||||
}, [fetch]),
|
}, [fetch]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSendEmail = async () => {
|
const openSendModal = () => {
|
||||||
|
setSendChannel("EMAIL");
|
||||||
|
setSendRecipient(data?.customerEmail || "");
|
||||||
|
setShowSendModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!sendRecipient.trim()) {
|
||||||
|
toast.error("Validation", "Recipient is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
const reqId = Array.isArray(id) ? id[0] : id;
|
const reqId = Array.isArray(id) ? id[0] : id;
|
||||||
await api.paymentRequests.sendEmail({ params: { id: reqId } });
|
await api.paymentRequests.send({
|
||||||
toast.success("Sent", "Payment request emailed to customer");
|
params: { id: reqId },
|
||||||
|
body: {
|
||||||
|
channel: sendChannel,
|
||||||
|
recipient: sendRecipient.trim(),
|
||||||
|
},
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
toast.success("Sent", `Payment request sent via ${sendChannel.toLowerCase()}`);
|
||||||
|
setShowSendModal(false);
|
||||||
|
fetch();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error("Error", err?.message || "Failed to send email");
|
toast.error("Error", err?.message || "Failed to send payment request");
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
|
|
@ -142,8 +177,20 @@ export default function PaymentRequestDetailScreen() {
|
||||||
contentContainerStyle={{ paddingBottom: 120 }}
|
contentContainerStyle={{ paddingBottom: 120 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
|
{/* Status Badge */}
|
||||||
|
<View className="px-5 mt-6 mb-4">
|
||||||
|
<View className={`self-start px-3 py-1 rounded-[6px] ${theme.bg}`}>
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<View className={`w-2 h-2 rounded-full ${theme.dot}`} />
|
||||||
|
<Text className={`text-[11px] font-sans-bold uppercase tracking-wider ${theme.text}`}>
|
||||||
|
{theme.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Customer + Dates cluster */}
|
{/* Customer + Dates cluster */}
|
||||||
<View className="px-5 mt-6 mb-6">
|
<View className="px-5 mb-6">
|
||||||
<View className="bg-card rounded-[6px] border border-border p-4 gap-4">
|
<View className="bg-card rounded-[6px] border border-border p-4 gap-4">
|
||||||
<View className="flex-row items-center gap-3">
|
<View className="flex-row items-center gap-3">
|
||||||
<View className="h-9 w-9 rounded-full bg-primary/10 items-center justify-center">
|
<View className="h-9 w-9 rounded-full bg-primary/10 items-center justify-center">
|
||||||
|
|
@ -346,28 +393,128 @@ export default function PaymentRequestDetailScreen() {
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<View className="px-5 gap-3">
|
<View className="px-5 gap-3">
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-[6px] bg-primary"
|
className="h-11 rounded-[6px] bg-primary"
|
||||||
onPress={handleSendEmail}
|
onPress={openSendModal}
|
||||||
disabled={sending || !data.customerEmail}
|
disabled={sending}
|
||||||
>
|
>
|
||||||
{sending ? (
|
<Send color="#ffffff" size={16} strokeWidth={2.5} />
|
||||||
<ActivityIndicator color="#ffffff" size="small" />
|
<Text className="ml-2 text-white text-[11px] font-sans-bold uppercase tracking-widest">
|
||||||
) : (
|
Send
|
||||||
<>
|
|
||||||
<Send color="#ffffff" size={16} strokeWidth={2.5} />
|
|
||||||
<Text className="ml-2 text-white text-[11px] font-sans-bold uppercase tracking-widest">
|
|
||||||
Send Email
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{!data.customerEmail && (
|
|
||||||
<Text className="text-[11px] text-muted-foreground font-sans-medium text-center">
|
|
||||||
No customer email on file
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-11 rounded-[6px] bg-secondary"
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => {
|
||||||
|
const reqId = Array.isArray(id) ? id[0] : id;
|
||||||
|
nav.go("payment-requests/edit", { id: reqId });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil color={isDark ? "#f1f5f9" : "#0f172a"} size={16} strokeWidth={2} />
|
||||||
|
<Text className="ml-2 text-foreground text-[11px] font-sans-bold uppercase tracking-widest">
|
||||||
|
Edit
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Send Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showSendModal}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setShowSendModal(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 bg-black/40"
|
||||||
|
onPress={() => setShowSendModal(false)}
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-end">
|
||||||
|
<Pressable
|
||||||
|
className="bg-card rounded-t-[36px] border-t border-border p-6 gap-5"
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground">
|
||||||
|
Send Payment Request
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowSendModal(false)}
|
||||||
|
className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text className="text-foreground text-xs font-sans-bold">✕</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Channel Toggle */}
|
||||||
|
<View className="flex-row gap-2">
|
||||||
|
{(["EMAIL", "PHONE"] as const).map((ch) => (
|
||||||
|
<Pressable
|
||||||
|
key={ch}
|
||||||
|
onPress={() => {
|
||||||
|
setSendChannel(ch);
|
||||||
|
if (ch === "EMAIL") {
|
||||||
|
setSendRecipient(data?.customerEmail || "");
|
||||||
|
} else {
|
||||||
|
setSendRecipient(data?.customerPhone || "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex-1 h-11 rounded-[6px] items-center justify-center border ${
|
||||||
|
sendChannel === ch
|
||||||
|
? "bg-primary border-primary"
|
||||||
|
: "bg-card border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-xs font-sans-bold ${
|
||||||
|
sendChannel === ch ? "text-white" : "text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ch === "EMAIL" ? "Email" : "SMS"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Recipient Input */}
|
||||||
|
<View>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
{sendChannel === "EMAIL" ? "Email Address" : "Phone Number"}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="h-12 px-3 rounded-[6px] border text-foreground text-sm font-sans-medium"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border, color: c.text }}
|
||||||
|
placeholder={
|
||||||
|
sendChannel === "EMAIL" ? "email@example.com" : "912345678"
|
||||||
|
}
|
||||||
|
placeholderTextColor={c.placeholder}
|
||||||
|
value={sendRecipient}
|
||||||
|
onChangeText={setSendRecipient}
|
||||||
|
keyboardType={sendChannel === "EMAIL" ? "email-address" : "phone-pad"}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="h-12 rounded-[6px] bg-primary"
|
||||||
|
onPress={handleSend}
|
||||||
|
disabled={sending || !sendRecipient.trim()}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send color="#ffffff" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="ml-2 text-white text-[11px] font-sans-bold uppercase tracking-widest">
|
||||||
|
Send {sendChannel === "EMAIL" ? "Email" : "SMS"}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,23 +20,9 @@ import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
import {
|
import { ChevronDown, Plus, Trash2 } from "@/lib/icons";
|
||||||
Calendar,
|
|
||||||
CalendarSearch,
|
|
||||||
ChevronDown,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from "@/lib/icons";
|
|
||||||
|
|
||||||
type Item = { id: number; description: string; qty: string; price: string };
|
type Item = { id: number; description: string; quantity: string; unitPrice: string };
|
||||||
|
|
||||||
type Account = {
|
|
||||||
id: number;
|
|
||||||
bankName: string;
|
|
||||||
accountName: string;
|
|
||||||
accountNumber: string;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
input: {
|
input: {
|
||||||
|
|
@ -80,6 +66,7 @@ function Field({
|
||||||
center = false,
|
center = false,
|
||||||
flex,
|
flex,
|
||||||
multiline = false,
|
multiline = false,
|
||||||
|
keyboardType,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -89,6 +76,7 @@ function Field({
|
||||||
center?: boolean;
|
center?: boolean;
|
||||||
flex?: number;
|
flex?: number;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
|
keyboardType?: "default" | "numeric" | "email-address" | "phone-pad";
|
||||||
}) {
|
}) {
|
||||||
const c = useInputColors();
|
const c = useInputColors();
|
||||||
return (
|
return (
|
||||||
|
|
@ -108,7 +96,7 @@ function Field({
|
||||||
placeholderTextColor={c.placeholder}
|
placeholderTextColor={c.placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
keyboardType={numeric ? "numeric" : "default"}
|
keyboardType={keyboardType || (numeric ? "numeric" : "default")}
|
||||||
multiline={multiline}
|
multiline={multiline}
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|
@ -147,15 +135,14 @@ function PickerField({
|
||||||
}
|
}
|
||||||
|
|
||||||
const CURRENCIES = ["ETB", "USD", "EUR", "GBP", "KES", "ZAR"];
|
const CURRENCIES = ["ETB", "USD", "EUR", "GBP", "KES", "ZAR"];
|
||||||
|
const CHANNELS = ["EMAIL", "PHONE"];
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ key: "details", label: "Details" },
|
{ key: "details", label: "Details" },
|
||||||
{ key: "customer", label: "Customer" },
|
{ key: "customer", label: "Customer" },
|
||||||
{ key: "schedule", label: "Schedule" },
|
{ key: "schedule", label: "Schedule" },
|
||||||
{ key: "items", label: "Items" },
|
{ key: "items", label: "Items" },
|
||||||
{ key: "accounts", label: "Accounts" },
|
{ key: "paymentMethod", label: "Payment" },
|
||||||
{ key: "totals", label: "Totals" },
|
|
||||||
{ key: "notes", label: "Notes" },
|
|
||||||
{ key: "summary", label: "Summary" },
|
{ key: "summary", label: "Summary" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -165,38 +152,29 @@ export default function CreatePaymentRequestScreen() {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const [paymentRequestNumber, setPaymentRequestNumber] = useState("");
|
const [paymentRequestNumber, setPaymentRequestNumber] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
const [customerEmail, setCustomerEmail] = useState("");
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
const [customerPhone, setCustomerPhone] = useState("");
|
const [customerId, setCustomerId] = useState("");
|
||||||
|
|
||||||
|
const [channel, setChannel] = useState("EMAIL");
|
||||||
|
const [recipient, setRecipient] = useState("");
|
||||||
|
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
const [currency, setCurrency] = useState("ETB");
|
const [currency, setCurrency] = useState("ETB");
|
||||||
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
|
|
||||||
const [taxAmount, setTaxAmount] = useState("0");
|
|
||||||
const [discountAmount, setDiscountAmount] = useState("0");
|
|
||||||
|
|
||||||
const [issueDate, setIssueDate] = useState(
|
const [issueDate, setIssueDate] = useState(
|
||||||
new Date().toISOString().split("T")[0],
|
new Date().toISOString().split("T")[0],
|
||||||
);
|
);
|
||||||
const [dueDate, setDueDate] = useState("");
|
const [dueDate, setDueDate] = useState("");
|
||||||
|
|
||||||
const [status, setStatus] = useState("DRAFT");
|
const [companyPaymentMethodId, setCompanyPaymentMethodId] = useState("");
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState<any[]>([]);
|
||||||
|
const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false);
|
||||||
|
|
||||||
const [items, setItems] = useState<Item[]>([
|
const [items, setItems] = useState<Item[]>([
|
||||||
{ id: 1, description: "", qty: "1", price: "" },
|
{ id: 1, description: "", quantity: "1", unitPrice: "" },
|
||||||
]);
|
|
||||||
|
|
||||||
const [accounts, setAccounts] = useState<Account[]>([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
bankName: "",
|
|
||||||
accountName: "",
|
|
||||||
accountNumber: "",
|
|
||||||
currency: "ETB",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const c = useInputColors();
|
const c = useInputColors();
|
||||||
|
|
@ -204,7 +182,8 @@ export default function CreatePaymentRequestScreen() {
|
||||||
const [showCurrency, setShowCurrency] = useState(false);
|
const [showCurrency, setShowCurrency] = useState(false);
|
||||||
const [showIssueDate, setShowIssueDate] = useState(false);
|
const [showIssueDate, setShowIssueDate] = useState(false);
|
||||||
const [showDueDate, setShowDueDate] = useState(false);
|
const [showDueDate, setShowDueDate] = useState(false);
|
||||||
const [showStatus, setShowStatus] = useState(false);
|
const [showChannel, setShowChannel] = useState(false);
|
||||||
|
const [showPaymentMethod, setShowPaymentMethod] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
@ -216,6 +195,27 @@ export default function CreatePaymentRequestScreen() {
|
||||||
setDueDate(d.toISOString().split("T")[0]);
|
setDueDate(d.toISOString().split("T")[0]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoadingPaymentMethods(true);
|
||||||
|
try {
|
||||||
|
const res = await api.company.paymentMethods();
|
||||||
|
const list = Array.isArray(res) ? res : res?.data || [];
|
||||||
|
setPaymentMethods(list);
|
||||||
|
} catch {
|
||||||
|
setPaymentMethods([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingPaymentMethods(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (channel === "EMAIL" && customerEmail && !recipient) {
|
||||||
|
setRecipient(customerEmail);
|
||||||
|
}
|
||||||
|
}, [channel, customerEmail]);
|
||||||
|
|
||||||
const updateItem = (id: number, field: keyof Item, value: string) =>
|
const updateItem = (id: number, field: keyof Item, value: string) =>
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((it) => (it.id === id ? { ...it, [field]: value } : it)),
|
prev.map((it) => (it.id === id ? { ...it, [field]: value } : it)),
|
||||||
|
|
@ -224,53 +224,24 @@ export default function CreatePaymentRequestScreen() {
|
||||||
const addItem = () =>
|
const addItem = () =>
|
||||||
setItems((prev) => [
|
setItems((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: Date.now(), description: "", qty: "1", price: "" },
|
{ id: Date.now(), description: "", quantity: "1", unitPrice: "" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const removeItem = (id: number) => {
|
const removeItem = (id: number) => {
|
||||||
if (items.length > 1) setItems((prev) => prev.filter((it) => it.id !== id));
|
if (items.length > 1) setItems((prev) => prev.filter((it) => it.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAccount = (id: number, field: keyof Account, value: string) =>
|
|
||||||
setAccounts((prev) =>
|
|
||||||
prev.map((acc) => (acc.id === id ? { ...acc, [field]: value } : acc)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const addAccount = () =>
|
|
||||||
setAccounts((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: Date.now(),
|
|
||||||
bankName: "",
|
|
||||||
accountName: "",
|
|
||||||
accountNumber: "",
|
|
||||||
currency: "ETB",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const removeAccount = (id: number) => {
|
|
||||||
if (accounts.length > 1)
|
|
||||||
setAccounts((prev) => prev.filter((acc) => acc.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const subtotal = useMemo(
|
const subtotal = useMemo(
|
||||||
() =>
|
() =>
|
||||||
items.reduce(
|
items.reduce(
|
||||||
(sum, item) =>
|
(sum, item) =>
|
||||||
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
|
sum +
|
||||||
|
(parseFloat(item.quantity) || 0) * (parseFloat(item.unitPrice) || 0),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
[items],
|
[items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const computedTotal = useMemo(
|
|
||||||
() =>
|
|
||||||
subtotal +
|
|
||||||
(parseFloat(taxAmount) || 0) -
|
|
||||||
(parseFloat(discountAmount) || 0),
|
|
||||||
[subtotal, taxAmount, discountAmount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (step === 0 && !paymentRequestNumber.trim()) {
|
if (step === 0 && !paymentRequestNumber.trim()) {
|
||||||
toast.error("Validation", "Payment request number is required");
|
toast.error("Validation", "Payment request number is required");
|
||||||
|
|
@ -285,38 +256,29 @@ export default function CreatePaymentRequestScreen() {
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!customerName) {
|
if (!customerName) {
|
||||||
toast.error("Validation Error", "Please enter a customer name");
|
toast.error("Validation", "Please select a customer");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedPhone = customerPhone ? `+251${customerPhone}` : "";
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
paymentRequestNumber,
|
paymentRequestNumber,
|
||||||
customerName,
|
customerName,
|
||||||
customerEmail,
|
customerEmail: customerEmail || undefined,
|
||||||
customerPhone: formattedPhone,
|
channel,
|
||||||
amount: amount ? Number(amount) : Number(computedTotal.toFixed(2)),
|
recipient,
|
||||||
|
amount: amount ? Number(amount) : Number(subtotal.toFixed(2)),
|
||||||
currency,
|
currency,
|
||||||
issueDate: new Date(issueDate).toISOString(),
|
issueDate: new Date(issueDate).toISOString(),
|
||||||
dueDate: new Date(dueDate).toISOString(),
|
dueDate: new Date(dueDate).toISOString(),
|
||||||
description: description || `Payment request for ${customerName}`,
|
companyPaymentMethodId: companyPaymentMethodId || undefined,
|
||||||
notes,
|
customerId: customerId || undefined,
|
||||||
taxAmount: parseFloat(taxAmount) || 0,
|
...(description ? { description } : {}),
|
||||||
discountAmount: parseFloat(discountAmount) || 0,
|
|
||||||
status,
|
|
||||||
accounts: accounts.map((a) => ({
|
|
||||||
bankName: a.bankName,
|
|
||||||
accountName: a.accountName,
|
|
||||||
accountNumber: a.accountNumber,
|
|
||||||
currency: a.currency,
|
|
||||||
})),
|
|
||||||
items: items.map((i) => ({
|
items: items.map((i) => ({
|
||||||
description: i.description || "Item",
|
description: i.description || "Item",
|
||||||
quantity: parseFloat(i.qty) || 0,
|
quantity: parseFloat(i.quantity) || 0,
|
||||||
unitPrice: parseFloat(i.price) || 0,
|
unitPrice: parseFloat(i.unitPrice) || 0,
|
||||||
total: Number(
|
total: Number(
|
||||||
((parseFloat(i.qty) || 0) * (parseFloat(i.price) || 0)).toFixed(2),
|
((parseFloat(i.quantity) || 0) * (parseFloat(i.unitPrice) || 0)).toFixed(2),
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
@ -339,6 +301,12 @@ export default function CreatePaymentRequestScreen() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const paymentMethodLabel = companyPaymentMethodId
|
||||||
|
? paymentMethods.find((pm: any) => pm.id === companyPaymentMethodId)
|
||||||
|
?.label || paymentMethods.find((pm: any) => pm.id === companyPaymentMethodId)
|
||||||
|
?.providerName || "Selected"
|
||||||
|
: "Select";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<FormFlow
|
<FormFlow
|
||||||
|
|
@ -367,6 +335,7 @@ export default function CreatePaymentRequestScreen() {
|
||||||
value={description}
|
value={description}
|
||||||
onChangeText={setDescription}
|
onChangeText={setDescription}
|
||||||
placeholder="e.g. Payment request for services"
|
placeholder="e.g. Payment request for services"
|
||||||
|
multiline
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -377,48 +346,49 @@ export default function CreatePaymentRequestScreen() {
|
||||||
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
Customer Information
|
Customer Information
|
||||||
</Text>
|
</Text>
|
||||||
<View className="gap-4">
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
Customer Name
|
Customer
|
||||||
</Text>
|
</Text>
|
||||||
<CustomerPicker
|
<CustomerPicker
|
||||||
value={customerName}
|
selectedIds={customerId ? [customerId] : []}
|
||||||
onSelect={(c) => {
|
selectedCustomers={
|
||||||
setCustomerName(c.name);
|
customerId
|
||||||
setCustomerEmail(c.email);
|
? [
|
||||||
setCustomerPhone(c.phone.replace("+251", ""));
|
{
|
||||||
|
id: customerId,
|
||||||
|
name: customerName,
|
||||||
|
email: customerEmail,
|
||||||
|
phone: "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onSelect={(ids, customers) => {
|
||||||
|
setCustomerId(ids[0] || "");
|
||||||
|
setCustomerName(customers[0]?.name || "");
|
||||||
|
setCustomerEmail(customers[0]?.email || "");
|
||||||
}}
|
}}
|
||||||
placeholder="Select or search for a customer"
|
placeholder="Select a customer"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row gap-4">
|
<PickerField
|
||||||
<Field
|
label="Invite Channel"
|
||||||
label="Email"
|
value={channel}
|
||||||
value={customerEmail}
|
onPress={() => setShowChannel(true)}
|
||||||
onChangeText={setCustomerEmail}
|
/>
|
||||||
placeholder="billing@acme.com"
|
<Field
|
||||||
flex={1}
|
label={channel === "EMAIL" ? "Recipient Email" : "Recipient Phone"}
|
||||||
/>
|
value={recipient}
|
||||||
</View>
|
onChangeText={setRecipient}
|
||||||
<View>
|
placeholder={
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
channel === "EMAIL"
|
||||||
Phone
|
? "email@example.com"
|
||||||
</Text>
|
: "912345678"
|
||||||
<View className="flex-row items-center h-11 px-3 border border-border rounded-[6px]" style={{ backgroundColor: c.bg, borderColor: c.border }}>
|
}
|
||||||
<Text className="text-foreground font-sans-bold text-xs">+251</Text>
|
keyboardType={channel === "EMAIL" ? "email-address" : "phone-pad"}
|
||||||
<TextInput
|
/>
|
||||||
className="flex-1 ml-2 text-foreground text-xs font-sans-medium"
|
|
||||||
placeholder="912345678"
|
|
||||||
placeholderTextColor={c.placeholder}
|
|
||||||
value={customerPhone}
|
|
||||||
onChangeText={setCustomerPhone}
|
|
||||||
keyboardType="phone-pad"
|
|
||||||
maxLength={9}
|
|
||||||
style={{ textAlignVertical: "center" }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -447,17 +417,12 @@ export default function CreatePaymentRequestScreen() {
|
||||||
value={currency}
|
value={currency}
|
||||||
onPress={() => setShowCurrency(true)}
|
onPress={() => setShowCurrency(true)}
|
||||||
/>
|
/>
|
||||||
<PickerField
|
|
||||||
label="Status"
|
|
||||||
value={status}
|
|
||||||
onPress={() => setShowStatus(true)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<Field
|
<Field
|
||||||
label="Amount"
|
label="Amount"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChangeText={setAmount}
|
onChangeText={setAmount}
|
||||||
placeholder="1500"
|
placeholder={subtotal > 0 ? `${currency} ${subtotal.toFixed(2)}` : "Enter amount"}
|
||||||
numeric
|
numeric
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -506,142 +471,144 @@ export default function CreatePaymentRequestScreen() {
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
numeric
|
numeric
|
||||||
center
|
center
|
||||||
value={item.qty}
|
value={item.quantity}
|
||||||
onChangeText={(v) => updateItem(item.id, "qty", v)}
|
onChangeText={(v) => updateItem(item.id, "quantity", v)}
|
||||||
flex={1}
|
flex={1}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Price"
|
label="Unit Price"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
numeric
|
numeric
|
||||||
value={item.price}
|
value={item.unitPrice}
|
||||||
onChangeText={(v) => updateItem(item.id, "price", v)}
|
onChangeText={(v) => updateItem(item.id, "unitPrice", v)}
|
||||||
flex={3}
|
flex={3}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
{parseFloat(item.quantity) > 0 && parseFloat(item.unitPrice) > 0 && (
|
||||||
|
<View className="flex-row justify-end mt-2">
|
||||||
|
<Text className="text-[12px] text-muted-foreground font-sans-medium">
|
||||||
|
= {currency}{" "}
|
||||||
|
{(
|
||||||
|
(parseFloat(item.quantity) || 0) *
|
||||||
|
(parseFloat(item.unitPrice) || 0)
|
||||||
|
).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
{subtotal > 0 && (
|
||||||
|
<View className="flex-row justify-end items-center gap-2 pt-2 border-t border-border">
|
||||||
|
<Text className="text-[14px] text-muted-foreground font-sans-medium">
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[15px] text-foreground font-sans-bold">
|
||||||
|
{currency} {subtotal.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<View className="gap-5">
|
<View className="gap-5">
|
||||||
<View className="flex-row items-center justify-between">
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
Payment Method
|
||||||
Accounts
|
</Text>
|
||||||
</Text>
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
<Pressable
|
<View>
|
||||||
onPress={addAccount}
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
className="flex-row items-center gap-1 px-3 py-1.5 rounded-[6px] bg-primary/10 border border-primary/20"
|
Company Payment Method
|
||||||
>
|
|
||||||
<Plus color="#ea580c" size={14} strokeWidth={2.5} />
|
|
||||||
<Text className="text-primary text-[12px] font-sans-bold">
|
|
||||||
Add
|
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
<Pressable
|
||||||
</View>
|
onPress={() => {
|
||||||
<View className="gap-3">
|
if (paymentMethods.length > 0) {
|
||||||
{accounts.map((acc, index) => (
|
setShowPaymentMethod(true);
|
||||||
<View
|
} else {
|
||||||
key={acc.id}
|
toast.error(
|
||||||
className={`bg-card pb-4 ${index < accounts.length - 1 ? "border-b border-border" : ""}`}
|
"No Methods",
|
||||||
|
"No payment methods available. Please configure one in company settings.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
>
|
>
|
||||||
<View className="flex-row justify-between items-center mb-3">
|
{loadingPaymentMethods ? (
|
||||||
<Text className="text-[16px] font-sans-bold text-foreground">
|
<ActivityIndicator color="#ea580c" size="small" />
|
||||||
Account {index + 1}
|
) : (
|
||||||
</Text>
|
<>
|
||||||
<Pressable
|
<Text
|
||||||
onPress={() => removeAccount(acc.id)}
|
className="text-xs font-sans-medium flex-1"
|
||||||
hitSlop={8}
|
style={{
|
||||||
>
|
color: companyPaymentMethodId ? c.text : c.placeholder,
|
||||||
<Trash2 color="#ef4444" size={16} />
|
}}
|
||||||
</Pressable>
|
numberOfLines={1}
|
||||||
</View>
|
>
|
||||||
<Field
|
{paymentMethodLabel}
|
||||||
label="Bank Name"
|
</Text>
|
||||||
value={acc.bankName}
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
onChangeText={(v) => updateAccount(acc.id, "bankName", v)}
|
</>
|
||||||
placeholder="e.g. Yaltopia Bank"
|
)}
|
||||||
/>
|
</Pressable>
|
||||||
<View className="flex-row gap-4 mt-4">
|
</View>
|
||||||
<Field
|
|
||||||
label="Account Name"
|
|
||||||
value={acc.accountName}
|
|
||||||
onChangeText={(v) =>
|
|
||||||
updateAccount(acc.id, "accountName", v)
|
|
||||||
}
|
|
||||||
placeholder="e.g. Yaltopia Tech PLC"
|
|
||||||
flex={1}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row gap-4 mt-4">
|
|
||||||
<Field
|
|
||||||
label="Account Number"
|
|
||||||
value={acc.accountNumber}
|
|
||||||
onChangeText={(v) =>
|
|
||||||
updateAccount(acc.id, "accountNumber", v)
|
|
||||||
}
|
|
||||||
placeholder="123456789"
|
|
||||||
flex={2}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Currency"
|
|
||||||
value={acc.currency}
|
|
||||||
onChangeText={(v) => updateAccount(acc.id, "currency", v)}
|
|
||||||
placeholder="ETB"
|
|
||||||
flex={1}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 5 && (
|
{step === 5 && (
|
||||||
<View className="gap-5">
|
<View className="gap-5 pb-4">
|
||||||
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
Totals
|
Summary
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-card rounded-[6px] pb-4 gap-4">
|
<View className="bg-card rounded-[6px] gap-3">
|
||||||
<View className="flex-row justify-between items-center">
|
<SummaryRow label="Request Number" value={paymentRequestNumber} />
|
||||||
<Text className="text-foreground font-sans-medium text-sm">
|
{description ? (
|
||||||
Subtotal
|
<SummaryRow label="Description" value={description} multiline />
|
||||||
|
) : null}
|
||||||
|
<SummaryRow label="Customer" value={customerName} />
|
||||||
|
<SummaryRow label="Channel" value={channel} />
|
||||||
|
<SummaryRow label="Recipient" value={recipient} />
|
||||||
|
<View className="border-t border-border/40 my-1" />
|
||||||
|
<SummaryRow label="Issue Date" value={issueDate} />
|
||||||
|
<SummaryRow label="Due Date" value={dueDate || "Not set"} />
|
||||||
|
<SummaryRow label="Currency" value={currency} />
|
||||||
|
{items.length > 0 && (
|
||||||
|
<View className="border-t border-border/40 pt-3">
|
||||||
|
<Text className="text-[12px] text-muted-foreground font-sans-bold uppercase tracking-widest mb-2">
|
||||||
|
Items ({items.length})
|
||||||
|
</Text>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className="flex-row justify-between py-1"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-[13px] text-foreground font-sans-medium flex-1"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.description || `Item ${i + 1}`}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[13px] text-foreground font-sans-bold">
|
||||||
|
{item.quantity} × {currency}{" "}
|
||||||
|
{parseFloat(item.unitPrice || "0").toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{paymentMethodLabel !== "Select" && (
|
||||||
|
<SummaryRow label="Payment Method" value={paymentMethodLabel} />
|
||||||
|
)}
|
||||||
|
<View className="border-t border-border/40 my-1" />
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[16px] font-sans-bold text-foreground">
|
||||||
|
Total Amount
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-foreground font-sans-bold">
|
<Text className="text-[16px] font-sans-bold text-primary">
|
||||||
{currency}{" "}
|
{currency}{" "}
|
||||||
{subtotal.toLocaleString("en-US", {
|
{(amount ? Number(amount) : subtotal).toLocaleString(
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row gap-4">
|
|
||||||
<Field
|
|
||||||
label="Tax Amount"
|
|
||||||
value={taxAmount}
|
|
||||||
onChangeText={setTaxAmount}
|
|
||||||
placeholder="0"
|
|
||||||
numeric
|
|
||||||
flex={1}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Discount"
|
|
||||||
value={discountAmount}
|
|
||||||
onChangeText={setDiscountAmount}
|
|
||||||
placeholder="0"
|
|
||||||
numeric
|
|
||||||
flex={1}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="border-t border-border/40 pt-4 flex-row justify-between">
|
|
||||||
<Text className="text-foreground font-sans-bold text-[16px]">
|
|
||||||
Total
|
|
||||||
</Text>
|
|
||||||
<Text className="text-primary font-sans-bold text-[16px]">
|
|
||||||
{currency}{" "}
|
|
||||||
{(amount ? Number(amount) : computedTotal).toLocaleString(
|
|
||||||
"en-US",
|
"en-US",
|
||||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
|
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
|
||||||
)}
|
)}
|
||||||
|
|
@ -650,172 +617,6 @@ export default function CreatePaymentRequestScreen() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{step === 6 && (
|
|
||||||
<View className="bg-card rounded-[6px]">
|
|
||||||
<Field
|
|
||||||
label="Notes"
|
|
||||||
value={notes}
|
|
||||||
onChangeText={setNotes}
|
|
||||||
placeholder="e.g. Payment terms: Net 30"
|
|
||||||
multiline
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 7 && (
|
|
||||||
<>
|
|
||||||
<View className="gap-5 pb-4">
|
|
||||||
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
|
||||||
Summary
|
|
||||||
</Text>
|
|
||||||
<View className="bg-card rounded-[6px] gap-3">
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Request Number
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
|
|
||||||
{paymentRequestNumber}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Customer
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
|
|
||||||
{customerName}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{customerEmail ? (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Email
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
|
|
||||||
{customerEmail}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{customerPhone ? (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Phone
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
|
|
||||||
+251{customerPhone}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{description ? (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Description
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4"
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Issue Date
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{issueDate}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Due Date
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{dueDate || "Not set"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Status
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{status}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{items.length > 0 && (
|
|
||||||
<View className="border-t border-border/40 pt-3">
|
|
||||||
<Text className="text-[12px] text-muted-foreground font-sans-bold uppercase tracking-widest mb-2">
|
|
||||||
Items ({items.length})
|
|
||||||
</Text>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<View
|
|
||||||
key={item.id}
|
|
||||||
className="flex-row justify-between py-1"
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className="text-[13px] text-foreground font-sans-medium flex-1"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{item.description || `Item ${i + 1}`}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[13px] text-foreground font-sans-bold">
|
|
||||||
{item.qty} × {currency}{" "}
|
|
||||||
{parseFloat(item.price || "0").toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{notes ? (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Notes
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4"
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{notes}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{parseFloat(taxAmount) > 0 && (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Tax
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
{currency} {parseFloat(taxAmount).toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{parseFloat(discountAmount) > 0 && (
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-medium">
|
|
||||||
Discount
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
|
||||||
-{currency} {parseFloat(discountAmount).toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View className="border-t border-border/40 my-1" />
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<Text className="text-[16px] font-sans-bold text-foreground">
|
|
||||||
Total Amount
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[16px] font-sans-bold text-primary">
|
|
||||||
{currency}{" "}
|
|
||||||
{(amount ? Number(amount) : computedTotal).toLocaleString(
|
|
||||||
"en-US",
|
|
||||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FormFlow>
|
</FormFlow>
|
||||||
|
|
||||||
<PickerModal
|
<PickerModal
|
||||||
|
|
@ -838,24 +639,41 @@ export default function CreatePaymentRequestScreen() {
|
||||||
</PickerModal>
|
</PickerModal>
|
||||||
|
|
||||||
<PickerModal
|
<PickerModal
|
||||||
visible={showStatus}
|
visible={showChannel}
|
||||||
onClose={() => setShowStatus(false)}
|
onClose={() => setShowChannel(false)}
|
||||||
title="Select Status"
|
title="Invite Channel"
|
||||||
>
|
>
|
||||||
{["DRAFT", "SENT", "OPENED", "PAID", "EXPIRED", "CANCELLED"].map(
|
{CHANNELS.map((ch) => (
|
||||||
(s) => (
|
<SelectOption
|
||||||
<SelectOption
|
key={ch}
|
||||||
key={s}
|
label={ch}
|
||||||
label={s}
|
value={ch}
|
||||||
value={s}
|
selected={channel === ch}
|
||||||
selected={status === s}
|
onSelect={(v) => {
|
||||||
onSelect={(v) => {
|
setChannel(v);
|
||||||
setStatus(v);
|
setShowChannel(false);
|
||||||
setShowStatus(false);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
))}
|
||||||
),
|
</PickerModal>
|
||||||
)}
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showPaymentMethod}
|
||||||
|
onClose={() => setShowPaymentMethod(false)}
|
||||||
|
title="Payment Method"
|
||||||
|
>
|
||||||
|
{paymentMethods.map((pm: any) => (
|
||||||
|
<SelectOption
|
||||||
|
key={pm.id}
|
||||||
|
label={pm.label || pm.providerName || pm.bankName || "Method"}
|
||||||
|
value={pm.id}
|
||||||
|
selected={companyPaymentMethodId === pm.id}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setCompanyPaymentMethodId(v);
|
||||||
|
setShowPaymentMethod(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</PickerModal>
|
</PickerModal>
|
||||||
|
|
||||||
<PickerModal
|
<PickerModal
|
||||||
|
|
@ -888,3 +706,27 @@ export default function CreatePaymentRequestScreen() {
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
multiline,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4"
|
||||||
|
numberOfLines={multiline ? undefined : 2}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
781
app/payment-requests/edit.tsx
Normal file
781
app/payment-requests/edit.tsx
Normal file
|
|
@ -0,0 +1,781 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { FormFlow } from "@/components/FormFlow";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
import { ChevronDown, Plus, Trash2 } from "@/lib/icons";
|
||||||
|
|
||||||
|
type Item = { id: number; description: string; quantity: string; unitPrice: string };
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
input: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
textAlignVertical: "center",
|
||||||
|
},
|
||||||
|
inputCenter: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
textAlign: "center",
|
||||||
|
textAlignVertical: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function useInputColors() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
placeholder: "rgba(100,116,139,0.45)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
numeric = false,
|
||||||
|
center = false,
|
||||||
|
flex,
|
||||||
|
multiline = false,
|
||||||
|
keyboardType,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
numeric?: boolean;
|
||||||
|
center?: boolean;
|
||||||
|
flex?: number;
|
||||||
|
multiline?: boolean;
|
||||||
|
keyboardType?: "default" | "numeric" | "email-address" | "phone-pad";
|
||||||
|
}) {
|
||||||
|
const c = useInputColors();
|
||||||
|
return (
|
||||||
|
<View style={flex != null ? { flex } : undefined}>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
center ? S.inputCenter : S.input,
|
||||||
|
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
|
||||||
|
multiline
|
||||||
|
? { height: 80, paddingTop: 10, textAlignVertical: "top" }
|
||||||
|
: {},
|
||||||
|
]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={c.placeholder}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType={keyboardType || (numeric ? "numeric" : "default")}
|
||||||
|
multiline={multiline}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickerField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const c = useInputColors();
|
||||||
|
return (
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-sans-bold" style={{ color: c.text }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCIES = ["ETB", "USD", "EUR", "GBP", "KES", "ZAR"];
|
||||||
|
const CHANNELS = ["EMAIL", "PHONE"];
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: "details", label: "Details" },
|
||||||
|
{ key: "customer", label: "Customer" },
|
||||||
|
{ key: "schedule", label: "Schedule" },
|
||||||
|
{ key: "items", label: "Items" },
|
||||||
|
{ key: "paymentMethod", label: "Payment" },
|
||||||
|
{ key: "summary", label: "Summary" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(d: string | null | undefined): string {
|
||||||
|
if (!d) return "";
|
||||||
|
try {
|
||||||
|
return new Date(d).toISOString().split("T")[0];
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditPaymentRequestScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
|
||||||
|
const [paymentRequestNumber, setPaymentRequestNumber] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
|
const [customerId, setCustomerId] = useState("");
|
||||||
|
|
||||||
|
const [channel, setChannel] = useState("EMAIL");
|
||||||
|
const [recipient, setRecipient] = useState("");
|
||||||
|
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [currency, setCurrency] = useState("ETB");
|
||||||
|
|
||||||
|
const [issueDate, setIssueDate] = useState(
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
|
);
|
||||||
|
const [dueDate, setDueDate] = useState("");
|
||||||
|
|
||||||
|
const [companyPaymentMethodId, setCompanyPaymentMethodId] = useState("");
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState<any[]>([]);
|
||||||
|
const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<Item[]>([
|
||||||
|
{ id: 1, description: "", quantity: "1", unitPrice: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const c = useInputColors();
|
||||||
|
|
||||||
|
const [showCurrency, setShowCurrency] = useState(false);
|
||||||
|
const [showIssueDate, setShowIssueDate] = useState(false);
|
||||||
|
const [showDueDate, setShowDueDate] = useState(false);
|
||||||
|
const [showChannel, setShowChannel] = useState(false);
|
||||||
|
const [showPaymentMethod, setShowPaymentMethod] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoadingData(true);
|
||||||
|
const reqId = Array.isArray(id) ? id[0] : id;
|
||||||
|
if (!reqId) return;
|
||||||
|
const data = await api.paymentRequests.getById({ params: { id: reqId } });
|
||||||
|
setPaymentRequestNumber(data.paymentRequestNumber || "");
|
||||||
|
setDescription(data.description || "");
|
||||||
|
setCustomerName(data.customerName || "");
|
||||||
|
setCustomerEmail(data.customerEmail || "");
|
||||||
|
setCustomerId(data.customerId || "");
|
||||||
|
setChannel(data.channel || "EMAIL");
|
||||||
|
setRecipient(data.recipient || data.customerEmail || "");
|
||||||
|
setAmount(data.amount != null ? String(data.amount) : "");
|
||||||
|
setCurrency(data.currency || "ETB");
|
||||||
|
setIssueDate(formatDate(data.issueDate) || new Date().toISOString().split("T")[0]);
|
||||||
|
setDueDate(formatDate(data.dueDate));
|
||||||
|
setCompanyPaymentMethodId(data.companyPaymentMethodId || "");
|
||||||
|
if (data.items?.length) {
|
||||||
|
setItems(
|
||||||
|
data.items.map((it: any, idx: number) => ({
|
||||||
|
id: idx + 1,
|
||||||
|
description: it.description || "",
|
||||||
|
quantity: String(it.quantity ?? 1),
|
||||||
|
unitPrice: String(it.unitPrice ?? ""),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Error", "Failed to load payment request");
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoadingPaymentMethods(true);
|
||||||
|
try {
|
||||||
|
const res = await api.company.paymentMethods();
|
||||||
|
const list = Array.isArray(res) ? res : res?.data || [];
|
||||||
|
setPaymentMethods(list);
|
||||||
|
} catch {
|
||||||
|
setPaymentMethods([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingPaymentMethods(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (channel === "EMAIL" && customerEmail && !recipient) {
|
||||||
|
setRecipient(customerEmail);
|
||||||
|
}
|
||||||
|
}, [channel, customerEmail]);
|
||||||
|
|
||||||
|
const updateItem = (id: number, field: keyof Item, value: string) =>
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((it) => (it.id === id ? { ...it, [field]: value } : it)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addItem = () =>
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: Date.now(), description: "", quantity: "1", unitPrice: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeItem = (id: number) => {
|
||||||
|
if (items.length > 1) setItems((prev) => prev.filter((it) => it.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal = useMemo(
|
||||||
|
() =>
|
||||||
|
items.reduce(
|
||||||
|
(sum, item) =>
|
||||||
|
sum +
|
||||||
|
(parseFloat(item.quantity) || 0) * (parseFloat(item.unitPrice) || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (step === 0 && !paymentRequestNumber.trim()) {
|
||||||
|
toast.error("Validation", "Payment request number is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 1 && !customerName.trim()) {
|
||||||
|
toast.error("Validation", "Customer name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(step + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!customerName) {
|
||||||
|
toast.error("Validation", "Please select a customer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
paymentRequestNumber,
|
||||||
|
customerName,
|
||||||
|
customerEmail: customerEmail || undefined,
|
||||||
|
channel,
|
||||||
|
recipient,
|
||||||
|
amount: amount ? Number(amount) : Number(subtotal.toFixed(2)),
|
||||||
|
currency,
|
||||||
|
issueDate: new Date(issueDate).toISOString(),
|
||||||
|
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
||||||
|
companyPaymentMethodId: companyPaymentMethodId || undefined,
|
||||||
|
customerId: customerId || undefined,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
items: items.map((i) => ({
|
||||||
|
description: i.description || "Item",
|
||||||
|
quantity: parseFloat(i.quantity) || 0,
|
||||||
|
unitPrice: parseFloat(i.unitPrice) || 0,
|
||||||
|
total: Number(
|
||||||
|
((parseFloat(i.quantity) || 0) * (parseFloat(i.unitPrice) || 0)).toFixed(2),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const reqId = Array.isArray(id) ? id[0] : id;
|
||||||
|
await api.paymentRequests.update({
|
||||||
|
params: { id: reqId },
|
||||||
|
body,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
toast.success("Success", "Payment request updated successfully!");
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[PaymentRequestEdit] Error:", err);
|
||||||
|
toast.error("Error", err?.message || "Failed to update payment request");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentMethodLabel = companyPaymentMethodId
|
||||||
|
? paymentMethods.find((pm: any) => pm.id === companyPaymentMethodId)
|
||||||
|
?.label || paymentMethods.find((pm: any) => pm.id === companyPaymentMethodId)
|
||||||
|
?.providerName || "Selected"
|
||||||
|
: "Select";
|
||||||
|
|
||||||
|
if (loadingData) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#E46212" />
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<FormFlow
|
||||||
|
steps={STEPS}
|
||||||
|
currentStep={step}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={() => setStep(step - 1)}
|
||||||
|
onComplete={handleSubmit}
|
||||||
|
loading={submitting}
|
||||||
|
completeLabel="Update Request"
|
||||||
|
>
|
||||||
|
{step === 0 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Request Details
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<Field
|
||||||
|
label="Request Number"
|
||||||
|
value={paymentRequestNumber}
|
||||||
|
onChangeText={setPaymentRequestNumber}
|
||||||
|
placeholder="e.g. PAYREQ-2024-001"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="e.g. Payment request for services"
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Customer Information
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
Customer
|
||||||
|
</Text>
|
||||||
|
<CustomerPicker
|
||||||
|
selectedIds={customerId ? [customerId] : []}
|
||||||
|
selectedCustomers={
|
||||||
|
customerId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: customerId,
|
||||||
|
name: customerName,
|
||||||
|
email: customerEmail,
|
||||||
|
phone: "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onSelect={(ids, customers) => {
|
||||||
|
setCustomerId(ids[0] || "");
|
||||||
|
setCustomerName(customers[0]?.name || "");
|
||||||
|
setCustomerEmail(customers[0]?.email || "");
|
||||||
|
}}
|
||||||
|
placeholder="Select a customer"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<PickerField
|
||||||
|
label="Invite Channel"
|
||||||
|
value={channel}
|
||||||
|
onPress={() => setShowChannel(true)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={channel === "EMAIL" ? "Recipient Email" : "Recipient Phone"}
|
||||||
|
value={recipient}
|
||||||
|
onChangeText={setRecipient}
|
||||||
|
placeholder={
|
||||||
|
channel === "EMAIL"
|
||||||
|
? "email@example.com"
|
||||||
|
: "912345678"
|
||||||
|
}
|
||||||
|
keyboardType={channel === "EMAIL" ? "email-address" : "phone-pad"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Schedule & Currency
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<PickerField
|
||||||
|
label="Issue Date"
|
||||||
|
value={issueDate}
|
||||||
|
onPress={() => setShowIssueDate(true)}
|
||||||
|
/>
|
||||||
|
<PickerField
|
||||||
|
label="Due Date"
|
||||||
|
value={dueDate || "Select"}
|
||||||
|
onPress={() => setShowDueDate(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<PickerField
|
||||||
|
label="Currency"
|
||||||
|
value={currency}
|
||||||
|
onPress={() => setShowCurrency(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Field
|
||||||
|
label="Amount"
|
||||||
|
value={amount}
|
||||||
|
onChangeText={setAmount}
|
||||||
|
placeholder={subtotal > 0 ? `${currency} ${subtotal.toFixed(2)}` : "Enter amount"}
|
||||||
|
numeric
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Items
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={addItem}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1.5 rounded-[6px] bg-primary/10 border border-primary/20"
|
||||||
|
>
|
||||||
|
<Plus color="#ea580c" size={14} strokeWidth={2.5} />
|
||||||
|
<Text className="text-primary text-[10px] font-sans-bold">
|
||||||
|
Add
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View className="gap-3">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className={`bg-card pb-4 ${index < items.length - 1 ? "border-b border-border" : ""}`}
|
||||||
|
>
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<Text className="text-[16px] font-sans-bold text-foreground">
|
||||||
|
Item {index + 1}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => removeItem(item.id)} hitSlop={8}>
|
||||||
|
<Trash2 color="#ef4444" size={16} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
placeholder="e.g. Web Development"
|
||||||
|
value={item.description}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "description", v)}
|
||||||
|
/>
|
||||||
|
<View className="flex-row gap-3 mt-4">
|
||||||
|
<Field
|
||||||
|
label="Qty"
|
||||||
|
placeholder="1"
|
||||||
|
numeric
|
||||||
|
center
|
||||||
|
value={item.quantity}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "quantity", v)}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Unit Price"
|
||||||
|
placeholder="0.00"
|
||||||
|
numeric
|
||||||
|
value={item.unitPrice}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "unitPrice", v)}
|
||||||
|
flex={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{parseFloat(item.quantity) > 0 && parseFloat(item.unitPrice) > 0 && (
|
||||||
|
<View className="flex-row justify-end mt-2">
|
||||||
|
<Text className="text-[12px] text-muted-foreground font-sans-medium">
|
||||||
|
= {currency}{" "}
|
||||||
|
{(
|
||||||
|
(parseFloat(item.quantity) || 0) *
|
||||||
|
(parseFloat(item.unitPrice) || 0)
|
||||||
|
).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{subtotal > 0 && (
|
||||||
|
<View className="flex-row justify-end items-center gap-2 pt-2 border-t border-border">
|
||||||
|
<Text className="text-[14px] text-muted-foreground font-sans-medium">
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[15px] text-foreground font-sans-bold">
|
||||||
|
{currency} {subtotal.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Payment Method
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
Company Payment Method
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
if (paymentMethods.length > 0) {
|
||||||
|
setShowPaymentMethod(true);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
"No Methods",
|
||||||
|
"No payment methods available. Please configure one in company settings.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
{loadingPaymentMethods ? (
|
||||||
|
<ActivityIndicator color="#ea580c" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-sans-medium flex-1"
|
||||||
|
style={{
|
||||||
|
color: companyPaymentMethodId ? c.text : c.placeholder,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{paymentMethodLabel}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<View className="gap-5 pb-4">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Summary
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-3">
|
||||||
|
<SummaryRow label="Request Number" value={paymentRequestNumber} />
|
||||||
|
{description ? (
|
||||||
|
<SummaryRow label="Description" value={description} multiline />
|
||||||
|
) : null}
|
||||||
|
<SummaryRow label="Customer" value={customerName} />
|
||||||
|
<SummaryRow label="Channel" value={channel} />
|
||||||
|
<SummaryRow label="Recipient" value={recipient} />
|
||||||
|
<View className="border-t border-border/40 my-1" />
|
||||||
|
<SummaryRow label="Issue Date" value={issueDate} />
|
||||||
|
<SummaryRow label="Due Date" value={dueDate || "Not set"} />
|
||||||
|
<SummaryRow label="Currency" value={currency} />
|
||||||
|
{items.length > 0 && (
|
||||||
|
<View className="border-t border-border/40 pt-3">
|
||||||
|
<Text className="text-[12px] text-muted-foreground font-sans-bold uppercase tracking-widest mb-2">
|
||||||
|
Items ({items.length})
|
||||||
|
</Text>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className="flex-row justify-between py-1"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-[13px] text-foreground font-sans-medium flex-1"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.description || `Item ${i + 1}`}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[13px] text-foreground font-sans-bold">
|
||||||
|
{item.quantity} × {currency}{" "}
|
||||||
|
{parseFloat(item.unitPrice || "0").toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{paymentMethodLabel !== "Select" && (
|
||||||
|
<SummaryRow label="Payment Method" value={paymentMethodLabel} />
|
||||||
|
)}
|
||||||
|
<View className="border-t border-border/40 my-1" />
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[16px] font-sans-bold text-foreground">
|
||||||
|
Total Amount
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] font-sans-bold text-primary">
|
||||||
|
{currency}{" "}
|
||||||
|
{(amount ? Number(amount) : subtotal).toLocaleString(
|
||||||
|
"en-US",
|
||||||
|
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</FormFlow>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showCurrency}
|
||||||
|
onClose={() => setShowCurrency(false)}
|
||||||
|
title="Select Currency"
|
||||||
|
>
|
||||||
|
{CURRENCIES.map((curr) => (
|
||||||
|
<SelectOption
|
||||||
|
key={curr}
|
||||||
|
label={curr}
|
||||||
|
value={curr}
|
||||||
|
selected={currency === curr}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setCurrency(v);
|
||||||
|
setShowCurrency(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showChannel}
|
||||||
|
onClose={() => setShowChannel(false)}
|
||||||
|
title="Invite Channel"
|
||||||
|
>
|
||||||
|
{CHANNELS.map((ch) => (
|
||||||
|
<SelectOption
|
||||||
|
key={ch}
|
||||||
|
label={ch}
|
||||||
|
value={ch}
|
||||||
|
selected={channel === ch}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setChannel(v);
|
||||||
|
setShowChannel(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showPaymentMethod}
|
||||||
|
onClose={() => setShowPaymentMethod(false)}
|
||||||
|
title="Payment Method"
|
||||||
|
>
|
||||||
|
{paymentMethods.map((pm: any) => (
|
||||||
|
<SelectOption
|
||||||
|
key={pm.id}
|
||||||
|
label={pm.label || pm.providerName || pm.bankName || "Method"}
|
||||||
|
value={pm.id}
|
||||||
|
selected={companyPaymentMethodId === pm.id}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setCompanyPaymentMethodId(v);
|
||||||
|
setShowPaymentMethod(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showIssueDate}
|
||||||
|
onClose={() => setShowIssueDate(false)}
|
||||||
|
title="Select Issue Date"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={issueDate}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setIssueDate(v);
|
||||||
|
setShowIssueDate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showDueDate}
|
||||||
|
onClose={() => setShowDueDate(false)}
|
||||||
|
title="Select Due Date"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={dueDate}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setDueDate(v);
|
||||||
|
setShowDueDate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
multiline,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-medium">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4"
|
||||||
|
numberOfLines={multiline ? undefined : 2}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
PermissionsAndroid,
|
PermissionsAndroid,
|
||||||
|
Switch,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
@ -21,8 +22,8 @@ import { EmptyState } from "@/components/EmptyState";
|
||||||
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
import { CalendarGrid } from "@/components/CalendarGrid";
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
import { FormFlow } from "@/components/FormFlow";
|
import { FormFlow } from "@/components/FormFlow";
|
||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
|
||||||
import { getPlaceholderColor } from "@/lib/colors";
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import { getScanData } from "@/lib/scan-cache";
|
||||||
|
|
||||||
let SmsAndroid: any = null;
|
let SmsAndroid: any = null;
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
|
|
@ -118,7 +119,7 @@ function PickerField({
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
className="h-10 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
>
|
>
|
||||||
<Text className="text-xs font-sans-bold" style={{ color: c.text }}>
|
<Text className="text-xs font-sans-bold" style={{ color: c.text }}>
|
||||||
|
|
@ -138,8 +139,24 @@ const PAYMENT_METHODS = [
|
||||||
"DECSI",
|
"DECSI",
|
||||||
"Bank Transfer",
|
"Bank Transfer",
|
||||||
"Cash",
|
"Cash",
|
||||||
|
"Credit Card",
|
||||||
"Other",
|
"Other",
|
||||||
];
|
];
|
||||||
|
const FINANCIAL_INSTITUTIONS = ["CBE", "ABYSSINIA", "TELE", "DASHEN"];
|
||||||
|
|
||||||
|
const PROVIDER_ALIASES: Record<string, string> = {
|
||||||
|
cbe: "CBE",
|
||||||
|
telebirr: "TELE",
|
||||||
|
tele: "TELE",
|
||||||
|
dashen: "DASHEN",
|
||||||
|
abyssinia: "ABYSSINIA",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeFinancialInstitution(input: string | undefined | null): string | null {
|
||||||
|
if (!input) return null;
|
||||||
|
const key = String(input).trim().toLowerCase();
|
||||||
|
return PROVIDER_ALIASES[key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseSmsMessage(body: string) {
|
function parseSmsMessage(body: string) {
|
||||||
const text = body.toUpperCase();
|
const text = body.toUpperCase();
|
||||||
|
|
@ -168,23 +185,25 @@ export default function CreatePaymentScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [scanRecordId, setScanRecordId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [transactionId, setTransactionId] = useState("");
|
const [transactionId, setTransactionId] = useState("");
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
const [currency, setCurrency] = useState("ETB");
|
const [currency, setCurrency] = useState("ETB");
|
||||||
const [paymentMethod, setPaymentMethod] = useState("Telebirr");
|
const [paymentMethod, setPaymentMethod] = useState("Telebirr");
|
||||||
|
const [financialInstitution, setFinancialInstitution] = useState("CBE");
|
||||||
|
const [isReferenceVerified, setIsReferenceVerified] = useState(false);
|
||||||
const [paymentDate, setPaymentDate] = useState(
|
const [paymentDate, setPaymentDate] = useState(
|
||||||
new Date().toISOString().split("T")[0],
|
new Date().toISOString().split("T")[0],
|
||||||
);
|
);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [selectedInvoice, setSelectedInvoice] = useState<any>(null);
|
const [selectedInvoice, setSelectedInvoice] = useState<any>(null);
|
||||||
|
const [customerId, setCustomerId] = useState("");
|
||||||
const [customerName, setCustomerName] = useState("");
|
|
||||||
const [customerEmail, setCustomerEmail] = useState("");
|
|
||||||
const [customerPhone, setCustomerPhone] = useState("");
|
|
||||||
|
|
||||||
const [showCurrency, setShowCurrency] = useState(false);
|
const [showCurrency, setShowCurrency] = useState(false);
|
||||||
const [showPaymentMethod, setShowPaymentMethod] = useState(false);
|
const [showPaymentMethod, setShowPaymentMethod] = useState(false);
|
||||||
|
const [showFinancialInstitution, setShowFinancialInstitution] =
|
||||||
|
useState(false);
|
||||||
const [showPaymentDate, setShowPaymentDate] = useState(false);
|
const [showPaymentDate, setShowPaymentDate] = useState(false);
|
||||||
const [showInvoicePicker, setShowInvoicePicker] = useState(false);
|
const [showInvoicePicker, setShowInvoicePicker] = useState(false);
|
||||||
|
|
||||||
|
|
@ -249,6 +268,46 @@ export default function CreatePaymentScreen() {
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const payload = getScanData();
|
||||||
|
if (!payload) return;
|
||||||
|
if (payload.type !== "payment" || !payload.id) return;
|
||||||
|
setScanRecordId(payload.id);
|
||||||
|
const scanData = payload.data || {};
|
||||||
|
if (scanData.transactionId)
|
||||||
|
setTransactionId(String(scanData.transactionId));
|
||||||
|
if (scanData.amount != null) setAmount(String(scanData.amount));
|
||||||
|
if (scanData.currency) setCurrency(scanData.currency);
|
||||||
|
if (scanData.paymentMethod) setPaymentMethod(scanData.paymentMethod);
|
||||||
|
if (scanData.provider) {
|
||||||
|
const normalized = normalizeFinancialInstitution(scanData.provider);
|
||||||
|
if (normalized) setFinancialInstitution(normalized);
|
||||||
|
}
|
||||||
|
if (scanData.paymentDate) {
|
||||||
|
try {
|
||||||
|
setPaymentDate(
|
||||||
|
new Date(scanData.paymentDate).toISOString().split("T")[0],
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
scanData.referenceNumber ||
|
||||||
|
scanData.merchantName ||
|
||||||
|
scanData.merchantId
|
||||||
|
) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (scanData.referenceNumber)
|
||||||
|
parts.push(`Ref: ${scanData.referenceNumber}`);
|
||||||
|
if (scanData.merchantName)
|
||||||
|
parts.push(`Merchant: ${scanData.merchantName}`);
|
||||||
|
if (scanData.merchantId)
|
||||||
|
parts.push(`Merchant ID: ${scanData.merchantId}`);
|
||||||
|
setNotes((prev) =>
|
||||||
|
prev ? `${prev}\n${parts.join(" · ")}` : parts.join(" · "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openInvoicePicker = async () => {
|
const openInvoicePicker = async () => {
|
||||||
setLoadingInvoices(true);
|
setLoadingInvoices(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -278,8 +337,8 @@ export default function CreatePaymentScreen() {
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ key: "details", label: "Payment Details" },
|
{ key: "details", label: "Payment Details" },
|
||||||
{ key: "invoice", label: "Invoice" },
|
{ key: "schedule", label: "Schedule" },
|
||||||
{ key: "info", label: "Customer Info" },
|
{ key: "method", label: "Method" },
|
||||||
{ key: "summary", label: "Summary" },
|
{ key: "summary", label: "Summary" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -321,15 +380,23 @@ export default function CreatePaymentScreen() {
|
||||||
currency,
|
currency,
|
||||||
paymentDate: new Date(paymentDate).toISOString(),
|
paymentDate: new Date(paymentDate).toISOString(),
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
notes,
|
financialInstitution,
|
||||||
customerName: customerName.trim() || undefined,
|
isReferenceVerified,
|
||||||
customerEmail: customerEmail.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
customerPhone: customerPhone.trim() ? `+251${customerPhone.trim()}` : undefined,
|
|
||||||
...(selectedInvoice?.id ? { invoiceId: selectedInvoice.id } : {}),
|
...(selectedInvoice?.id ? { invoiceId: selectedInvoice.id } : {}),
|
||||||
|
...(customerId ? { customerId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.payments.create({ body: payload });
|
if (scanRecordId) {
|
||||||
toast.success("Success", "Payment created successfully!");
|
await api.payments.update({
|
||||||
|
params: { id: scanRecordId },
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
toast.success("Success", "Payment updated successfully!");
|
||||||
|
} else {
|
||||||
|
await api.payments.create({ body: payload });
|
||||||
|
toast.success("Success", "Payment created successfully!");
|
||||||
|
}
|
||||||
nav.back();
|
nav.back();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[CreatePayment] Error:", error);
|
console.error("[CreatePayment] Error:", error);
|
||||||
|
|
@ -337,7 +404,9 @@ export default function CreatePaymentScreen() {
|
||||||
error?.response?.data?.message ||
|
error?.response?.data?.message ||
|
||||||
error?.data?.message ||
|
error?.data?.message ||
|
||||||
error?.message ||
|
error?.message ||
|
||||||
"Failed to create payment";
|
(scanRecordId
|
||||||
|
? "Failed to update payment"
|
||||||
|
: "Failed to create payment");
|
||||||
toast.error("Error", msg);
|
toast.error("Error", msg);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
@ -353,7 +422,7 @@ export default function CreatePaymentScreen() {
|
||||||
onBack={() => setStep(step - 1)}
|
onBack={() => setStep(step - 1)}
|
||||||
onComplete={handleSubmit}
|
onComplete={handleSubmit}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
completeLabel="Create Payment"
|
completeLabel={scanRecordId ? "Update Payment" : "Create Payment"}
|
||||||
>
|
>
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<View className="gap-5">
|
<View className="gap-5">
|
||||||
|
|
@ -382,11 +451,6 @@ export default function CreatePaymentScreen() {
|
||||||
onPress={() => setShowCurrency(true)}
|
onPress={() => setShowCurrency(true)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<PickerField
|
|
||||||
label="Payment Method"
|
|
||||||
value={paymentMethod}
|
|
||||||
onPress={() => setShowPaymentMethod(true)}
|
|
||||||
/>
|
|
||||||
<PickerField
|
<PickerField
|
||||||
label="Payment Date"
|
label="Payment Date"
|
||||||
value={paymentDate}
|
value={paymentDate}
|
||||||
|
|
@ -399,7 +463,7 @@ export default function CreatePaymentScreen() {
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<View className="gap-5">
|
<View className="gap-5">
|
||||||
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
Invoice
|
Link Invoice
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-card rounded-[6px] gap-4">
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
<View>
|
<View>
|
||||||
|
|
@ -446,49 +510,37 @@ export default function CreatePaymentScreen() {
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<View className="gap-5">
|
<View className="gap-5">
|
||||||
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
Customer Info
|
Method
|
||||||
</Text>
|
</Text>
|
||||||
<View className="gap-4">
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
<View>
|
<PickerField
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
label="Payment Method"
|
||||||
Customer Name
|
value={paymentMethod}
|
||||||
</Text>
|
onPress={() => setShowPaymentMethod(true)}
|
||||||
<CustomerPicker
|
/>
|
||||||
value={customerName}
|
<PickerField
|
||||||
onSelect={(c) => {
|
label="Financial Institution"
|
||||||
setCustomerName(c.name);
|
value={financialInstitution}
|
||||||
setCustomerEmail(c.email);
|
onPress={() => setShowFinancialInstitution(true)}
|
||||||
setCustomerPhone(c.phone.replace("+251", ""));
|
/>
|
||||||
}}
|
</View>
|
||||||
placeholder="Select or search for a customer"
|
|
||||||
/>
|
<View className="bg-card rounded-[6px]">
|
||||||
</View>
|
<View className="flex-row items-center justify-between p-1">
|
||||||
<View className="flex-row gap-4">
|
<View className="flex-1">
|
||||||
<Field
|
<Text className="text-[14px] font-sans-bold text-foreground">
|
||||||
label="Email"
|
Reference verified
|
||||||
value={customerEmail}
|
</Text>
|
||||||
onChangeText={setCustomerEmail}
|
<Text className="text-[12px] text-muted-foreground mt-0.5 font-sans-medium">
|
||||||
placeholder="billing@acme.com"
|
Transaction reference has been confirmed
|
||||||
flex={1}
|
</Text>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
|
||||||
Phone
|
|
||||||
</Text>
|
|
||||||
<View className="flex-row items-center h-11 px-3 border border-border rounded-[6px]" style={{ backgroundColor: c.bg, borderColor: c.border }}>
|
|
||||||
<Text className="text-foreground font-sans-bold text-xs">+251</Text>
|
|
||||||
<TextInput
|
|
||||||
className="flex-1 ml-2 text-foreground text-xs font-sans-medium"
|
|
||||||
placeholder="912345678"
|
|
||||||
placeholderTextColor={c.placeholder}
|
|
||||||
value={customerPhone}
|
|
||||||
onChangeText={setCustomerPhone}
|
|
||||||
keyboardType="phone-pad"
|
|
||||||
maxLength={9}
|
|
||||||
style={{ textAlignVertical: "center" }}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={isReferenceVerified}
|
||||||
|
onValueChange={setIsReferenceVerified}
|
||||||
|
trackColor={{ false: "#334155", true: "#ea580c" }}
|
||||||
|
thumbColor={isReferenceVerified ? "#fff" : "#64748b"}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -508,6 +560,18 @@ export default function CreatePaymentScreen() {
|
||||||
{transactionId}
|
{transactionId}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
Amount
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{currency}{" "}
|
||||||
|
{(parseFloat(amount) || 0).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<View className="flex-row justify-between">
|
<View className="flex-row justify-between">
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
Payment Method
|
Payment Method
|
||||||
|
|
@ -516,6 +580,22 @@ export default function CreatePaymentScreen() {
|
||||||
{paymentMethod}
|
{paymentMethod}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
Financial Institution
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{financialInstitution}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
Reference Verified
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{isReferenceVerified ? "Yes" : "No"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<View className="flex-row justify-between">
|
<View className="flex-row justify-between">
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
Payment Date
|
Payment Date
|
||||||
|
|
@ -524,6 +604,16 @@ export default function CreatePaymentScreen() {
|
||||||
{paymentDate}
|
{paymentDate}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{selectedInvoice?.customerName ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
Customer
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{selectedInvoice?.customerName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View className="flex-row justify-between">
|
<View className="flex-row justify-between">
|
||||||
<Text className="text-[14px] text-foreground font-sans-bold">
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
Linked Invoice
|
Linked Invoice
|
||||||
|
|
@ -603,6 +693,25 @@ export default function CreatePaymentScreen() {
|
||||||
))}
|
))}
|
||||||
</PickerModal>
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showFinancialInstitution}
|
||||||
|
onClose={() => setShowFinancialInstitution(false)}
|
||||||
|
title="Select Financial Institution"
|
||||||
|
>
|
||||||
|
{FINANCIAL_INSTITUTIONS.map((fi) => (
|
||||||
|
<SelectOption
|
||||||
|
key={fi}
|
||||||
|
label={fi}
|
||||||
|
value={fi}
|
||||||
|
selected={financialInstitution === fi}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setFinancialInstitution(v);
|
||||||
|
setShowFinancialInstitution(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
<PickerModal
|
<PickerModal
|
||||||
visible={showPaymentDate}
|
visible={showPaymentDate}
|
||||||
onClose={() => setShowPaymentDate(false)}
|
onClose={() => setShowPaymentDate(false)}
|
||||||
|
|
@ -645,6 +754,7 @@ export default function CreatePaymentScreen() {
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setSelectedInvoice(null);
|
setSelectedInvoice(null);
|
||||||
|
setCustomerId("");
|
||||||
setShowInvoicePicker(false);
|
setShowInvoicePicker(false);
|
||||||
}}
|
}}
|
||||||
className="px-4 py-3 border-b border-border/40 flex-row items-center"
|
className="px-4 py-3 border-b border-border/40 flex-row items-center"
|
||||||
|
|
@ -659,6 +769,7 @@ export default function CreatePaymentScreen() {
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setSelectedInvoice(inv);
|
setSelectedInvoice(inv);
|
||||||
|
setCustomerId(inv.customerId || "");
|
||||||
setShowInvoicePicker(false);
|
setShowInvoicePicker(false);
|
||||||
}}
|
}}
|
||||||
className="px-4 py-3 border-b border-border/40 flex-row items-center"
|
className="px-4 py-3 border-b border-border/40 flex-row items-center"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { api } from "@/lib/api";
|
||||||
import { usePinStore } from "@/lib/pin-store";
|
import { usePinStore } from "@/lib/pin-store";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import bcrypt from "react-native-bcrypt";
|
||||||
|
|
||||||
const LOCKOUT_THRESHOLD = 5;
|
const LOCKOUT_THRESHOLD = 5;
|
||||||
|
|
||||||
|
|
@ -34,7 +35,11 @@ export default function PinLockScreen() {
|
||||||
if (loading || value.length < 6) return;
|
if (loading || value.length < 6) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.auth.verifyPin({ query: { pin: value } });
|
const res = await api.auth.verifyPin({ query: { pin: value } });
|
||||||
|
const match = bcrypt.compareSync(value, res.pin);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("PIN mismatch");
|
||||||
|
}
|
||||||
unlock();
|
unlock();
|
||||||
nav.go("(tabs)");
|
nav.go("(tabs)");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -90,7 +95,6 @@ export default function PinLockScreen() {
|
||||||
<Text variant="h4" className="font-sans-bold text-foreground">
|
<Text variant="h4" className="font-sans-bold text-foreground">
|
||||||
{user?.firstName ?? "User"}
|
{user?.firstName ?? "User"}
|
||||||
</Text>
|
</Text>
|
||||||
r
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text variant="muted" className="text-center mb-8 px-4">
|
<Text variant="muted" className="text-center mb-8 px-4">
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import {
|
||||||
User,
|
User,
|
||||||
Lock,
|
Lock,
|
||||||
Globe,
|
Globe,
|
||||||
Building2,
|
|
||||||
Users,
|
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
@ -212,28 +210,6 @@ export default function ProfileScreen() {
|
||||||
/> */}
|
/> */}
|
||||||
</MenuGroup>
|
</MenuGroup>
|
||||||
|
|
||||||
<MenuGroup label="Company">
|
|
||||||
<MenuItem
|
|
||||||
icon={<Building2 color={iconColor} size={17} />}
|
|
||||||
label="Overview"
|
|
||||||
sublabel="View company details"
|
|
||||||
onPress={() => nav.go("company-details")}
|
|
||||||
/>
|
|
||||||
{/* <MenuItem
|
|
||||||
icon={<Building2 color={iconColor} size={17} />}
|
|
||||||
label="Edit Company Info"
|
|
||||||
sublabel="Update business details"
|
|
||||||
onPress={() => nav.go("company/edit")}
|
|
||||||
/> */}
|
|
||||||
<MenuItem
|
|
||||||
icon={<Users color={iconColor} size={17} />}
|
|
||||||
label="Workers"
|
|
||||||
sublabel="Manage team members"
|
|
||||||
onPress={() => nav.go("team/index")}
|
|
||||||
isLast
|
|
||||||
/>
|
|
||||||
</MenuGroup>
|
|
||||||
|
|
||||||
<MenuGroup label="Support & Legal">
|
<MenuGroup label="Support & Legal">
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<HelpCircle color={iconColor} size={17} />}
|
icon={<HelpCircle color={iconColor} size={17} />}
|
||||||
|
|
|
||||||
622
app/proforma-requests/[id].tsx
Normal file
622
app/proforma-requests/[id].tsx
Normal file
|
|
@ -0,0 +1,622 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
793
app/proforma-requests/create.tsx
Normal file
793
app/proforma-requests/create.tsx
Normal file
|
|
@ -0,0 +1,793 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { View, Pressable, TextInput, StyleSheet, Switch } from "react-native";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { ChevronDown, Plus, Trash2, X } from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
|
import { FormFlow } from "@/components/FormFlow";
|
||||||
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: number;
|
||||||
|
itemName: string;
|
||||||
|
itemDescription: string;
|
||||||
|
quantity: string;
|
||||||
|
unitOfMeasure: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
input: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
textAlignVertical: "center",
|
||||||
|
},
|
||||||
|
inputCenter: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
textAlignVertical: "center",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function useInputColors() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
placeholder: "rgba(100,116,139,0.45)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
numeric = false,
|
||||||
|
center = false,
|
||||||
|
flex,
|
||||||
|
multiline = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
numeric?: boolean;
|
||||||
|
center?: boolean;
|
||||||
|
flex?: number;
|
||||||
|
multiline?: boolean;
|
||||||
|
}) {
|
||||||
|
const c = useInputColors();
|
||||||
|
return (
|
||||||
|
<View style={flex != null ? { flex } : undefined}>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
center ? S.inputCenter : S.input,
|
||||||
|
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
|
||||||
|
multiline
|
||||||
|
? { height: 80, paddingTop: 10, textAlignVertical: "top" }
|
||||||
|
: {},
|
||||||
|
]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={c.placeholder}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType={numeric ? "numeric" : "default"}
|
||||||
|
multiline={multiline}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickerField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onPress,
|
||||||
|
flex,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onPress: () => void;
|
||||||
|
flex?: number;
|
||||||
|
}) {
|
||||||
|
const c = useInputColors();
|
||||||
|
return (
|
||||||
|
<View style={flex != null ? { flex } : undefined}>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className="h-10 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-sans-bold" style={{ color: c.text }}>
|
||||||
|
{value || "Select"}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES = ["EQUIPMENT", "SERVICE", "MIXED"];
|
||||||
|
const INCOTERMS = ["EXW", "FOB", "CIF", "DAP", "DDP"];
|
||||||
|
const INVITE_CHANNELS = ["EMAIL", "PHONE"];
|
||||||
|
const UNITS = ["unit", "kg", "m", "m²", "m³", "litre", "hour", "service"];
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: "details", label: "Details" },
|
||||||
|
{ key: "items", label: "Items" },
|
||||||
|
{ key: "terms", label: "Terms" },
|
||||||
|
{ key: "schedule", label: "Schedule" },
|
||||||
|
{ key: "summary", label: "Summary" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CreateProformaRequestScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Step 1 — Details
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [category, setCategory] = useState("EQUIPMENT");
|
||||||
|
|
||||||
|
// Step 2 — Items
|
||||||
|
const [items, setItems] = useState<Item[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
itemName: "",
|
||||||
|
itemDescription: "",
|
||||||
|
quantity: "1",
|
||||||
|
unitOfMeasure: "unit",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Step 3 — Terms
|
||||||
|
const [paymentTerms, setPaymentTerms] = useState("Net 30 days");
|
||||||
|
const [incoterms, setIncoterms] = useState("EXW");
|
||||||
|
const [taxIncluded, setTaxIncluded] = useState(false);
|
||||||
|
const [discountStructure, setDiscountStructure] = useState("");
|
||||||
|
const [validityPeriod, setValidityPeriod] = useState("30");
|
||||||
|
const [allowRevisions, setAllowRevisions] = useState(true);
|
||||||
|
|
||||||
|
// Step 4 — Schedule & Invite
|
||||||
|
const [submissionDeadline, setSubmissionDeadline] = useState("");
|
||||||
|
const [inviteChannel, setInviteChannel] = useState("EMAIL");
|
||||||
|
const [customerIds, setCustomerIds] = useState<string[]>([]);
|
||||||
|
const [selectedCustomers, setSelectedCustomers] = useState<
|
||||||
|
{ id: string; name: string; email: string; phone: string }[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// Modal visibility
|
||||||
|
const [showCategory, setShowCategory] = useState(false);
|
||||||
|
const [showIncoterms, setShowIncoterms] = useState(false);
|
||||||
|
const [showInviteChannel, setShowInviteChannel] = useState(false);
|
||||||
|
const [showDeadline, setShowDeadline] = useState(false);
|
||||||
|
|
||||||
|
const c = useInputColors();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 14);
|
||||||
|
setSubmissionDeadline(d.toISOString().split("T")[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateItem = (id: number, field: keyof Item, value: string) =>
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((it) => (it.id === id ? { ...it, [field]: value } : it)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addItem = () =>
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
itemName: "",
|
||||||
|
itemDescription: "",
|
||||||
|
quantity: "1",
|
||||||
|
unitOfMeasure: "unit",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeItem = (id: number) => {
|
||||||
|
if (items.length > 1) setItems((prev) => prev.filter((it) => it.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setItemUnit = (id: number, unit: string) =>
|
||||||
|
updateItem(id, "unitOfMeasure", unit);
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (step === 0 && !title.trim()) {
|
||||||
|
toast.error("Validation", "Title is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 1) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
toast.error("Validation", "Add at least one item");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.every((it) => !it.itemName.trim())) {
|
||||||
|
toast.error("Validation", "At least one item must have a name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStep((s) => s + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
toast.error("Validation", "Title is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!submissionDeadline) {
|
||||||
|
toast.error("Validation", "Submission deadline is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
category,
|
||||||
|
submissionDeadline: new Date(submissionDeadline).toISOString(),
|
||||||
|
allowRevisions,
|
||||||
|
paymentTerms: paymentTerms.trim() || undefined,
|
||||||
|
incoterms: incoterms || undefined,
|
||||||
|
taxIncluded,
|
||||||
|
discountStructure: discountStructure.trim() || undefined,
|
||||||
|
validityPeriod: parseInt(validityPeriod, 10) || 30,
|
||||||
|
items: items
|
||||||
|
.filter((it) => it.itemName.trim())
|
||||||
|
.map((it) => ({
|
||||||
|
itemName: it.itemName.trim(),
|
||||||
|
itemDescription: it.itemDescription.trim() || undefined,
|
||||||
|
quantity: parseInt(it.quantity, 10) || 1,
|
||||||
|
unitOfMeasure: it.unitOfMeasure || "unit",
|
||||||
|
})),
|
||||||
|
customerIds: customerIds.length > 0 ? customerIds : undefined,
|
||||||
|
inviteChannel,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await api.proformaRequests.create({
|
||||||
|
body: payload,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
toast.success("Success", "Proforma request created successfully!");
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[ProformaRequestCreate] Error:", err);
|
||||||
|
toast.error(
|
||||||
|
"Error",
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.message ||
|
||||||
|
"Failed to create request",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalQuantity = items.reduce(
|
||||||
|
(s, it) => s + (parseInt(it.quantity, 10) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const namedItemCount = items.filter((it) => it.itemName.trim()).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<FormFlow
|
||||||
|
steps={STEPS}
|
||||||
|
currentStep={step}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={() => setStep(step - 1)}
|
||||||
|
onComplete={handleSubmit}
|
||||||
|
loading={submitting}
|
||||||
|
completeLabel="Create Request"
|
||||||
|
>
|
||||||
|
{step === 0 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Request Details
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<Field
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
placeholder="e.g. Office Equipment Procurement 2024"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="Briefly describe what you're requesting"
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
<PickerField
|
||||||
|
label="Category"
|
||||||
|
value={category}
|
||||||
|
onPress={() => setShowCategory(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Requested Items
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={addItem}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1.5 rounded-[6px] bg-primary/10 border border-primary/20"
|
||||||
|
>
|
||||||
|
<Plus color="#ea580c" size={14} strokeWidth={2.5} />
|
||||||
|
<Text className="text-primary text-[10px] font-sans-bold">
|
||||||
|
Add
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View className="gap-3">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<ItemRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
canRemove={items.length > 1}
|
||||||
|
onUpdate={updateItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onPickUnit={(u) => setItemUnit(item.id, u)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Commercial Terms
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<Field
|
||||||
|
label="Payment Terms"
|
||||||
|
value={paymentTerms}
|
||||||
|
onChangeText={setPaymentTerms}
|
||||||
|
placeholder="e.g. Net 30 days"
|
||||||
|
/>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<PickerField
|
||||||
|
label="Incoterms"
|
||||||
|
value={incoterms}
|
||||||
|
onPress={() => setShowIncoterms(true)}
|
||||||
|
flex={2}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Validity (days)"
|
||||||
|
value={validityPeriod}
|
||||||
|
onChangeText={setValidityPeriod}
|
||||||
|
placeholder="30"
|
||||||
|
numeric
|
||||||
|
center
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Field
|
||||||
|
label="Discount Structure"
|
||||||
|
value={discountStructure}
|
||||||
|
onChangeText={setDiscountStructure}
|
||||||
|
placeholder="e.g. 5% discount for orders > 100 units"
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Options
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-5">
|
||||||
|
<ToggleRow
|
||||||
|
title="Tax Included"
|
||||||
|
subtitle="Prices quoted include applicable taxes"
|
||||||
|
value={taxIncluded}
|
||||||
|
onValueChange={setTaxIncluded}
|
||||||
|
/>
|
||||||
|
<View className="h-px bg-border" />
|
||||||
|
<ToggleRow
|
||||||
|
title="Allow Revisions"
|
||||||
|
subtitle="Suppliers may submit revised quotes"
|
||||||
|
value={allowRevisions}
|
||||||
|
onValueChange={setAllowRevisions}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Deadline & Invitation
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-4">
|
||||||
|
<PickerField
|
||||||
|
label="Submission Deadline"
|
||||||
|
value={submissionDeadline}
|
||||||
|
onPress={() => setShowDeadline(true)}
|
||||||
|
/>
|
||||||
|
<PickerField
|
||||||
|
label="Invite Channel"
|
||||||
|
value={inviteChannel}
|
||||||
|
onPress={() => setShowInviteChannel(true)}
|
||||||
|
/>
|
||||||
|
<View>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
Customers
|
||||||
|
</Text>
|
||||||
|
<CustomerPicker
|
||||||
|
selectedIds={customerIds}
|
||||||
|
selectedCustomers={selectedCustomers}
|
||||||
|
onSelect={(ids, customers) => {
|
||||||
|
setCustomerIds(ids);
|
||||||
|
setSelectedCustomers(customers);
|
||||||
|
}}
|
||||||
|
placeholder="Select customers to invite"
|
||||||
|
/>
|
||||||
|
{selectedCustomers.length > 0 && (
|
||||||
|
<View className="flex-row flex-wrap gap-2 mt-2">
|
||||||
|
{selectedCustomers.map((cust) => (
|
||||||
|
<View
|
||||||
|
key={cust.id}
|
||||||
|
className="bg-primary/10 rounded-[4px] pl-3 pr-2 py-1.5 flex-row items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Text className="text-primary text-[11px] font-sans-bold">
|
||||||
|
{cust.name}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setCustomerIds((ids) =>
|
||||||
|
ids.filter((id) => id !== cust.id),
|
||||||
|
);
|
||||||
|
setSelectedCustomers((prev) =>
|
||||||
|
prev.filter((c) => c.id !== cust.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
hitSlop={6}
|
||||||
|
>
|
||||||
|
<X size={12} color="#E46212" strokeWidth={3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<View className="gap-5 pb-4">
|
||||||
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
||||||
|
Summary
|
||||||
|
</Text>
|
||||||
|
<View className="bg-card rounded-[6px] gap-3">
|
||||||
|
<Row label="Title" value={title} />
|
||||||
|
{description ? (
|
||||||
|
<Row label="Description" value={description} multiline />
|
||||||
|
) : null}
|
||||||
|
<Row label="Category" value={category} />
|
||||||
|
<View className="border-t border-border/40 my-1" />
|
||||||
|
{namedItemCount > 0 ? (
|
||||||
|
<View>
|
||||||
|
<Text className="text-[12px] text-muted-foreground font-sans-bold uppercase tracking-widest mb-2">
|
||||||
|
Items ({namedItemCount})
|
||||||
|
</Text>
|
||||||
|
{items
|
||||||
|
.filter((it) => it.itemName.trim())
|
||||||
|
.map((it, i) => (
|
||||||
|
<View
|
||||||
|
key={it.id}
|
||||||
|
className="flex-row justify-between py-1"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-[13px] text-foreground font-sans-medium flex-1"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{it.itemName}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[13px] text-foreground font-sans-bold ml-3">
|
||||||
|
{it.quantity} {it.unitOfMeasure}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<View className="border-t border-border/40 my-1" />
|
||||||
|
<Row label="Payment Terms" value={paymentTerms || "—"} />
|
||||||
|
<Row label="Incoterms" value={incoterms} />
|
||||||
|
<Row label="Validity" value={`${validityPeriod || 30} days`} />
|
||||||
|
{discountStructure ? (
|
||||||
|
<Row label="Discount" value={discountStructure} multiline />
|
||||||
|
) : null}
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-muted-foreground font-sans-medium">
|
||||||
|
Tax Included
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{taxIncluded ? "Yes" : "No"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-muted-foreground font-sans-medium">
|
||||||
|
Allow Revisions
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] text-foreground font-sans-bold">
|
||||||
|
{allowRevisions ? "Yes" : "No"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="border-t border-border/40 my-1" />
|
||||||
|
<Row label="Deadline" value={submissionDeadline} />
|
||||||
|
<Row label="Invite Channel" value={inviteChannel} />
|
||||||
|
{selectedCustomers.length > 0 ? (
|
||||||
|
<View>
|
||||||
|
<Text className="text-[12px] text-muted-foreground font-sans-bold uppercase tracking-widest mb-2">
|
||||||
|
Customers ({selectedCustomers.length})
|
||||||
|
</Text>
|
||||||
|
{selectedCustomers.map((cust) => (
|
||||||
|
<Text
|
||||||
|
key={cust.id}
|
||||||
|
className="text-[13px] text-foreground font-sans-medium mb-1"
|
||||||
|
>
|
||||||
|
{cust.name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</FormFlow>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showCategory}
|
||||||
|
onClose={() => setShowCategory(false)}
|
||||||
|
title="Select Category"
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<SelectOption
|
||||||
|
key={cat}
|
||||||
|
label={cat}
|
||||||
|
value={cat}
|
||||||
|
selected={category === cat}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setCategory(v);
|
||||||
|
setShowCategory(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showIncoterms}
|
||||||
|
onClose={() => setShowIncoterms(false)}
|
||||||
|
title="Select Incoterms"
|
||||||
|
>
|
||||||
|
{INCOTERMS.map((t) => (
|
||||||
|
<SelectOption
|
||||||
|
key={t}
|
||||||
|
label={t}
|
||||||
|
value={t}
|
||||||
|
selected={incoterms === t}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setIncoterms(v);
|
||||||
|
setShowIncoterms(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showInviteChannel}
|
||||||
|
onClose={() => setShowInviteChannel(false)}
|
||||||
|
title="Invite Channel"
|
||||||
|
>
|
||||||
|
{INVITE_CHANNELS.map((ch) => (
|
||||||
|
<SelectOption
|
||||||
|
key={ch}
|
||||||
|
label={ch}
|
||||||
|
value={ch}
|
||||||
|
selected={inviteChannel === ch}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setInviteChannel(v);
|
||||||
|
setShowInviteChannel(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showDeadline}
|
||||||
|
onClose={() => setShowDeadline(false)}
|
||||||
|
title="Submission Deadline"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={submissionDeadline}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setSubmissionDeadline(v);
|
||||||
|
setShowDeadline(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center justify-between p-1">
|
||||||
|
<View className="flex-1 pr-4">
|
||||||
|
<Text className="text-[14px] font-sans-bold text-foreground">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[12px] text-muted-foreground mt-0.5 font-sans-medium">
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
trackColor={{ false: "#334155", true: "#ea580c" }}
|
||||||
|
thumbColor={value ? "#fff" : "#64748b"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemRow({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
canRemove,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
onPickUnit,
|
||||||
|
}: {
|
||||||
|
item: Item;
|
||||||
|
index: number;
|
||||||
|
canRemove: boolean;
|
||||||
|
onUpdate: (id: number, field: keyof Item, value: string) => void;
|
||||||
|
onRemove: (id: number) => void;
|
||||||
|
onPickUnit: (u: string) => void;
|
||||||
|
}) {
|
||||||
|
const [showUnits, setShowUnits] = useState(false);
|
||||||
|
const c = useInputColors();
|
||||||
|
return (
|
||||||
|
<View className="bg-card pb-4">
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<Text className="text-[16px] font-sans-bold text-foreground">
|
||||||
|
Item {index + 1}
|
||||||
|
</Text>
|
||||||
|
{canRemove && (
|
||||||
|
<Pressable onPress={() => onRemove(item.id)} hitSlop={8}>
|
||||||
|
<Trash2 color="#ef4444" size={16} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Field
|
||||||
|
label="Item Name"
|
||||||
|
placeholder="e.g. Laptop Computer"
|
||||||
|
value={item.itemName}
|
||||||
|
onChangeText={(v) => onUpdate(item.id, "itemName", v)}
|
||||||
|
/>
|
||||||
|
<View className="mt-4">
|
||||||
|
<Field
|
||||||
|
label="Item Description"
|
||||||
|
placeholder="Optional details or specs"
|
||||||
|
value={item.itemDescription}
|
||||||
|
onChangeText={(v) => onUpdate(item.id, "itemDescription", v)}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-3 mt-4">
|
||||||
|
<Field
|
||||||
|
label="Qty"
|
||||||
|
placeholder="1"
|
||||||
|
numeric
|
||||||
|
center
|
||||||
|
value={item.quantity}
|
||||||
|
onChangeText={(v) => onUpdate(item.id, "quantity", v)}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
Unit
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowUnits(true)}
|
||||||
|
className="h-10 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-sans-bold" style={{ color: c.text }}>
|
||||||
|
{item.unitOfMeasure || "unit"}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<PickerModal
|
||||||
|
visible={showUnits}
|
||||||
|
onClose={() => setShowUnits(false)}
|
||||||
|
title="Unit of Measure"
|
||||||
|
>
|
||||||
|
{UNITS.map((u) => (
|
||||||
|
<SelectOption
|
||||||
|
key={u}
|
||||||
|
label={u}
|
||||||
|
value={u}
|
||||||
|
selected={item.unitOfMeasure === u}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onPickUnit(v);
|
||||||
|
setShowUnits(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
multiline,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[14px] text-muted-foreground font-sans-medium">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4"
|
||||||
|
numberOfLines={multiline ? undefined : 2}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
602
app/proforma.tsx
602
app/proforma.tsx
|
|
@ -1,24 +1,36 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
|
ScrollView,
|
||||||
Pressable,
|
Pressable,
|
||||||
ActivityIndicator,
|
|
||||||
FlatList,
|
|
||||||
ListRenderItem,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { useFocusEffect } from "expo-router";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Plus, FileText, Search } from "@/lib/icons";
|
import {
|
||||||
|
Plus,
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
ChevronRight,
|
||||||
|
Inbox,
|
||||||
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
|
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 {
|
interface ProformaItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -41,170 +53,141 @@ interface ProformaItem {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dummyData: ProformaItem = {
|
interface ProformaRequest {
|
||||||
id: "dummy-1",
|
id: string;
|
||||||
proformaNumber: "PF-001",
|
title: string;
|
||||||
customerName: "John Doe",
|
description: string;
|
||||||
customerEmail: "john@example.com",
|
category: "EQUIPMENT" | "SERVICE" | "MIXED";
|
||||||
customerPhone: "+1234567890",
|
status:
|
||||||
amount: { value: 1000, currency: "USD" },
|
| "DRAFT"
|
||||||
currency: "USD",
|
| "OPEN"
|
||||||
issueDate: "2026-03-10T11:51:36.134Z",
|
| "UNDER_REVIEW"
|
||||||
dueDate: "2026-03-10T11:51:36.134Z",
|
| "REVISION_REQUESTED"
|
||||||
description: "Dummy proforma",
|
| "CLOSED"
|
||||||
notes: "Test notes",
|
| "CANCELLED";
|
||||||
taxAmount: { value: 100, currency: "USD" },
|
submissionDeadline: string;
|
||||||
discountAmount: { value: 50, currency: "USD" },
|
items: { id: string; itemName: string; quantity: number; unitOfMeasure: string }[];
|
||||||
pdfPath: "dummy.pdf",
|
createdAt: string;
|
||||||
userId: "user-1",
|
updatedAt: string;
|
||||||
items: [
|
}
|
||||||
{
|
|
||||||
id: "item-1",
|
const REQUEST_STATUS_COLORS: Record<string, string> = {
|
||||||
description: "Test item",
|
DRAFT: "#6b7280",
|
||||||
quantity: 1,
|
OPEN: "#E46212",
|
||||||
unitPrice: { value: 1000, currency: "USD" },
|
UNDER_REVIEW: "#2563eb",
|
||||||
total: { value: 1000, currency: "USD" },
|
REVISION_REQUESTED: "#dc2626",
|
||||||
},
|
CLOSED: "#16a34a",
|
||||||
],
|
CANCELLED: "#6b7280",
|
||||||
createdAt: "2026-03-10T11:51:36.134Z",
|
};
|
||||||
updatedAt: "2026-03-10T11:51:36.134Z",
|
|
||||||
|
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() {
|
export default function ProformaScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const permissions = useAuthStore((s) => s.permissions);
|
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 [proformas, setProformas] = useState<ProformaItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
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(
|
const canCreateProformas = hasPermission(
|
||||||
permissions,
|
permissions,
|
||||||
PERMISSION_MAP["proforma:create"],
|
PERMISSION_MAP["proforma:create"],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchProformas = useCallback(
|
const fetchProformas = useCallback(async (pageNum: number) => {
|
||||||
async (pageNum: number, isRefresh = false) => {
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
const { isAuthenticated } = useAuthStore.getState();
|
if (!isAuthenticated) return;
|
||||||
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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
try {
|
const fetchRequests = useCallback(async (pageNum: number) => {
|
||||||
if (!isRefresh) {
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
|
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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const response = await api.proforma.getAll({
|
useFocusEffect(
|
||||||
query: { page: pageNum, limit: 10 },
|
useCallback(() => {
|
||||||
});
|
if (tab === "proforma") fetchProformas(1);
|
||||||
|
else fetchRequests(1);
|
||||||
let newProformas = response.data;
|
}, [tab, fetchProformas, fetchRequests]),
|
||||||
|
|
||||||
const newData = newProformas;
|
|
||||||
if (isRefresh) {
|
|
||||||
setProformas(newData);
|
|
||||||
} else {
|
|
||||||
setProformas((prev) =>
|
|
||||||
pageNum === 1 ? newData : [...prev, ...newData],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setHasMore(response.meta.hasNextPage);
|
|
||||||
setPage(pageNum);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("[Proforma] Fetch error:", err);
|
|
||||||
setHasMore(false);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchProformas(1);
|
|
||||||
}, [fetchProformas]);
|
|
||||||
|
|
||||||
const onRefresh = () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
fetchProformas(1, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
if (hasMore && !loadingMore && !loading) {
|
if (tab === "proforma" && hasMore && !loadingMore && !loading) {
|
||||||
fetchProformas(page + 1);
|
fetchProformas(page + 1);
|
||||||
}
|
}
|
||||||
};
|
if (
|
||||||
|
tab === "request" &&
|
||||||
const renderProformaItem: ListRenderItem<ProformaItem> = ({ item }) => {
|
reqHasMore &&
|
||||||
const amountVal =
|
!reqLoadingMore &&
|
||||||
typeof item.amount === "object" ? item.amount.value : item.amount;
|
!requestsLoading
|
||||||
const issuedStr = item.issueDate
|
) {
|
||||||
? new Date(item.issueDate).toLocaleDateString()
|
fetchRequests(reqPage + 1);
|
||||||
: "";
|
}
|
||||||
const dueStr = item.dueDate
|
|
||||||
? new Date(item.dueDate).toLocaleDateString()
|
|
||||||
: "";
|
|
||||||
const itemsCount = Array.isArray(item.items) ? item.items.length : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="px-[16px]">
|
|
||||||
<Pressable
|
|
||||||
onPress={() => nav.go("proforma/[id]", { id: item.id })}
|
|
||||||
className="mb-3"
|
|
||||||
>
|
|
||||||
<Card className="rounded-[12px] bg-card overflow-hidden border border-border/40">
|
|
||||||
<View className="p-4">
|
|
||||||
<View className="flex-row items-start">
|
|
||||||
<View className="bg-primary/10 p-2.5 rounded-[12px] border border-primary/10 mr-3">
|
|
||||||
<FileText color="#ea580c" size={18} strokeWidth={2.5} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-1">
|
|
||||||
<View className="flex-row justify-between">
|
|
||||||
<View className="flex-1 pr-2">
|
|
||||||
<Text
|
|
||||||
className="text-foreground font-sans-semibold"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{item.proformaNumber || "Proforma"}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
variant="muted"
|
|
||||||
className="text-xs mt-0.5"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{item.customerName || "Customer"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="items-end">
|
|
||||||
<Text className="text-foreground font-sans-bold text-base">
|
|
||||||
{item.currency || "$"}
|
|
||||||
{amountVal?.toLocaleString?.() ?? amountVal ?? "0"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="mt-2 flex-row items-center justify-between">
|
|
||||||
<Text
|
|
||||||
variant="muted"
|
|
||||||
className="text-[10px] font-sans-medium"
|
|
||||||
>
|
|
||||||
Issued: {issuedStr} | Due: {dueStr} | {itemsCount} item
|
|
||||||
{itemsCount !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredProformas = useMemo(() => {
|
const filteredProformas = useMemo(() => {
|
||||||
|
|
@ -226,63 +209,288 @@ export default function ProformaScreen() {
|
||||||
});
|
});
|
||||||
}, [proformas, search]);
|
}, [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 (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<FlatList
|
<ScrollView
|
||||||
data={filteredProformas}
|
className="flex-1"
|
||||||
renderItem={renderProformaItem}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
contentContainerStyle={{ paddingBottom: 150 }}
|
contentContainerStyle={{ paddingBottom: 150 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onRefresh={onRefresh}
|
onScroll={({ nativeEvent }) => {
|
||||||
refreshing={refreshing}
|
const isCloseToBottom =
|
||||||
onEndReached={loadMore}
|
nativeEvent.layoutMeasurement.height +
|
||||||
onEndReachedThreshold={0.5}
|
nativeEvent.contentOffset.y >=
|
||||||
ListHeaderComponent={
|
nativeEvent.contentSize.height - 20;
|
||||||
<>
|
if (isCloseToBottom) loadMore();
|
||||||
<StandardHeader showBack title="Proforma" />
|
}}
|
||||||
<View className="px-[16px] pt-6">
|
scrollEventThrottle={400}
|
||||||
<View className="flex-row items-center bg-card border border-border rounded-xl px-3 h-11 mb-4">
|
>
|
||||||
<Search size={16} color="#94a3b8" strokeWidth={2} />
|
<StandardHeader
|
||||||
<TextInput
|
title="Proforma"
|
||||||
className="flex-1 ml-2 text-foreground text-sm"
|
showBack
|
||||||
placeholder="Search by name, number, or amount..."
|
showSearch
|
||||||
placeholderTextColor="#94a3b8"
|
onSearchPress={() => setSearchOpen(true)}
|
||||||
value={search}
|
/>
|
||||||
onChangeText={setSearch}
|
<View className="px-[16px] pt-6">
|
||||||
autoCapitalize="none"
|
<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} />
|
||||||
</View>
|
<TextInput
|
||||||
<Button
|
className="flex-1 ml-2 text-foreground text-sm"
|
||||||
className="mb-4 h-10 rounded-[10px] bg-primary"
|
placeholder={
|
||||||
onPress={() => nav.go("proforma/create")}
|
tab === "proforma"
|
||||||
>
|
? "Search by name, number, or amount..."
|
||||||
<Plus color="white" size={20} strokeWidth={3} />
|
: "Search by title, description, status..."
|
||||||
<Text className="text-white text-xs font-sans-semibold uppercase tracking-widest ml-2">
|
}
|
||||||
Create New Proforma
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
</Text>
|
value={search}
|
||||||
</Button>
|
onChangeText={setSearch}
|
||||||
</View>
|
autoCapitalize="none"
|
||||||
</>
|
|
||||||
}
|
|
||||||
ListFooterComponent={
|
|
||||||
loadingMore ? (
|
|
||||||
<ActivityIndicator color="#ea580c" className="py-4" />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
ListEmptyComponent={
|
|
||||||
!loading ? (
|
|
||||||
<EmptyState
|
|
||||||
title="No proformas yet"
|
|
||||||
description="Create your first proforma to get started with invoicing."
|
|
||||||
centered
|
|
||||||
/>
|
/>
|
||||||
) : (
|
</View>
|
||||||
<View className="py-20">
|
|
||||||
<ActivityIndicator size="large" color="#ea580c" />
|
<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>
|
||||||
)
|
)}
|
||||||
}
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
visible={searchOpen}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
/>
|
/>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,21 @@ import {
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
|
||||||
Linking,
|
Linking,
|
||||||
Pressable,
|
Pressable,
|
||||||
Modal,
|
Modal,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
StyleSheet,
|
||||||
|
Image,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router";
|
import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
Calendar,
|
||||||
Download,
|
|
||||||
Trash2,
|
|
||||||
Package,
|
|
||||||
Clock,
|
Clock,
|
||||||
User,
|
User,
|
||||||
Hash,
|
Hash,
|
||||||
|
|
@ -26,9 +25,15 @@ import {
|
||||||
Edit,
|
Edit,
|
||||||
Mail,
|
Mail,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Globe,
|
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
X,
|
X,
|
||||||
|
Package,
|
||||||
|
Share2,
|
||||||
|
Download,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Check,
|
||||||
|
Trash2,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
|
@ -36,6 +41,9 @@ import { api, BASE_URL } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { ActionModal } from "@/components/ActionModal";
|
||||||
|
import { SendHorizonal } from "lucide-react-native";
|
||||||
|
import ticketImage from "@/assets/ticket.png";
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
|
@ -49,12 +57,40 @@ function fmt(v: number, currency = "ETB") {
|
||||||
return `${currency} ${v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return `${currency} ${v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_THEME: Record<string, { label: string; bg: string; text: string; dot: string }> = {
|
const STATUS_THEME: Record<
|
||||||
PAID: { label: "Paid", bg: "bg-emerald-500/10", text: "text-emerald-600", dot: "bg-emerald-500" },
|
string,
|
||||||
PENDING: { label: "Pending", bg: "bg-amber-500/10", text: "text-amber-600", dot: "bg-amber-500" },
|
{ label: string; bg: string; text: string; dot: string }
|
||||||
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" },
|
PAID: {
|
||||||
DEFAULT: { label: "Unknown", bg: "bg-slate-500/10", text: "text-slate-500", dot: "bg-slate-500" },
|
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() {
|
export default function ProformaDetailScreen() {
|
||||||
|
|
@ -65,9 +101,18 @@ export default function ProformaDetailScreen() {
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [proforma, setProforma] = useState<any>(null);
|
const [proforma, setProforma] = useState<any>(null);
|
||||||
const [showActions, setShowActions] = useState(false);
|
const [activeTab, setActiveTab] = useState<"details" | "items">("details");
|
||||||
|
const [showMoreSheet, setShowMoreSheet] = useState(false);
|
||||||
|
const [showSendSheet, setShowSendSheet] = useState(false);
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
useFocusEffect(useCallback(() => { fetchProforma(); }, [id]));
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchProforma();
|
||||||
|
}, [id]),
|
||||||
|
);
|
||||||
|
|
||||||
const fetchProforma = async () => {
|
const fetchProforma = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -93,26 +138,38 @@ export default function ProformaDetailScreen() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleShare = async (channel: "email" | "sms") => {
|
||||||
Alert.alert("Delete Proforma", "This cannot be undone.", [
|
try {
|
||||||
{ text: "Cancel", style: "cancel" },
|
setSharing(true);
|
||||||
{
|
const pid = Array.isArray(id) ? id[0] : id;
|
||||||
text: "Delete",
|
await api.proforma.shareLink({ body: { proformaId: pid, channel } });
|
||||||
style: "destructive",
|
toast.success(
|
||||||
onPress: async () => {
|
"Sent",
|
||||||
try {
|
`Proforma shared via ${channel === "email" ? "email" : "SMS"}`,
|
||||||
setLoading(true);
|
);
|
||||||
const pid = Array.isArray(id) ? id[0] : id;
|
setShowSendSheet(false);
|
||||||
await api.proforma.delete({ params: { id: pid } });
|
} catch (err: any) {
|
||||||
toast.success("Success", "Proforma deleted");
|
toast.error("Error", err?.message || "Failed to share proforma");
|
||||||
nav.back();
|
} finally {
|
||||||
} catch {
|
setSharing(false);
|
||||||
toast.error("Error", "Failed to delete proforma");
|
}
|
||||||
setLoading(false);
|
};
|
||||||
}
|
|
||||||
},
|
const handleDelete = () => setShowDeleteModal(true);
|
||||||
},
|
|
||||||
]);
|
const confirmDelete = async () => {
|
||||||
|
try {
|
||||||
|
setDeleting(true);
|
||||||
|
const pid = Array.isArray(id) ? id[0] : id;
|
||||||
|
await api.proforma.delete({ params: { id: pid } });
|
||||||
|
toast.success("Deleted", "Proforma has been removed.");
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("Error", err?.message || "Failed to delete proforma");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -152,249 +209,438 @@ export default function ProformaDetailScreen() {
|
||||||
const statusKey = (proforma.status || "DRAFT").toUpperCase();
|
const statusKey = (proforma.status || "DRAFT").toUpperCase();
|
||||||
const theme = STATUS_THEME[statusKey] || STATUS_THEME.DEFAULT;
|
const theme = STATUS_THEME[statusKey] || STATUS_THEME.DEFAULT;
|
||||||
|
|
||||||
|
const issueDate = proforma.issueDate ? new Date(proforma.issueDate) : null;
|
||||||
|
const dueDate = proforma.dueDate ? new Date(proforma.dueDate) : null;
|
||||||
|
|
||||||
|
const formatLongDate = (d: Date) =>
|
||||||
|
d.toLocaleDateString("en-US", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerName = (
|
||||||
|
proforma.customerName?.replace("Customer Name: ", "") || "Walking Client"
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const ActionOption = ({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
onPress,
|
||||||
|
destructive,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
}) => (
|
||||||
|
<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={`text-[14px] font-sans-bold ${
|
||||||
|
destructive ? "text-red-500" : "text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<Stack.Screen options={{ headerShown: false }} />
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
<StandardHeader
|
<StandardHeader
|
||||||
title="Proforma"
|
title="Proforma Details"
|
||||||
showBack
|
showBack
|
||||||
rightAction="edit"
|
right={
|
||||||
onRightActionPress={() => nav.go("proforma/edit", { id: proforma.id })}
|
<Pressable
|
||||||
|
onPress={() => setShowMoreSheet(true)}
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
>
|
||||||
|
<MoreVertical color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
contentContainerStyle={{ paddingBottom: 120 }}
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Hero — Amount + Status */}
|
{/* Hero Card — illustration overflows the top */}
|
||||||
<View className="px-5 pt-6 mb-6">
|
<View className="px-5 pt-3">
|
||||||
<View className="flex-row items-start justify-between mb-1">
|
<View
|
||||||
<View className="flex-1 mr-4">
|
className="items-center"
|
||||||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-1.5">
|
style={{ marginBottom: -60, zIndex: 2 }}
|
||||||
Total Amount
|
>
|
||||||
|
<Image
|
||||||
|
source={ticketImage}
|
||||||
|
style={{ width: 150, height: 150 }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="rounded-[14px] pt-14 pb-6 px-6 items-center bg-primary/5"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: isDark
|
||||||
|
? "rgba(255,255,255,0.06)"
|
||||||
|
: "rgba(0,0,0,0.05)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-foreground text-[34px] font-sans-black tracking-tight leading-tight">
|
||||||
|
{amount.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}{" "}
|
||||||
|
<Text className="text-foreground text-[20px] font-sans-bold">
|
||||||
|
{currency}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-baseline gap-1.5">
|
</Text>
|
||||||
<Text className="text-3xl font-sans-black text-foreground tracking-tight">
|
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-1">
|
||||||
{amount.toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
{proforma.proformaNumber
|
||||||
</Text>
|
? `Proforma ${proforma.proformaNumber}`
|
||||||
<Text className="text-base font-sans-bold text-primary">
|
: `Proforma #${(proforma.id || "").slice(0, 8).toUpperCase()}`}
|
||||||
{currency}
|
</Text>
|
||||||
</Text>
|
|
||||||
</View>
|
{/* Status badge */}
|
||||||
</View>
|
<View className={`px-2.5 py-1 rounded-[4px] mt-4 ${theme.bg}`}>
|
||||||
<View className={`px-2.5 py-1 rounded-[4px] ${theme.bg}`}>
|
|
||||||
<View className="flex-row items-center gap-1.5">
|
<View className="flex-row items-center gap-1.5">
|
||||||
<View className={`w-1.5 h-1.5 rounded-full ${theme.dot}`} />
|
<View className={`w-1.5 h-1.5 rounded-full ${theme.dot}`} />
|
||||||
<Text className={`text-[9px] font-sans-bold uppercase tracking-widest ${theme.text}`}>
|
<Text
|
||||||
|
className={`text-[9px] font-sans-bold uppercase tracking-widest ${theme.text}`}
|
||||||
|
>
|
||||||
{theme.label}
|
{theme.label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Period Dates */}
|
<View className="w-full mt-5 gap-3">
|
||||||
<View className="px-5 mb-6">
|
<View className="flex-row justify-between items-center">
|
||||||
<View className="bg-card rounded-[6px] border border-border p-4">
|
<Text className="text-muted-foreground text-[12px] font-sans-medium">
|
||||||
<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
|
Issued
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center gap-1.5">
|
<Text className="text-foreground text-[12px] font-sans-bold">
|
||||||
<Calendar size={13} color="#94a3b8" strokeWidth={2} />
|
{issueDate ? formatLongDate(issueDate) : "—"}
|
||||||
<Text className="text-foreground font-sans-bold text-sm">
|
</Text>
|
||||||
{proforma.issueDate ? new Date(proforma.issueDate).toLocaleDateString() : "—"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<View className="w-px bg-border" />
|
<View className="flex-row justify-between items-center">
|
||||||
<View className="flex-1">
|
<Text className="text-muted-foreground text-[12px] font-sans-medium">
|
||||||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-1.5">
|
|
||||||
Due
|
Due
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center gap-1.5">
|
<Text className="text-foreground text-[12px] font-sans-bold">
|
||||||
<Clock size={13} color="#94a3b8" strokeWidth={2} />
|
{dueDate ? formatLongDate(dueDate) : "—"}
|
||||||
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
</View>
|
||||||
|
|
||||||
{/* Items */}
|
{/* Tabs */}
|
||||||
{items.length > 0 && (
|
<View className="px-5 pt-12">
|
||||||
<View className="px-5 mb-6">
|
<View className="flex-row gap-6 border-b border-border">
|
||||||
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2">
|
<Pressable
|
||||||
Items ({items.length})
|
onPress={() => setActiveTab("details")}
|
||||||
</Text>
|
className="pb-2.5"
|
||||||
<View className="bg-card rounded-[6px] border border-border overflow-hidden">
|
>
|
||||||
{items.map((item: any, idx: number) => (
|
<Text
|
||||||
<View
|
className={`text-[14px] font-sans-bold ${
|
||||||
key={item.id || idx}
|
activeTab === "details"
|
||||||
className={`px-4 py-3 ${idx < items.length - 1 ? "border-b border-border" : ""}`}
|
? "text-foreground"
|
||||||
>
|
: "text-muted-foreground"
|
||||||
<View className="flex-row justify-between items-start mb-0.5">
|
}`}
|
||||||
<Text
|
>
|
||||||
className="text-foreground font-sans-bold text-sm flex-1 mr-3"
|
Details
|
||||||
numberOfLines={2}
|
</Text>
|
||||||
>
|
{activeTab === "details" && (
|
||||||
{item.description || `Item ${idx + 1}`}
|
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => setActiveTab("items")} className="pb-2.5">
|
||||||
|
<Text
|
||||||
|
className={`text-[14px] font-sans-bold ${
|
||||||
|
activeTab === "items"
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Items
|
||||||
|
</Text>
|
||||||
|
{activeTab === "items" && (
|
||||||
|
<View className="absolute -bottom-px left-0 right-0 h-0.5 bg-foreground" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{activeTab === "details" ? (
|
||||||
|
<View className="px-5 pt-5 gap-6">
|
||||||
|
{/* Customer */}
|
||||||
|
{proforma.customerName && (
|
||||||
|
<View>
|
||||||
|
<Text className="font-sans-bold text-xs uppercase tracking-widest text-muted-foreground mb-3">
|
||||||
|
Customer
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<View className="h-10 w-10 rounded-full bg-primary/10 items-center justify-center">
|
||||||
|
<User color="#E46212" size={18} strokeWidth={2} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-sans-bold text-sm">
|
||||||
|
{customerName}
|
||||||
|
</Text>
|
||||||
|
{(proforma.customerEmail || proforma.customerPhone) && (
|
||||||
|
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
|
||||||
|
{proforma.customerEmail || proforma.customerPhone}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Proforma Details */}
|
||||||
|
<View>
|
||||||
|
<Text className="font-sans-bold text-xs uppercase tracking-widest text-muted-foreground mb-3">
|
||||||
|
Proforma Details
|
||||||
|
</Text>
|
||||||
|
<View className="gap-4">
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Hash size={15} color="#64748b" />
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
|
||||||
|
Proforma Number
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-foreground font-sans-bold text-sm">
|
<Text className="text-foreground font-sans-bold text-sm">
|
||||||
{fmt(safeVal(item.total || item.unitPrice) * safeVal(item.quantity || 1), currency)}
|
{proforma.proformaNumber ||
|
||||||
|
`PRF${(proforma.id || "").slice(0, 8).toUpperCase()}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-muted-foreground text-[11px] font-sans-medium">
|
</View>
|
||||||
{safeVal(item.quantity)} × {fmt(safeVal(item.unitPrice), currency)}
|
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Calendar size={15} color="#64748b" />
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
|
||||||
|
Issue Date
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground font-sans-bold text-sm">
|
||||||
|
{issueDate ? issueDate.toLocaleString() : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Clock size={15} color="#64748b" />
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
|
||||||
|
Due Date
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground font-sans-bold text-sm">
|
||||||
|
{dueDate ? dueDate.toLocaleString() : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{proforma.description && (
|
||||||
|
<View className="flex-row items-start gap-3">
|
||||||
|
<FileText
|
||||||
|
size={15}
|
||||||
|
color="#64748b"
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
|
||||||
|
Description
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground font-sans-medium text-sm leading-5">
|
||||||
|
{proforma.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Note (border only, no bg) */}
|
||||||
|
{proforma.notes && (
|
||||||
|
<View>
|
||||||
|
<Text className="text-foreground text-sm font-sans-bold mb-2">
|
||||||
|
Note
|
||||||
|
</Text>
|
||||||
|
<View className="rounded-[10px] border border-border p-4">
|
||||||
|
<Text className="text-foreground font-sans-medium text-[13px] leading-5">
|
||||||
|
{proforma.notes}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
)}
|
)}
|
||||||
{discount > 0 && (
|
</View>
|
||||||
<View className="flex-row justify-between">
|
) : (
|
||||||
<Text className="text-muted-foreground text-sm font-sans-medium">Discount</Text>
|
<View className="px-5 pt-5">
|
||||||
<Text className="text-foreground text-sm font-sans-bold">-{fmt(discount, currency)}</Text>
|
{items.length > 0 ? (
|
||||||
|
<View>
|
||||||
|
<Text className="text-foreground text-[16px] font-sans-black tracking-tight mb-3">
|
||||||
|
Items
|
||||||
|
</Text>
|
||||||
|
<View>
|
||||||
|
{items.map((item: any, idx: number) => {
|
||||||
|
const qty = safeVal(item.quantity || 1);
|
||||||
|
const unitPrice = safeVal(item.unitPrice);
|
||||||
|
const lineTotal = safeVal(item.total || qty * unitPrice);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.id || idx}
|
||||||
|
className={`flex-row items-center gap-3 py-3 ${
|
||||||
|
idx < items.length - 1 ? "border-b border-border" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="h-12 w-12 rounded-[8px] bg-muted items-center justify-center overflow-hidden">
|
||||||
|
<Package
|
||||||
|
size={20}
|
||||||
|
color="#94a3b8"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
className="text-foreground text-[14px] font-sans-bold"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.description || "No item"}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
|
||||||
|
{qty} ×{" "}
|
||||||
|
{unitPrice.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}{" "}
|
||||||
|
{currency}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-foreground text-[14px] font-sans-bold">
|
||||||
|
{lineTotal.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}{" "}
|
||||||
|
{currency}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<View className="mt-6 gap-2.5">
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text className="text-muted-foreground text-[14px] font-sans-medium">
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground text-[14px] font-sans-bold">
|
||||||
|
{fmt(subtotal, currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{tax > 0 && (
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text className="text-muted-foreground text-[14px] font-sans-medium">
|
||||||
|
Tax
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground text-[14px] font-sans-bold">
|
||||||
|
+{fmt(tax, currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{discount > 0 && (
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text className="text-muted-foreground text-[14px] font-sans-medium">
|
||||||
|
Discount
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground text-[14px] font-sans-bold">
|
||||||
|
-{fmt(discount, currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="border-t border-border/60 pt-2.5 flex-row justify-between items-center">
|
||||||
|
<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>
|
</View>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="No items yet"
|
||||||
|
description="This proforma doesn't have any items yet."
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<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>
|
||||||
</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>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Actions Bottom Sheet */}
|
{/* Sticky bottom bar — Send + Download PDF (like invoice detail) */}
|
||||||
|
<View
|
||||||
|
className="flex-row gap-3 px-5 py-3 border-t border-border"
|
||||||
|
style={{ backgroundColor: isDark ? "#0a0505" : "#ffffff" }}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowSendSheet(true)}
|
||||||
|
className="flex-1 h-12 rounded-[8px] border border-border items-center justify-center flex-row gap-2 bg-card"
|
||||||
|
>
|
||||||
|
<SendHorizonal color="#0f172a" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="text-foreground text-[13px] font-sans-bold">
|
||||||
|
Send
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleGetPdf}
|
||||||
|
className="flex-1 h-12 rounded-[8px] border border-border items-center justify-center flex-row gap-2 bg-card"
|
||||||
|
>
|
||||||
|
<Download color="#0f172a" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="text-foreground text-[13px] font-sans-bold">
|
||||||
|
Download PDF
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* More bottom sheet */}
|
||||||
<Modal
|
<Modal
|
||||||
visible={showActions}
|
visible={showMoreSheet}
|
||||||
transparent
|
transparent
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
onRequestClose={() => setShowActions(false)}
|
onRequestClose={() => setShowMoreSheet(false)}
|
||||||
>
|
>
|
||||||
<Pressable className="flex-1 bg-black/40" onPress={() => setShowActions(false)}>
|
<Pressable
|
||||||
|
className="flex-1 bg-black/40"
|
||||||
|
onPress={() => setShowMoreSheet(false)}
|
||||||
|
>
|
||||||
<View className="flex-1 justify-end">
|
<View className="flex-1 justify-end">
|
||||||
<Pressable
|
<Pressable
|
||||||
className="bg-card rounded-t-[36px] overflow-hidden border-t-[3px] border-border/20"
|
className="bg-card rounded-t-[36px] overflow-hidden border-t-[3px] border-border/20"
|
||||||
style={{ maxHeight: SCREEN_HEIGHT * 0.8 }}
|
style={{ maxHeight: SCREEN_HEIGHT * 0.5 }}
|
||||||
onPress={(e) => e.stopPropagation()}
|
onPress={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<View className="px-6 pb-4 pt-4 flex-row justify-between items-center">
|
<View className="px-6 pb-4 pt-4 flex-row justify-between items-center">
|
||||||
<View className="w-10" />
|
<View className="w-10" />
|
||||||
<Text className="text-foreground font-sans-bold text-[18px]">Actions</Text>
|
<Text className="text-foreground font-sans-bold text-[18px]">
|
||||||
|
Proforma
|
||||||
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setShowActions(false)}
|
onPress={() => setShowMoreSheet(false)}
|
||||||
className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10"
|
className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10"
|
||||||
>
|
>
|
||||||
<X
|
<X
|
||||||
|
|
@ -410,77 +656,106 @@ export default function ProformaDetailScreen() {
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{ paddingBottom: 40 }}
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
>
|
>
|
||||||
{/* PDF */}
|
|
||||||
<ActionOption
|
<ActionOption
|
||||||
icon={<Download color="#E46212" size={18} strokeWidth={2} />}
|
icon={<Edit color="#E46212" size={18} strokeWidth={2} />}
|
||||||
label="Download PDF"
|
label="Edit Proforma"
|
||||||
description="Save proforma as PDF document"
|
description="Update details, items, or dates"
|
||||||
onPress={() => { setShowActions(false); handleGetPdf(); }}
|
onPress={() => {
|
||||||
|
setShowMoreSheet(false);
|
||||||
|
nav.go("proforma/edit", { id: proforma.id });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete */}
|
|
||||||
<ActionOption
|
<ActionOption
|
||||||
icon={<Trash2 color="#ef4444" size={18} strokeWidth={2} />}
|
icon={<Trash2 color="#ef4444" size={18} strokeWidth={2} />}
|
||||||
label="Delete Proforma"
|
label="Delete Proforma"
|
||||||
description="Permanently remove this proforma"
|
description="Permanently remove this record"
|
||||||
onPress={() => { setShowActions(false); handleDelete(); }}
|
onPress={() => {
|
||||||
danger
|
setShowMoreSheet(false);
|
||||||
/>
|
handleDelete();
|
||||||
|
}}
|
||||||
{/* Send as Email */}
|
destructive
|
||||||
<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>
|
</ScrollView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Send bottom sheet (Email / SMS) */}
|
||||||
|
<Modal
|
||||||
|
visible={showSendSheet}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setShowSendSheet(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 bg-black/40"
|
||||||
|
onPress={() => setShowSendSheet(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.5 }}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<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]">
|
||||||
|
Send Proforma
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowSendSheet(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 }}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground text-[12px] font-sans-medium mb-4">
|
||||||
|
Send a public, shortened link via yaltopia.com to your
|
||||||
|
customer's email or phone.
|
||||||
|
</Text>
|
||||||
|
<ActionOption
|
||||||
|
icon={<Mail color="#E46212" size={18} strokeWidth={2} />}
|
||||||
|
label="Send as Email"
|
||||||
|
description="Public accessible shortened link via yaltopia.com"
|
||||||
|
onPress={() => handleShare("email")}
|
||||||
|
/>
|
||||||
|
<ActionOption
|
||||||
|
icon={
|
||||||
|
<MessageSquare color="#E46212" size={18} strokeWidth={2} />
|
||||||
|
}
|
||||||
|
label="Send as SMS"
|
||||||
|
description="Public accessible shortened link via yaltopia.com"
|
||||||
|
onPress={() => handleShare("sms")}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ActionModal
|
||||||
|
visible={showDeleteModal}
|
||||||
|
onClose={() => setShowDeleteModal(false)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
title="Delete Proforma"
|
||||||
|
description="Are you sure you want to permanently delete this proforma? This action cannot be reversed."
|
||||||
|
confirmText="Delete"
|
||||||
|
confirmVariant="destructive"
|
||||||
|
icon={Trash2}
|
||||||
|
iconColor="#ef4444"
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
</ScreenWrapper>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -142,9 +142,11 @@ export default function CreateProformaScreen() {
|
||||||
|
|
||||||
// Fields
|
// Fields
|
||||||
const [proformaNumber, setProformaNumber] = useState("");
|
const [proformaNumber, setProformaNumber] = useState("");
|
||||||
|
const [customerId, setCustomerId] = useState("");
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
const [customerEmail, setCustomerEmail] = useState("");
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
const [customerPhone, setCustomerPhone] = useState("");
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [selectedCustomers, setSelectedCustomers] = useState<any[]>([]);
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [currency, setCurrency] = useState("ETB");
|
const [currency, setCurrency] = useState("ETB");
|
||||||
const [taxAmount, setTaxAmount] = useState("0");
|
const [taxAmount, setTaxAmount] = useState("0");
|
||||||
|
|
@ -301,11 +303,20 @@ export default function CreateProformaScreen() {
|
||||||
Customer Name
|
Customer Name
|
||||||
</Text>
|
</Text>
|
||||||
<CustomerPicker
|
<CustomerPicker
|
||||||
value={customerName}
|
selectedIds={customerId ? [customerId] : []}
|
||||||
onSelect={(c) => {
|
selectedCustomers={selectedCustomers}
|
||||||
setCustomerName(c.name);
|
onSelect={(ids, customers) => {
|
||||||
setCustomerEmail(c.email);
|
setCustomerId(ids[0] || "");
|
||||||
setCustomerPhone(c.phone.replace("+251", ""));
|
setSelectedCustomers(customers);
|
||||||
|
if (customers[0]) {
|
||||||
|
setCustomerName(customers[0].name);
|
||||||
|
setCustomerEmail(customers[0].email);
|
||||||
|
setCustomerPhone(customers[0].phone?.replace("+251", "") || "");
|
||||||
|
} else {
|
||||||
|
setCustomerName("");
|
||||||
|
setCustomerEmail("");
|
||||||
|
setCustomerPhone("");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Select or search for a customer"
|
placeholder="Select or search for a customer"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -160,9 +160,11 @@ export default function EditProformaScreen() {
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
const [proformaNumber, setProformaNumber] = useState("");
|
const [proformaNumber, setProformaNumber] = useState("");
|
||||||
|
const [customerId, setCustomerId] = useState("");
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
const [customerEmail, setCustomerEmail] = useState("");
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
const [customerPhone, setCustomerPhone] = useState("");
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [selectedCustomers, setSelectedCustomers] = useState<any[]>([]);
|
||||||
const [currency, setCurrency] = useState("USD");
|
const [currency, setCurrency] = useState("USD");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|
@ -358,11 +360,20 @@ export default function EditProformaScreen() {
|
||||||
Customer Name
|
Customer Name
|
||||||
</Text>
|
</Text>
|
||||||
<CustomerPicker
|
<CustomerPicker
|
||||||
value={customerName}
|
selectedIds={customerId ? [customerId] : []}
|
||||||
onSelect={(c) => {
|
selectedCustomers={selectedCustomers}
|
||||||
setCustomerName(c.name);
|
onSelect={(ids, customers) => {
|
||||||
setCustomerEmail(c.email);
|
setCustomerId(ids[0] || "");
|
||||||
setCustomerPhone(c.phone.replace("+251", ""));
|
setSelectedCustomers(customers);
|
||||||
|
if (customers[0]) {
|
||||||
|
setCustomerName(customers[0].name);
|
||||||
|
setCustomerEmail(customers[0].email);
|
||||||
|
setCustomerPhone(customers[0].phone?.replace("+251", "") || "");
|
||||||
|
} else {
|
||||||
|
setCustomerName("");
|
||||||
|
setCustomerEmail("");
|
||||||
|
setCustomerPhone("");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Select or search for a customer"
|
placeholder="Select or search for a customer"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export default function TeamMemberDetailsScreen() {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<StandardHeader title="Worker Details" showBack />
|
<StandardHeader title="Team Member Details" showBack />
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<ActivityIndicator size="large" color="#E46212" />
|
<ActivityIndicator size="large" color="#E46212" />
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -63,9 +63,9 @@ export default function TeamMemberDetailsScreen() {
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<StandardHeader title="Worker Details" showBack />
|
<StandardHeader title="Team Member Details" showBack />
|
||||||
<View className="flex-1 items-center justify-center px-5">
|
<View className="flex-1 items-center justify-center px-5">
|
||||||
<Text className="text-muted-foreground">Worker not found</Text>
|
<Text className="text-muted-foreground">Team member not found</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
|
|
@ -73,7 +73,7 @@ export default function TeamMemberDetailsScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<StandardHeader title="Worker Details" showBack />
|
<StandardHeader title="Team Member Details" showBack />
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
|
|
||||||
291
app/team/create.tsx
Normal file
291
app/team/create.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, TextInput, StyleSheet, Pressable } from "react-native";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { FormFlow } from "@/components/FormFlow";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
User,
|
||||||
|
Lock,
|
||||||
|
ShieldCheck,
|
||||||
|
ChevronDown,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
input: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
textAlignVertical: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function useInputColors() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
placeholder: "rgba(100,116,139,0.45)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
icon,
|
||||||
|
flex,
|
||||||
|
numeric = false,
|
||||||
|
secureTextEntry = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
flex?: number;
|
||||||
|
numeric?: boolean;
|
||||||
|
secureTextEntry?: boolean;
|
||||||
|
}) {
|
||||||
|
const c = useInputColors();
|
||||||
|
return (
|
||||||
|
<View style={flex != null ? { flex } : undefined}>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View style={[S.input, { backgroundColor: c.bg, borderColor: c.border, flexDirection: "row", alignItems: "center" }]}>
|
||||||
|
{icon}
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1, marginLeft: icon ? 8 : 0, color: c.text, fontSize: 13, fontWeight: "500" }}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={c.placeholder}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType={numeric ? "numeric" : "default"}
|
||||||
|
secureTextEntry={secureTextEntry}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLES = ["VIEWER", "EMPLOYEE", "ACCOUNTANT", "CUSTOMER_SERVICE"];
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "contact", label: "Contact" },
|
||||||
|
{ key: "access", label: "Access" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CreateTeamMemberScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
const iconColor = isDark ? "#94a3b8" : "#64748b";
|
||||||
|
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [showRolePicker, setShowRolePicker] = useState(false);
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [role, setRole] = useState("VIEWER");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (step === 0 && (!firstName.trim() || !lastName.trim())) {
|
||||||
|
toast.error("Validation", "First and last name are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 1 && (!email.trim() || !phone.trim())) {
|
||||||
|
toast.error("Validation", "Email and phone are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step < STEPS.length - 1) setStep(step + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step > 0) setStep(step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!password.trim()) {
|
||||||
|
toast.error("Validation", "Password is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const formattedPhone = `+251${phone.trim()}`;
|
||||||
|
await api.team.create({
|
||||||
|
body: {
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
phone: formattedPhone,
|
||||||
|
role,
|
||||||
|
password: password.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success("Team Member Added", `${firstName} has been added to the team.`);
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("Creation Failed", err.message || "Failed to add team member");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<FormFlow
|
||||||
|
steps={STEPS}
|
||||||
|
currentStep={step}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
onComplete={handleSubmit}
|
||||||
|
loading={submitting}
|
||||||
|
completeLabel="Add Team Member"
|
||||||
|
>
|
||||||
|
{step === 0 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<View className="mb-2">
|
||||||
|
<Text className="font-sans-bold text-[18px] text-foreground">
|
||||||
|
Personal Info
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 font-sans-medium text-[13px] text-muted-foreground">
|
||||||
|
Enter the team member's full name
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Field
|
||||||
|
label="First Name"
|
||||||
|
value={firstName}
|
||||||
|
onChangeText={setFirstName}
|
||||||
|
placeholder="First name"
|
||||||
|
icon={<User size={16} color={iconColor} />}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Last Name"
|
||||||
|
value={lastName}
|
||||||
|
onChangeText={setLastName}
|
||||||
|
placeholder="Last name"
|
||||||
|
icon={<User size={16} color={iconColor} />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<View className="mb-2">
|
||||||
|
<Text className="font-sans-bold text-[18px] text-foreground">
|
||||||
|
Contact Details
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 font-sans-medium text-[13px] text-muted-foreground">
|
||||||
|
How to reach this team member
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Field
|
||||||
|
label="Email Address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="email@company.com"
|
||||||
|
icon={<Mail size={16} color={iconColor} />}
|
||||||
|
/>
|
||||||
|
<View>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
Phone Number
|
||||||
|
</Text>
|
||||||
|
<View style={[S.input, { backgroundColor: useInputColors().bg, borderColor: useInputColors().border, flexDirection: "row", alignItems: "center" }]}>
|
||||||
|
<Phone size={16} color={iconColor} />
|
||||||
|
<Text style={{ marginLeft: 8, color: useInputColors().text, fontSize: 13, fontWeight: "600" }}>+251</Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1, marginLeft: 4, color: useInputColors().text, fontSize: 13, fontWeight: "500" }}
|
||||||
|
placeholder="912345678"
|
||||||
|
placeholderTextColor={useInputColors().placeholder}
|
||||||
|
value={phone}
|
||||||
|
onChangeText={setPhone}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
maxLength={9}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<View className="gap-5">
|
||||||
|
<View className="mb-2">
|
||||||
|
<Text className="font-sans-bold text-[18px] text-foreground">
|
||||||
|
System Access
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 font-sans-medium text-[13px] text-muted-foreground">
|
||||||
|
Set role and initial password
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
||||||
|
Role
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowRolePicker(true)}
|
||||||
|
style={[S.input, { backgroundColor: useInputColors().bg, borderColor: useInputColors().border, flexDirection: "row", alignItems: "center" }]}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={16} color={iconColor} />
|
||||||
|
<Text style={{ flex: 1, marginLeft: 8, color: useInputColors().text, fontSize: 13, fontWeight: "500" }}>
|
||||||
|
{role.replace("_", " ")}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={16} color={iconColor} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Initial Password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="••••••••"
|
||||||
|
icon={<Lock size={16} color={iconColor} />}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</FormFlow>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showRolePicker}
|
||||||
|
onClose={() => setShowRolePicker(false)}
|
||||||
|
title="Select Role"
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<SelectOption
|
||||||
|
key={r}
|
||||||
|
label={r.replace("_", " ")}
|
||||||
|
value={r}
|
||||||
|
selected={role === r}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setRole(v);
|
||||||
|
setShowRolePicker(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -65,14 +65,14 @@ export default function TeamScreen() {
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<Stack.Screen options={{ headerShown: false }} />
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
<StandardHeader title="Workers" showBack />
|
<StandardHeader title="Team" showBack />
|
||||||
|
|
||||||
<View className="flex-1 px-5 pt-4">
|
<View className="flex-1 px-5 pt-4">
|
||||||
<View className="flex-row items-center bg-card rounded-xl px-4 border border-border h-12 mb-6">
|
<View className="flex-row items-center bg-card rounded-xl px-4 border border-border h-12 mb-6">
|
||||||
<Search size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<Search size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground"
|
className="flex-1 ml-3 text-foreground"
|
||||||
placeholder="Search workers..."
|
placeholder="Search team members..."
|
||||||
placeholderTextColor={getPlaceholderColor(isDark)}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChangeText={setSearchQuery}
|
onChangeText={setSearchQuery}
|
||||||
|
|
@ -81,17 +81,17 @@ export default function TeamScreen() {
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
className="mb-4 h-10 rounded-lg bg-primary"
|
className="mb-4 h-10 rounded-lg bg-primary"
|
||||||
onPress={() => nav.go("user/create")}
|
onPress={() => nav.go("team/create")}
|
||||||
>
|
>
|
||||||
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
||||||
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
<Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
|
||||||
Add Worker
|
Add Team Member
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<View className="flex-row justify-between items-center mb-4">
|
<View className="flex-row justify-between items-center mb-4">
|
||||||
<Text variant="h4" className="text-foreground tracking-tight">
|
<Text variant="h4" className="text-foreground tracking-tight">
|
||||||
Workers ({filteredWorkers?.length || 0})
|
Members ({filteredWorkers?.length || 0})
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -159,7 +159,7 @@ export default function TeamScreen() {
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No workers found"
|
title="No team members found"
|
||||||
description="Start by adding your first team member."
|
description="Start by adding your first team member."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 229 KiB |
BIN
assets/ticket.png
Normal file
BIN
assets/ticket.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
|
|
@ -3,7 +3,7 @@ import { View, Pressable, TextInput, useColorScheme, Modal, ScrollView } from "r
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { X, Search, FileText, ShieldCheck, Wallet, Receipt, Settings, User, HelpCircle, Briefcase, FolderOpen, BarChart3, DraftingCompass, Scan, Lock, Globe, History } from "@/lib/icons";
|
import { X, Search, FileText, ShieldCheck, Wallet, Settings, User, HelpCircle, Briefcase, FolderOpen, BarChart3, DraftingCompass, Scan, Lock, Globe, History, Inbox } from "@/lib/icons";
|
||||||
|
|
||||||
|
|
||||||
const ICON_COLOR = "#E46212";
|
const ICON_COLOR = "#E46212";
|
||||||
|
|
@ -19,7 +19,6 @@ const FLOWS: Flow[] = [
|
||||||
{ label: "Add Invoice", keywords: ["invoice", "create", "new", "bill"], route: "invoices/create", icon: <FileText size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Add Invoice", keywords: ["invoice", "create", "new", "bill"], route: "invoices/create", icon: <FileText size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "Verify Payment", keywords: ["verify", "payment", "reference", "ft"], route: "verify-payment", icon: <ShieldCheck size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Verify Payment", keywords: ["verify", "payment", "reference", "ft"], route: "verify-payment", icon: <ShieldCheck size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "Create Payment", keywords: ["payment", "create", "new", "pay"], route: "payments/create", icon: <Wallet size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Create Payment", keywords: ["payment", "create", "new", "pay"], route: "payments/create", icon: <Wallet size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "Add Receipt", keywords: ["receipt", "scan", "upload"], route: "add-receipt", icon: <Receipt size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
|
||||||
{ label: "Settings", keywords: ["settings", "preferences", "theme"], route: "settings", icon: <Settings size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Settings", keywords: ["settings", "preferences", "theme"], route: "settings", icon: <Settings size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "Profile", keywords: ["profile", "account", "user"], route: "profile", icon: <User size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Profile", keywords: ["profile", "account", "user"], route: "profile", icon: <User size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "Help & Support", keywords: ["help", "support", "ticket"], route: "help", icon: <HelpCircle size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Help & Support", keywords: ["help", "support", "ticket"], route: "help", icon: <HelpCircle size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
|
|
@ -29,6 +28,7 @@ const FLOWS: Flow[] = [
|
||||||
{ label: "Reports", keywords: ["reports", "analytics", "stats"], route: "reports/index", icon: <BarChart3 size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Reports", keywords: ["reports", "analytics", "stats"], route: "reports/index", icon: <BarChart3 size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "Scan Receipt", keywords: ["scan", "camera", "receipt", "ocr"], route: "(tabs)/scan", icon: <Scan size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Scan Receipt", keywords: ["scan", "camera", "receipt", "ocr"], route: "(tabs)/scan", icon: <Scan size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "Proforma", keywords: ["proforma", "estimate", "quote"], route: "(tabs)/proforma", icon: <DraftingCompass size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Proforma", keywords: ["proforma", "estimate", "quote"], route: "(tabs)/proforma", icon: <DraftingCompass size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
|
{ label: "Proforma Requests", keywords: ["request", "rfq", "quote", "proforma", "inquiry"], route: "(tabs)/proforma", icon: <Inbox size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "News", keywords: ["news", "updates", "announcements"], route: "news/index", icon: <Globe size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "News", keywords: ["news", "updates", "announcements"], route: "news/index", icon: <Globe size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "Change PIN", keywords: ["pin", "password", "security", "change"], route: "set-pin", icon: <Lock size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Change PIN", keywords: ["pin", "password", "security", "change"], route: "set-pin", icon: <Lock size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
{ label: "Payment History", keywords: ["payments", "history", "transactions"], route: "history", icon: <History size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
{ label: "Payment History", keywords: ["payments", "history", "transactions"], route: "history", icon: <History size={18} color={ICON_COLOR} strokeWidth={2} /> },
|
||||||
|
|
|
||||||
102
components/ConfirmSubmitModal.tsx
Normal file
102
components/ConfirmSubmitModal.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Modal, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
import { Text } from "./ui/text";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { CheckCircle2, X } from "@/lib/icons";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
interface ConfirmSubmitModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmSubmitModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title = "Confirm submission",
|
||||||
|
description = "Are you sure all the information is correct? Please review before proceeding.",
|
||||||
|
confirmText = "Yes, submit",
|
||||||
|
cancelText = "Review again",
|
||||||
|
loading = false,
|
||||||
|
}: ConfirmSubmitModalProps) {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
transparent
|
||||||
|
visible={visible}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
className="bg-black/60 items-center justify-center p-6"
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="w-full max-w-sm bg-card rounded-[6px] border border-border overflow-hidden"
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center justify-between px-5 pt-5 pb-2">
|
||||||
|
<View className="flex-row items-center gap-3 flex-1">
|
||||||
|
<View className="p-2 rounded-full bg-primary/10">
|
||||||
|
<CheckCircle2 size={20} color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
<Text variant="h4" className="font-sans-black tracking-tight flex-1">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-5 pb-6">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-muted-foreground font-sans-medium leading-5"
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row border-t border-border p-3 gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 h-12 rounded-[6px]"
|
||||||
|
onPress={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text className="font-sans-bold text-xs">
|
||||||
|
{cancelText}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="flex-1 h-12 rounded-[6px] bg-primary"
|
||||||
|
onPress={onConfirm}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<Text className="text-white font-sans-bold text-xs">
|
||||||
|
{confirmText}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
components/CreateMethodSheet.tsx
Normal file
102
components/CreateMethodSheet.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Modal, Pressable, View, Dimensions, ScrollView } from "react-native";
|
||||||
|
import { Text } from "./ui/text";
|
||||||
|
import { ScanLine, X, Edit } from "@/lib/icons";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
interface CreateMethodSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectScan: () => void;
|
||||||
|
onSelectManual: () => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateMethodSheet({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSelectScan,
|
||||||
|
onSelectManual,
|
||||||
|
title = "Create",
|
||||||
|
}: CreateMethodSheetProps) {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
|
||||||
|
<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.5 }}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<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]">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={onSelectScan}
|
||||||
|
className="flex-row items-center gap-3.5 p-4 mb-2 rounded-[6px] border border-border bg-card active:opacity-70"
|
||||||
|
>
|
||||||
|
<View className="h-10 w-10 rounded-full bg-primary/10 items-center justify-center">
|
||||||
|
<ScanLine color="#E46212" size={20} strokeWidth={2} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground text-[14px] font-sans-bold">
|
||||||
|
Scan
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
|
||||||
|
Capture a photo and auto-fill the form
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={onSelectManual}
|
||||||
|
className="flex-row items-center gap-3.5 p-4 mb-2 rounded-[6px] border border-border bg-card active:opacity-70"
|
||||||
|
>
|
||||||
|
<View className="h-10 w-10 rounded-full bg-primary/10 items-center justify-center">
|
||||||
|
<Edit color="#E46212" size={20} strokeWidth={2} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground text-[14px] font-sans-bold">
|
||||||
|
Enter manually
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
|
||||||
|
Enter all the details yourself
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,25 +10,27 @@ import {
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Search, X, Plus, User, Building2, ChevronDown } from "@/lib/icons";
|
import { Search, X, Plus, User, Building2, ChevronDown, Check } from "@/lib/icons";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
interface CustomerData {
|
interface CustomerData {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomerPickerProps {
|
interface CustomerPickerProps {
|
||||||
value: string;
|
selectedIds: string[];
|
||||||
onSelect: (c: CustomerData) => void;
|
selectedCustomers: CustomerData[];
|
||||||
|
onSelect: (ids: string[], customers: CustomerData[]) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerProps) {
|
export function CustomerPicker({ selectedIds, selectedCustomers, onSelect, placeholder }: CustomerPickerProps) {
|
||||||
const nav = useSirouRouter<any>();
|
const nav = useSirouRouter<any>();
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
const isDark = colorScheme === "dark";
|
const isDark = colorScheme === "dark";
|
||||||
|
|
@ -38,8 +40,13 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const [tempIds, setTempIds] = useState<string[]>(selectedIds);
|
||||||
|
const [tempCustomers, setTempCustomers] = useState<CustomerData[]>(selectedCustomers);
|
||||||
|
|
||||||
const openPicker = async () => {
|
const openPicker = async () => {
|
||||||
setShow(true);
|
setShow(true);
|
||||||
|
setTempIds(selectedIds);
|
||||||
|
setTempCustomers(selectedCustomers);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -52,6 +59,29 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleCustomer = (c: any) => {
|
||||||
|
const id = String(c.id);
|
||||||
|
const name = c.displayName || `${c.firstName || ""} ${c.lastName || ""}`.trim() || c.companyName || "";
|
||||||
|
|
||||||
|
let newIds: string[];
|
||||||
|
let newCustomers: CustomerData[];
|
||||||
|
|
||||||
|
if (tempIds.includes(id)) {
|
||||||
|
newIds = tempIds.filter((i) => i !== id);
|
||||||
|
newCustomers = tempCustomers.filter((p) => p.id !== id);
|
||||||
|
} else {
|
||||||
|
newIds = [...tempIds, id];
|
||||||
|
newCustomers = [
|
||||||
|
...tempCustomers,
|
||||||
|
{ id, name, email: c.email || "", phone: c.phone || "" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
setTempIds(newIds);
|
||||||
|
setTempCustomers(newCustomers);
|
||||||
|
onSelect(newIds, newCustomers);
|
||||||
|
};
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search.trim()) return customers;
|
if (!search.trim()) return customers;
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
|
|
@ -65,14 +95,25 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP
|
||||||
);
|
);
|
||||||
}, [customers, search]);
|
}, [customers, search]);
|
||||||
|
|
||||||
|
const triggerLabel = selectedIds.length === 0
|
||||||
|
? (placeholder || "Select customers")
|
||||||
|
: selectedIds.length === 1
|
||||||
|
? selectedCustomers[0]?.name || placeholder
|
||||||
|
: `${selectedIds.length} customers selected`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={openPicker}
|
onPress={openPicker}
|
||||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between bg-card"
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between bg-card"
|
||||||
>
|
>
|
||||||
<Text className={`text-xs font-sans-medium ${value ? "text-foreground" : "text-muted-foreground"}`}>
|
<Text
|
||||||
{value || placeholder || "Select a customer"}
|
className={`text-xs font-sans-medium flex-1 mr-2 ${
|
||||||
|
selectedIds.length > 0 ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{triggerLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronDown size={14} color="#94a3b8" strokeWidth={2.5} />
|
<ChevronDown size={14} color="#94a3b8" strokeWidth={2.5} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
@ -119,7 +160,6 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Add New Customer */}
|
|
||||||
<View className="px-5 pb-5">
|
<View className="px-5 pb-5">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => { setShow(false); nav.go("customers/create"); }}
|
onPress={() => { setShow(false); nav.go("customers/create"); }}
|
||||||
|
|
@ -148,20 +188,14 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP
|
||||||
) : (
|
) : (
|
||||||
filtered.map((c: any) => {
|
filtered.map((c: any) => {
|
||||||
const isCompany = c.type === "COMPANY";
|
const isCompany = c.type === "COMPANY";
|
||||||
|
const isSelected = tempIds.includes(String(c.id));
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onPress={() => {
|
onPress={() => toggleCustomer(c)}
|
||||||
setShow(false);
|
className="bg-card rounded-[6px] border border-border p-4 mb-3 flex-row items-center"
|
||||||
onSelect({
|
|
||||||
name: c.displayName || `${c.firstName || ""} ${c.lastName || ""}`.trim() || c.companyName || "",
|
|
||||||
email: c.email || "",
|
|
||||||
phone: c.phone || "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="bg-card rounded-[6px] border border-border p-4 mb-3"
|
|
||||||
>
|
>
|
||||||
<View className="flex-row items-center gap-3">
|
<View className="flex-row items-center gap-3 flex-1">
|
||||||
<View className={`h-9 w-9 rounded-full items-center justify-center ${isCompany ? "bg-blue-500/10" : "bg-primary/10"}`}>
|
<View className={`h-9 w-9 rounded-full items-center justify-center ${isCompany ? "bg-blue-500/10" : "bg-primary/10"}`}>
|
||||||
{isCompany ? (
|
{isCompany ? (
|
||||||
<Building2 color="#2563eb" size={16} strokeWidth={2} />
|
<Building2 color="#2563eb" size={16} strokeWidth={2} />
|
||||||
|
|
@ -185,6 +219,13 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<View
|
||||||
|
className={`h-5 w-5 rounded-full border-2 items-center justify-center ml-3 ${
|
||||||
|
isSelected ? "bg-primary border-primary" : "border-muted-foreground/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && <Check size={12} color="white" strokeWidth={3} />}
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,54 @@
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { View, StyleSheet, Animated } from "react-native";
|
import { View, StyleSheet, Animated } from "react-native";
|
||||||
import { CheckCircle2, AlertCircle, AlertTriangle, Lightbulb, X } from "@/lib/icons";
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Lightbulb,
|
||||||
|
} from "@/lib/icons";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useToast, ToastType } from "@/lib/toast-store";
|
import { useToast, ToastType } from "@/lib/toast-store";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
const VARIANT_CONFIG: Record<
|
const VARIANT_CONFIG: Record<
|
||||||
ToastType,
|
ToastType,
|
||||||
{ iconColor: string; borderColor: string; icon: React.ReactNode }
|
{ iconColor: string; icon: typeof CheckCircle2 }
|
||||||
> = {
|
> = {
|
||||||
success: {
|
success: {
|
||||||
iconColor: "#16a34a",
|
iconColor: "#4ADE80",
|
||||||
borderColor: "#16a34a",
|
icon: CheckCircle2,
|
||||||
icon: <CheckCircle2 size={18} color="#16a34a" strokeWidth={2.5} />,
|
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
iconColor: "#dc2626",
|
iconColor: "#F87171",
|
||||||
borderColor: "#dc2626",
|
icon: AlertCircle,
|
||||||
icon: <AlertCircle size={18} color="#dc2626" strokeWidth={2.5} />,
|
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
iconColor: "#d97706",
|
iconColor: "#FBBF24",
|
||||||
borderColor: "#d97706",
|
icon: AlertTriangle,
|
||||||
icon: <AlertTriangle size={18} color="#d97706" strokeWidth={2.5} />,
|
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
iconColor: "#E46212",
|
iconColor: "#60A5FA",
|
||||||
borderColor: "#E46212",
|
icon: Lightbulb,
|
||||||
icon: <Lightbulb size={18} color="#E46212" strokeWidth={2.5} />,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModalToast() {
|
export function ModalToast() {
|
||||||
const { visible, type, title, message, hide, duration } = useToast();
|
const { visible, type, title, message, hide, duration } = useToast();
|
||||||
const isDark = useColorScheme() === "dark";
|
const isDark = useColorScheme() === "dark";
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const translateX = useRef(new Animated.Value(-40)).current;
|
const translateY = useRef(new Animated.Value(-20)).current;
|
||||||
const opacity = useRef(new Animated.Value(0)).current;
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const hideRef = useRef(hide);
|
||||||
|
hideRef.current = hide;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
translateX.setValue(-40);
|
translateY.setValue(-20);
|
||||||
opacity.setValue(0);
|
opacity.setValue(0);
|
||||||
|
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.spring(translateX, {
|
Animated.spring(translateY, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
speed: 20,
|
speed: 20,
|
||||||
|
|
@ -59,57 +61,48 @@ export function ModalToast() {
|
||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
|
|
||||||
const timer = setTimeout(hide, duration);
|
const timer = setTimeout(() => {
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 180,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => hideRef.current());
|
||||||
|
}, duration);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible, duration]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
const config = VARIANT_CONFIG[type];
|
const config = VARIANT_CONFIG[type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
style={[StyleSheet.absoluteFill, styles.absoluteOverlay]}
|
style={[StyleSheet.absoluteFill, styles.absoluteOverlay]}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<View style={styles.wrapper}>
|
||||||
style={[
|
<Animated.View
|
||||||
styles.toast,
|
|
||||||
{
|
|
||||||
top: insets.top + 12,
|
|
||||||
backgroundColor: isDark ? "#1C1C1C" : "#ffffff",
|
|
||||||
borderColor: config.borderColor,
|
|
||||||
borderWidth: 1,
|
|
||||||
transform: [{ translateX }],
|
|
||||||
opacity,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
style={[
|
||||||
styles.iconContainer,
|
styles.toast,
|
||||||
{ backgroundColor: isDark ? "#2a2a2a" : "#f5f5f5" },
|
{
|
||||||
|
backgroundColor: isDark ? "#1C1C1C" : "#ffffff",
|
||||||
|
borderColor: isDark ? "#2A2A2A" : "#E5E5E5",
|
||||||
|
transform: [{ translateY }],
|
||||||
|
opacity,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{config.icon}
|
<Icon size={22} color={config.iconColor} strokeWidth={1.5} />
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.textContainer}>
|
<View style={styles.textContainer}>
|
||||||
<Text className="text-foreground text-[14px] font-sans-black leading-[18px]">
|
<Text className="text-foreground text-[15px] font-sans-semibold tracking-[-0.3px]">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
|
||||||
{message ? (
|
|
||||||
<Text className="text-muted-foreground text-[12px] font-sans-medium leading-[16px] mt-1">
|
|
||||||
{message}
|
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
|
</View>
|
||||||
<View className="h-6 w-6 rounded-full items-center justify-center">
|
|
||||||
<X size={14} color="#9ca3af" strokeWidth={2.5} />
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -119,27 +112,26 @@ const styles = StyleSheet.create({
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
elevation: 50,
|
elevation: 50,
|
||||||
},
|
},
|
||||||
|
wrapper: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 60,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
marginHorizontal: 16,
|
width: "100%",
|
||||||
borderRadius: 12,
|
maxWidth: 400,
|
||||||
paddingHorizontal: 16,
|
borderRadius: 14,
|
||||||
paddingVertical: 12,
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 16,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
shadowColor: "#000",
|
borderWidth: 1,
|
||||||
shadowOpacity: 0.18,
|
gap: 12,
|
||||||
shadowRadius: 8,
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
},
|
|
||||||
iconContainer: {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
},
|
||||||
textContainer: {
|
textContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginLeft: 12,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { View, Dimensions, Pressable } from "react-native";
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { useToast, ToastType } from "@/lib/toast-store";
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
Lightbulb,
|
|
||||||
X,
|
|
||||||
} from "@/lib/icons";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import Animated, {
|
|
||||||
useSharedValue,
|
|
||||||
useAnimatedStyle,
|
|
||||||
withSpring,
|
|
||||||
withTiming,
|
|
||||||
runOnJS,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
|
||||||
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.35;
|
|
||||||
|
|
||||||
const TOAST_VARIANTS: Record<
|
|
||||||
ToastType,
|
|
||||||
{
|
|
||||||
accent: string;
|
|
||||||
iconBg: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
success: {
|
|
||||||
accent: "#16a34a",
|
|
||||||
iconBg: "#16a34a15",
|
|
||||||
icon: <CheckCircle2 size={20} color="#16a34a" strokeWidth={2.5} />,
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
accent: "#E46212",
|
|
||||||
iconBg: "#E4621215",
|
|
||||||
icon: <Lightbulb size={20} color="#E46212" strokeWidth={2.5} />,
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
accent: "#d97706",
|
|
||||||
iconBg: "#d9770615",
|
|
||||||
icon: <AlertTriangle size={20} color="#d97706" strokeWidth={2.5} />,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
accent: "#dc2626",
|
|
||||||
iconBg: "#dc262615",
|
|
||||||
icon: <AlertCircle size={20} color="#dc2626" strokeWidth={2.5} />,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Toast() {
|
|
||||||
const { visible, type, title, message, hide, duration } = useToast();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const opacity = useSharedValue(0);
|
|
||||||
const scale = useSharedValue(0.85);
|
|
||||||
const translateY = useSharedValue(-60);
|
|
||||||
const translateX = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
opacity.value = withTiming(1, { duration: 200 });
|
|
||||||
scale.value = withSpring(1, { damping: 14, stiffness: 160 });
|
|
||||||
translateY.value = withSpring(0, { damping: 16, stiffness: 140 });
|
|
||||||
translateX.value = 0;
|
|
||||||
|
|
||||||
const timer = setTimeout(handleHide, duration);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
const handleHide = () => {
|
|
||||||
opacity.value = withTiming(0, { duration: 180 });
|
|
||||||
scale.value = withTiming(0.92, { duration: 180 });
|
|
||||||
translateY.value = withTiming(-40, { duration: 180 }, () => {
|
|
||||||
runOnJS(hide)();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const swipeGesture = Gesture.Pan()
|
|
||||||
.onUpdate((event) => {
|
|
||||||
translateX.value = event.translationX;
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
if (Math.abs(event.translationX) > SWIPE_THRESHOLD) {
|
|
||||||
translateX.value = withTiming(
|
|
||||||
event.translationX > 0 ? SCREEN_WIDTH : -SCREEN_WIDTH,
|
|
||||||
{ duration: 200 },
|
|
||||||
() => runOnJS(handleHide)(),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
translateX.value = withSpring(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: opacity.value,
|
|
||||||
transform: [
|
|
||||||
{ translateY: translateY.value },
|
|
||||||
{ translateX: translateX.value },
|
|
||||||
{ scale: scale.value },
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
const variant = TOAST_VARIANTS[type];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GestureDetector gesture={swipeGesture}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
top: insets.top + 12,
|
|
||||||
zIndex: 9999,
|
|
||||||
shadowColor: variant.accent,
|
|
||||||
shadowOffset: { width: 0, height: 6 },
|
|
||||||
shadowOpacity: 0.15,
|
|
||||||
shadowRadius: 16,
|
|
||||||
elevation: 8,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
className="bg-white dark:bg-[#1C1C1C] border border-border rounded-[14px]"
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: 3,
|
|
||||||
backgroundColor: variant.accent,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View className="flex-row items-start pt-4 pb-3.5 px-4">
|
|
||||||
<View
|
|
||||||
className="h-8 w-8 rounded-full items-center justify-center mr-3 mt-0.5"
|
|
||||||
style={{ backgroundColor: variant.iconBg }}
|
|
||||||
>
|
|
||||||
{variant.icon}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-1 pr-1">
|
|
||||||
<Text className="text-foreground text-[14px] font-sans-black leading-[18px]">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{message ? (
|
|
||||||
<Text className="text-muted-foreground text-[12px] font-sans-medium leading-[16px] mt-1">
|
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={handleHide}
|
|
||||||
hitSlop={8}
|
|
||||||
className="h-6 w-6 rounded-full items-center justify-center -mr-1 mt-0.5"
|
|
||||||
>
|
|
||||||
<X size={14} color="#9ca3af" strokeWidth={2.5} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
17
lib/api.ts
17
lib/api.ts
|
|
@ -81,6 +81,7 @@ export const api = createApi({
|
||||||
endpoints: {
|
endpoints: {
|
||||||
get: { method: "GET", path: "company" },
|
get: { method: "GET", path: "company" },
|
||||||
update: { method: "PUT", path: "company" },
|
update: { method: "PUT", path: "company" },
|
||||||
|
paymentMethods: { method: "GET", path: "company/payment-methods" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
team: {
|
team: {
|
||||||
|
|
@ -115,6 +116,7 @@ export const api = createApi({
|
||||||
update: { method: "PUT", path: "payments/:id" },
|
update: { method: "PUT", path: "payments/:id" },
|
||||||
associate: { method: "POST", path: "payments/:id/associate" },
|
associate: { method: "POST", path: "payments/:id/associate" },
|
||||||
verifySms: { method: "POST", path: "payments/:id/verify-sms" },
|
verifySms: { method: "POST", path: "payments/:id/verify-sms" },
|
||||||
|
flag: { method: "POST", path: "payments/:id/flag" },
|
||||||
delete: { method: "DELETE", path: "payments/:id" },
|
delete: { method: "DELETE", path: "payments/:id" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -124,8 +126,9 @@ export const api = createApi({
|
||||||
getAll: { method: "GET", path: "payment-requests" },
|
getAll: { method: "GET", path: "payment-requests" },
|
||||||
getById: { method: "GET", path: "payment-requests/:id" },
|
getById: { method: "GET", path: "payment-requests/:id" },
|
||||||
create: { method: "POST", path: "payment-requests" },
|
create: { method: "POST", path: "payment-requests" },
|
||||||
|
update: { method: "PUT", path: "payment-requests/:id" },
|
||||||
open: { method: "POST", path: "payment-requests/:id/open" },
|
open: { method: "POST", path: "payment-requests/:id/open" },
|
||||||
sendEmail: { method: "POST", path: "payment-requests/:id/send-email" },
|
send: { method: "POST", path: "payment-requests/:id/send" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
proforma: {
|
proforma: {
|
||||||
|
|
@ -137,6 +140,15 @@ export const api = createApi({
|
||||||
update: { method: "PUT", path: "proforma/:id" },
|
update: { method: "PUT", path: "proforma/:id" },
|
||||||
delete: { method: "DELETE", path: "proforma/:id" },
|
delete: { method: "DELETE", path: "proforma/:id" },
|
||||||
getPdf: { method: "GET", path: "proforma/:id/pdf" },
|
getPdf: { method: "GET", path: "proforma/:id/pdf" },
|
||||||
|
shareLink: { method: "POST", path: "proforma/share/link" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proformaRequests: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
getAll: { method: "GET", path: "proforma-requests" },
|
||||||
|
getById: { method: "GET", path: "proforma-requests/:id" },
|
||||||
|
create: { method: "POST", path: "proforma-requests" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rbac: {
|
rbac: {
|
||||||
|
|
@ -165,6 +177,8 @@ export const api = createApi({
|
||||||
getAll: { method: "GET", path: "customers" },
|
getAll: { method: "GET", path: "customers" },
|
||||||
getById: { method: "GET", path: "customers/:id" },
|
getById: { method: "GET", path: "customers/:id" },
|
||||||
create: { method: "POST", path: "customers" },
|
create: { method: "POST", path: "customers" },
|
||||||
|
update: { method: "PUT", path: "customers/:id" },
|
||||||
|
delete: { method: "DELETE", path: "customers/:id" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
declarations: {
|
declarations: {
|
||||||
|
|
@ -175,6 +189,7 @@ export const api = createApi({
|
||||||
create: { method: "POST", path: "declarations" },
|
create: { method: "POST", path: "declarations" },
|
||||||
update: { method: "PUT", path: "declarations/:id" },
|
update: { method: "PUT", path: "declarations/:id" },
|
||||||
delete: { method: "DELETE", path: "declarations/:id" },
|
delete: { method: "DELETE", path: "declarations/:id" },
|
||||||
|
scan: { method: "POST", path: "declarations/scan" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ export {
|
||||||
Triangle as TrianglePlanets,
|
Triangle as TrianglePlanets,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
|
Flag,
|
||||||
Check,
|
Check,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
|
@ -82,4 +83,8 @@ export {
|
||||||
MapPin,
|
MapPin,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
Inbox,
|
||||||
|
Truck,
|
||||||
|
Hourglass,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { ImageSourcePropType } from "react-native";
|
|
||||||
|
|
||||||
const PROVIDER_LOGOS: Record<string, ImageSourcePropType> = {
|
|
||||||
telebirr: require("@/assets/telebirr.png"),
|
|
||||||
cbe: require("@/assets/cbe.png"),
|
|
||||||
dashen: require("@/assets/dashen.png"),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getProviderLogo(paymentMethod: string): ImageSourcePropType | null {
|
|
||||||
return PROVIDER_LOGOS[paymentMethod.toLowerCase()] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCash(paymentMethod: string): boolean {
|
|
||||||
return paymentMethod.toLowerCase() === "cash";
|
|
||||||
}
|
|
||||||
|
|
@ -87,6 +87,17 @@ export const routes = defineRoutes({
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
"proforma-requests/create": {
|
||||||
|
path: "/proforma-requests/create",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Create Proforma Request" },
|
||||||
|
},
|
||||||
|
"proforma-requests/[id]": {
|
||||||
|
path: "/proforma-requests/:id",
|
||||||
|
params: { id: "string" },
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Proforma Request" },
|
||||||
|
},
|
||||||
"payments/[id]": {
|
"payments/[id]": {
|
||||||
path: "/payments/:id",
|
path: "/payments/:id",
|
||||||
params: { id: "string" },
|
params: { id: "string" },
|
||||||
|
|
@ -104,11 +115,23 @@ export const routes = defineRoutes({
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
"payment-requests/edit": {
|
||||||
|
path: "/payment-requests/edit",
|
||||||
|
params: { id: "string" },
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
"customers/create": {
|
"customers/create": {
|
||||||
path: "/customers/create",
|
path: "/customers/create",
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
"customers/edit": {
|
||||||
|
path: "/customers/edit",
|
||||||
|
params: { id: "string" },
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
"customers/[id]": {
|
"customers/[id]": {
|
||||||
path: "/customers/:id",
|
path: "/customers/:id",
|
||||||
params: { id: "string" },
|
params: { id: "string" },
|
||||||
|
|
@ -208,11 +231,6 @@ export const routes = defineRoutes({
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true, title: "Verification Result" },
|
meta: { requiresAuth: true, title: "Verification Result" },
|
||||||
},
|
},
|
||||||
"add-receipt": {
|
|
||||||
path: "/add-receipt",
|
|
||||||
guards: ["auth"],
|
|
||||||
meta: { requiresAuth: true, title: "Add Receipt" },
|
|
||||||
},
|
|
||||||
company: {
|
company: {
|
||||||
path: "/company",
|
path: "/company",
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
|
|
@ -231,18 +249,18 @@ export const routes = defineRoutes({
|
||||||
"team/index": {
|
"team/index": {
|
||||||
path: "/team",
|
path: "/team",
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true, title: "Workers" },
|
meta: { requiresAuth: true, title: "Team" },
|
||||||
},
|
},
|
||||||
"team/[id]/details": {
|
"team/[id]/details": {
|
||||||
path: "/team/:id/details",
|
path: "/team/:id/details",
|
||||||
params: { id: "string" },
|
params: { id: "string" },
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true, title: "Worker Details" },
|
meta: { requiresAuth: true, title: "Team Member Details" },
|
||||||
},
|
},
|
||||||
"user/create": {
|
"team/create": {
|
||||||
path: "/user/create",
|
path: "/team/create",
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true, title: "Add User" },
|
meta: { requiresAuth: true, title: "Add Team Member" },
|
||||||
},
|
},
|
||||||
"declarations/index": {
|
"declarations/index": {
|
||||||
path: "/declarations/index",
|
path: "/declarations/index",
|
||||||
|
|
@ -254,6 +272,11 @@ export const routes = defineRoutes({
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true, title: "Create Declaration" },
|
meta: { requiresAuth: true, title: "Create Declaration" },
|
||||||
},
|
},
|
||||||
|
"declarations/scan": {
|
||||||
|
path: "/declarations/scan",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Scan Declaration" },
|
||||||
|
},
|
||||||
"declarations/edit": {
|
"declarations/edit": {
|
||||||
path: "/declarations/edit",
|
path: "/declarations/edit",
|
||||||
params: { id: "string" },
|
params: { id: "string" },
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
let _scanData: any = null;
|
export type ScanType = "invoice" | "payment" | "declaration";
|
||||||
|
|
||||||
export function setScanData(data: any) {
|
export interface ScanPayload {
|
||||||
_scanData = data;
|
type: ScanType;
|
||||||
|
id?: string;
|
||||||
|
data: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScanData() {
|
let _scanData: ScanPayload | null = null;
|
||||||
|
|
||||||
|
export function setScanData(payload: ScanPayload) {
|
||||||
|
_scanData = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScanData(): ScanPayload | null {
|
||||||
const data = _scanData;
|
const data = _scanData;
|
||||||
_scanData = null;
|
_scanData = null;
|
||||||
return data;
|
return data;
|
||||||
|
|
|
||||||
574
package-lock.json
generated
574
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -26,6 +26,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"expo": "~52.0.35",
|
"expo": "~52.0.35",
|
||||||
"expo-camera": "~16.0.18",
|
"expo-camera": "~16.0.18",
|
||||||
|
"expo-clipboard": "^56.0.4",
|
||||||
"expo-constants": "~17.0.7",
|
"expo-constants": "~17.0.7",
|
||||||
"expo-document-picker": "~13.0.3",
|
"expo-document-picker": "~13.0.3",
|
||||||
"expo-image-picker": "~16.0.3",
|
"expo-image-picker": "~16.0.3",
|
||||||
|
|
@ -40,6 +41,7 @@
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "0.76.7",
|
"react-native": "0.76.7",
|
||||||
|
"react-native-bcrypt": "^2.4.0",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"react-native-get-sms-android": "^2.1.0",
|
"react-native-get-sms-android": "^2.1.0",
|
||||||
"react-native-reanimated": "~3.16.1",
|
"react-native-reanimated": "~3.16.1",
|
||||||
|
|
@ -48,6 +50,7 @@
|
||||||
"react-native-svg": "15.8.0",
|
"react-native-svg": "15.8.0",
|
||||||
"react-native-timer-picker": "^2.6.3",
|
"react-native-timer-picker": "^2.6.3",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
|
"react-native-webview": "^13.12.5",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user