This commit is contained in:
elnatansamuel25 2026-06-17 15:16:40 +03:00
parent e79ad09043
commit b6bc3d2d9c
48 changed files with 7389 additions and 3375 deletions

View File

@ -22,7 +22,10 @@
"com.googleusercontent.apps.1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi" "com.googleusercontent.apps.1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi"
] ]
} }
] ],
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
}
} }
}, },
"android": { "android": {

View File

@ -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"

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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) {

View File

@ -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>
);
}

View File

@ -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>
); );

View File

@ -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
visible={showProformaSheet}
onClose={() => setShowProformaSheet(false)}
loading={sheetLoading}
items={filteredProformas}
search={proformaSearch}
onSearchChange={setProformaSearch}
onCreateNew={goProformaCreate}
onSelectItem={(id: string) => {
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 <Modal
visible={visible} visible={showDeleteModal}
transparent transparent
animationType="slide" animationType="fade"
onRequestClose={onClose} onRequestClose={() => setShowDeleteModal(false)}
> >
<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="flex-1 bg-black/50 items-center justify-center px-8"
style={{ maxHeight: SCREEN_HEIGHT * 0.8 }} onPress={() => setShowDeleteModal(false)}
>
<Pressable
className="bg-card rounded-2xl p-6 w-full border border-border"
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]"> </View>
{label}s <Text className="text-[18px] font-sans-bold text-foreground text-center">
Delete Customer?
</Text> </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 className="gap-3">
<Pressable <Pressable
onPress={onClose} onPress={handleDelete}
className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10" disabled={deleting}
className="bg-red-500 h-12 rounded-[6px] items-center justify-center"
> >
<X {deleting ? (
size={14} <ActivityIndicator color="#ffffff" size="small" />
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>
{/* Create New */}
<View className="px-5 pb-5">
<Pressable
onPress={onCreateNew}
className="bg-primary rounded-[6px] py-3.5 flex-row items-center justify-center gap-2"
>
<Plus size={16} color="white" strokeWidth={2.5} />
<Text className="text-white font-sans-bold text-sm">
Create New {label}
</Text>
</Pressable>
</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) => { <Text className="text-white font-sans-bold text-sm">
const num = type === "proforma" Yes, Delete
? item.proformaNumber </Text>
: item.paymentRequestNumber; )}
const status = (item.status || "DRAFT").toUpperCase(); </Pressable>
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 <Pressable
key={item.id} onPress={() => setShowDeleteModal(false)}
onPress={() => onSelectItem(item.id)} className="bg-secondary h-12 rounded-[6px] items-center justify-center border border-border"
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"> <Text className="text-foreground font-sans-bold text-sm">
{num || item.id?.slice(0, 8) || "—"} Cancel
</Text> </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> </View>
</Pressable> </Pressable>
</Pressable>
</Modal> </Modal>
</ScreenWrapper>
); );
} }

525
app/customers/edit.tsx Normal file
View 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>
);
}

View File

@ -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>
); );
} }

View File

@ -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
View 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>
);
}

View File

@ -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">
<User size={16} color={iconColor} />
<TextInput <TextInput
className="flex-1 ml-3 text-foreground text-base" style={[S.input, { backgroundColor: c.bg, borderColor: c.border, color: c.text }]}
placeholder="Enter first name" placeholder="Enter first name"
placeholderTextColor={isDark ? "#475569" : "#94a3b8"} placeholderTextColor={c.placeholder}
value={firstName} value={firstName}
onChangeText={setFirstName} onChangeText={setFirstName}
autoCorrect={false} 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">
<User size={16} color={iconColor} />
<TextInput <TextInput
className="flex-1 ml-3 text-foreground text-base" style={[S.input, { backgroundColor: c.bg, borderColor: c.border, color: c.text }]}
placeholder="Enter last name" placeholder="Enter last name"
placeholderTextColor={isDark ? "#475569" : "#94a3b8"} placeholderTextColor={c.placeholder}
value={lastName} value={lastName}
onChangeText={setLastName} onChangeText={setLastName}
autoCorrect={false} autoCorrect={false}
/> />
{lastName.trim().length > 0 && (
<Check size={16} color="#10b981" />
)}
</View>
</View> </View>
{user?.email && ( {user?.email && (
@ -120,18 +137,25 @@ 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}
> >
@ -144,16 +168,16 @@ export default function EditProfileScreen() {
)} )}
</Button> </Button>
<Pressable <Button
variant="outline"
className="h-12 rounded-[10px]"
onPress={() => nav.back()} onPress={() => nav.back()}
className="h-12 rounded-[8px] border border-border items-center justify-center"
disabled={loading} disabled={loading}
> >
<Text className="text-muted-foreground font-sans-bold text-sm tracking-widest"> <Text className="text-muted-foreground font-sans-bold text-sm tracking-widest">
Cancel Cancel
</Text> </Text>
</Pressable> </Button>
</View>
</View> </View>
</ScrollView> </ScrollView>
</ScreenWrapper> </ScreenWrapper>

View File

@ -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,18 +459,19 @@ export default function InvoiceDetailScreen() {
</Text> </Text>
</View> </View>
{invoice.notes && (
<View> <View>
<Text className="text-[11px] font-sans-bold tracking-widest text-muted-foreground mb-1.5"> <Text className="text-foreground text-sm font-sans-bold mb-2">
Notes Note
</Text> </Text>
<Text <View className="rounded-[10px] bg-muted p-4">
className="text-foreground text-[14px] font-sans-bold" <Text className="text-foreground font-sans-medium text-[13px] leading-5">
numberOfLines={1} {invoice.notes}
>
{invoice.notes || "-"}
</Text> </Text>
</View> </View>
</View> </View>
)}
</View>
{items.length > 0 ? ( {items.length > 0 ? (
<View className="mt-8"> <View className="mt-8">
@ -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,
);
const unitPrice = Number(
item.unitPrice?.value || item.unitPrice || 0,
);
const lineTotal = Number(
item.total?.value || item.total || qty * unitPrice,
);
return (
<View
key={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"> <WebView
<Package source={{ uri: scannedImageUrl || "" }}
size={20} style={{
color="#94a3b8" width: "100%",
strokeWidth={1.5} height: 360,
backgroundColor: isDark ? "#1F1F1F" : "#ffffff",
}}
originWhitelist={["*"]}
mixedContentMode="always"
scalesPageToFit
onLoadStart={() => setImageLoading(true)}
onLoadEnd={() => setImageLoading(false)}
onError={() => {
setImageLoading(false);
toast.error(
"Image Error",
"Failed to load scanned image.",
);
}}
renderError={() => (
<View className="flex-1 items-center justify-center p-4">
<Camera size={28} color="#94a3b8" />
<Text className="text-muted-foreground text-[12px] font-sans-medium mt-2 text-center">
Failed to load image
</Text>
</View>
)}
/> />
</View> {imageLoading && (
<View className="flex-1"> <View
<Text className="absolute inset-0 items-center justify-center bg-card/40"
className="text-foreground text-[14px] font-sans-bold" pointerEvents="none"
numberOfLines={1}
> >
{item.description || `Item ${idx + 1}`} <ActivityIndicator color="#ea580c" size="large" />
</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> </View>
<Text className="text-foreground text-[14px] font-sans-bold"> )}
{lineTotal.toLocaleString("en-US", { <View className="absolute bottom-3 right-3 h-9 w-9 rounded-full bg-black/60 items-center justify-center flex-row">
minimumFractionDigits: 2, <ArrowUpRight size={16} color="#ffffff" strokeWidth={2.5} />
})}{" "} </View>
{invoice.currency || "USD"} </Pressable>
<Text className="text-muted-foreground text-[11px] font-sans-medium mt-2 text-center">
Tap to view full screen
</Text> </Text>
</View> </View>
);
})}
</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)}

View File

@ -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,8 +203,10 @@ 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;
setScanRecordId(payload.id);
const scanData = payload.data || {};
if (scanData.invoiceNumber) setInvoiceNumber(scanData.invoiceNumber); if (scanData.invoiceNumber) setInvoiceNumber(scanData.invoiceNumber);
const name = const name =
scanData.customerName scanData.customerName
@ -233,7 +240,6 @@ export default function CreateInvoiceScreen() {
})), })),
); );
} }
}
}, []); }, []);
const handlePickImage = async () => { const handlePickImage = async () => {
@ -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,8 +386,7 @@ export default function CreateInvoiceScreen() {
} }
setSubmitting(true); setSubmitting(true);
try { try {
await api.invoices.create({ const body = {
body: {
invoiceNumber, invoiceNumber,
customerName, customerName,
customerEmail, customerEmail,
@ -395,8 +401,10 @@ export default function CreateInvoiceScreen() {
notes, notes,
taxAmount: parseFloat(taxAmount) || 0, taxAmount: parseFloat(taxAmount) || 0,
discountAmount: parseFloat(discountAmount) || 0, discountAmount: parseFloat(discountAmount) || 0,
isScanned: false, isScanned: !!scanRecordId,
scannedData: { sellerTIN: "123456", items: [] }, scannedData: scanRecordId
? undefined
: { sellerTIN: "123456", items: [] },
items: validItems.map((item) => ({ items: validItems.map((item) => ({
description: item.description.trim(), description: item.description.trim(),
quantity: parseFloat(item.qty) || 0, quantity: parseFloat(item.qty) || 0,
@ -407,16 +415,22 @@ export default function CreateInvoiceScreen() {
).toFixed(2), ).toFixed(2),
), ),
})), })),
}, };
});
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!"); 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"
/> />

View File

@ -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"
/> />

View File

@ -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">
<Bell size={20} color="white" strokeWidth={2} />
</View>
{/* Content */}
<View className="flex-1 ml-3 min-w-0">
<Text
className="text-[14px] font-sans-bold text-foreground"
numberOfLines={1}
>
{item.title || "Notification"}
</Text>
{item.body ? (
<Text
className="text-muted-foreground text-[13px] font-sans-medium mt-0.5"
numberOfLines={1}
>
{item.body}
</Text> </Text>
{message ? (
<Text className="text-muted-foreground mt-1 text-sm">{message}</Text>
) : null} ) : null}
</View>
{/* Time + Unread dot */}
<View className="items-end ml-2 flex-shrink-0">
{time ? ( {time ? (
<Text className="text-muted-foreground mt-1 text-xs">{time}</Text> <Text className="text-muted-foreground/60 text-[11px] font-sans-medium">
{time}
</Text>
) : null} ) : null}
</CardContent> <View className="w-2 h-2 rounded-full bg-blue-500 mt-1.5" />
</Card> </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
} }

View File

@ -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,9 +393,112 @@ 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}
>
<Send color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white text-[11px] font-sans-bold uppercase tracking-widest">
Send
</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>
</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 ? ( {sending ? (
<ActivityIndicator color="#ffffff" size="small" /> <ActivityIndicator color="#ffffff" size="small" />
@ -356,18 +506,15 @@ export default function PaymentRequestDetailScreen() {
<> <>
<Send color="#ffffff" size={16} strokeWidth={2.5} /> <Send color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white text-[11px] font-sans-bold uppercase tracking-widest"> <Text className="ml-2 text-white text-[11px] font-sans-bold uppercase tracking-widest">
Send Email Send {sendChannel === "EMAIL" ? "Email" : "SMS"}
</Text> </Text>
</> </>
)} )}
</Button> </Button>
{!data.customerEmail && ( </Pressable>
<Text className="text-[11px] text-muted-foreground font-sans-medium text-center">
No customer email on file
</Text>
)}
</View> </View>
</ScrollView> </Pressable>
</Modal>
</ScreenWrapper> </ScreenWrapper>
); );
} }

View File

@ -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,49 +346,50 @@ 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
label="Invite Channel"
value={channel}
onPress={() => setShowChannel(true)}
/>
<Field <Field
label="Email" label={channel === "EMAIL" ? "Recipient Email" : "Recipient Phone"}
value={customerEmail} value={recipient}
onChangeText={setCustomerEmail} onChangeText={setRecipient}
placeholder="billing@acme.com" placeholder={
flex={1} channel === "EMAIL"
? "email@example.com"
: "912345678"
}
keyboardType={channel === "EMAIL" ? "email-address" : "phone-pad"}
/> />
</View> </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>
</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,242 +471,109 @@ 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">
Accounts 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> </Text>
<Pressable <Pressable
onPress={addAccount} onPress={() => {
className="flex-row items-center gap-1 px-3 py-1.5 rounded-[6px] bg-primary/10 border border-primary/20" 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 }}
> >
<Plus color="#ea580c" size={14} strokeWidth={2.5} /> {loadingPaymentMethods ? (
<Text className="text-primary text-[12px] font-sans-bold"> <ActivityIndicator color="#ea580c" size="small" />
Add ) : (
<>
<Text
className="text-xs font-sans-medium flex-1"
style={{
color: companyPaymentMethodId ? c.text : c.placeholder,
}}
numberOfLines={1}
>
{paymentMethodLabel}
</Text> </Text>
<ChevronDown size={14} color={c.text} strokeWidth={3} />
</>
)}
</Pressable> </Pressable>
</View> </View>
<View className="gap-3">
{accounts.map((acc, index) => (
<View
key={acc.id}
className={`bg-card pb-4 ${index < accounts.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">
Account {index + 1}
</Text>
<Pressable
onPress={() => removeAccount(acc.id)}
hitSlop={8}
>
<Trash2 color="#ef4444" size={16} />
</Pressable>
</View>
<Field
label="Bank Name"
value={acc.bankName}
onChangeText={(v) => updateAccount(acc.id, "bankName", v)}
placeholder="e.g. Yaltopia Bank"
/>
<View className="flex-row gap-4 mt-4">
<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">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Totals
</Text>
<View className="bg-card rounded-[6px] pb-4 gap-4">
<View className="flex-row justify-between items-center">
<Text className="text-foreground font-sans-medium text-sm">
Subtotal
</Text>
<Text className="text-foreground font-sans-bold">
{currency}{" "}
{subtotal.toLocaleString("en-US", {
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",
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
)}
</Text>
</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"> <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">
Summary Summary
</Text> </Text>
<View className="bg-card rounded-[6px] gap-3"> <View className="bg-card rounded-[6px] gap-3">
<View className="flex-row justify-between"> <SummaryRow label="Request Number" value={paymentRequestNumber} />
<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 ? ( {description ? (
<View className="flex-row justify-between"> <SummaryRow label="Description" value={description} multiline />
<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} ) : null}
<View className="flex-row justify-between"> <SummaryRow label="Customer" value={customerName} />
<Text className="text-[14px] text-foreground font-sans-medium"> <SummaryRow label="Channel" value={channel} />
Issue Date <SummaryRow label="Recipient" value={recipient} />
</Text> <View className="border-t border-border/40 my-1" />
<Text className="text-[14px] text-foreground font-sans-bold"> <SummaryRow label="Issue Date" value={issueDate} />
{issueDate} <SummaryRow label="Due Date" value={dueDate || "Not set"} />
</Text> <SummaryRow label="Currency" value={currency} />
</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 && ( {items.length > 0 && (
<View className="border-t border-border/40 pt-3"> <View className="border-t border-border/40 pt-3">
<Text className="text-[12px] text-muted-foreground font-sans-bold uppercase tracking-widest mb-2"> <Text className="text-[12px] text-muted-foreground font-sans-bold uppercase tracking-widest mb-2">
@ -759,45 +591,15 @@ export default function CreatePaymentRequestScreen() {
{item.description || `Item ${i + 1}`} {item.description || `Item ${i + 1}`}
</Text> </Text>
<Text className="text-[13px] text-foreground font-sans-bold"> <Text className="text-[13px] text-foreground font-sans-bold">
{item.qty} × {currency}{" "} {item.quantity} × {currency}{" "}
{parseFloat(item.price || "0").toFixed(2)} {parseFloat(item.unitPrice || "0").toFixed(2)}
</Text> </Text>
</View> </View>
))} ))}
</View> </View>
)} )}
{notes ? ( {paymentMethodLabel !== "Select" && (
<View className="flex-row justify-between"> <SummaryRow label="Payment Method" value={paymentMethodLabel} />
<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="border-t border-border/40 my-1" />
<View className="flex-row justify-between"> <View className="flex-row justify-between">
@ -806,7 +608,7 @@ export default function CreatePaymentRequestScreen() {
</Text> </Text>
<Text className="text-[16px] font-sans-bold text-primary"> <Text className="text-[16px] font-sans-bold text-primary">
{currency}{" "} {currency}{" "}
{(amount ? Number(amount) : computedTotal).toLocaleString( {(amount ? Number(amount) : subtotal).toLocaleString(
"en-US", "en-US",
{ minimumFractionDigits: 2, maximumFractionDigits: 2 }, { minimumFractionDigits: 2, maximumFractionDigits: 2 },
)} )}
@ -814,7 +616,6 @@ export default function CreatePaymentRequestScreen() {
</View> </View>
</View> </View>
</View> </View>
</>
)} )}
</FormFlow> </FormFlow>
@ -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={s} key={ch}
label={s} label={ch}
value={s} value={ch}
selected={status === s} selected={channel === ch}
onSelect={(v) => { onSelect={(v) => {
setStatus(v); setChannel(v);
setShowStatus(false); 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>
<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>
);
}

View 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

View File

@ -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 } : {}),
}; };
if (scanRecordId) {
await api.payments.update({
params: { id: scanRecordId },
body: payload,
});
toast.success("Success", "Payment updated successfully!");
} else {
await api.payments.create({ body: payload }); await api.payments.create({ body: payload });
toast.success("Success", "Payment created successfully!"); 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", ""));
}}
placeholder="Select or search for a customer"
/> />
</View> </View>
<View className="flex-row gap-4">
<Field <View className="bg-card rounded-[6px]">
label="Email" <View className="flex-row items-center justify-between p-1">
value={customerEmail} <View className="flex-1">
onChangeText={setCustomerEmail} <Text className="text-[14px] font-sans-bold text-foreground">
placeholder="billing@acme.com" Reference verified
flex={1} </Text>
/> <Text className="text-[12px] text-muted-foreground mt-0.5 font-sans-medium">
</View> Transaction reference has been confirmed
<View>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
Phone
</Text> </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"

View File

@ -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">

View File

@ -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} />}

View 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>
);
}

View 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>
);
}

View File

@ -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,75 +53,88 @@ 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 { try {
if (!isRefresh) {
pageNum === 1 ? setLoading(true) : setLoadingMore(true); pageNum === 1 ? setLoading(true) : setLoadingMore(true);
}
const response = await api.proforma.getAll({ const response = await api.proforma.getAll({
query: { page: pageNum, limit: 10 }, query: { page: pageNum, limit: 10 },
}); });
const newProformas = response.data;
let newProformas = response.data;
const newData = newProformas;
if (isRefresh) {
setProformas(newData);
} else {
setProformas((prev) => setProformas((prev) =>
pageNum === 1 ? newData : [...prev, ...newData], pageNum === 1 ? newProformas : [...prev, ...newProformas],
); );
}
setHasMore(response.meta.hasNextPage); setHasMore(response.meta.hasNextPage);
setPage(pageNum); setPage(pageNum);
} catch (err: any) { } catch (err: any) {
@ -117,94 +142,52 @@ export default function ProformaScreen() {
setHasMore(false); setHasMore(false);
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false);
setLoadingMore(false); setLoadingMore(false);
} }
}, }, []);
[],
const fetchRequests = useCallback(async (pageNum: number) => {
const { isAuthenticated } = useAuthStore.getState();
if (!isAuthenticated) return;
try {
pageNum === 1 ? setRequestsLoading(true) : setReqLoadingMore(true);
const response = await api.proformaRequests.getAll({
query: { page: pageNum, limit: 10 },
});
const newRequests = response.data;
setRequests((prev) =>
pageNum === 1 ? newRequests : [...prev, ...newRequests],
); );
setReqHasMore(response.meta.hasNextPage);
setReqPage(pageNum);
} catch (err: any) {
console.error("[ProformaRequests] Fetch error:", err);
toast.error("Error", "Failed to fetch proforma requests.");
} finally {
setRequestsLoading(false);
setReqLoadingMore(false);
}
}, []);
useEffect(() => { useFocusEffect(
fetchProformas(1); useCallback(() => {
}, [fetchProformas]); if (tab === "proforma") fetchProformas(1);
else fetchRequests(1);
const onRefresh = () => { }, [tab, fetchProformas, fetchRequests]),
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 ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<FlatList <StandardHeader
data={filteredProformas} title="Proforma"
renderItem={renderProformaItem} showBack
keyExtractor={(item) => item.id} showSearch
onSearchPress={() => setSearchOpen(true)}
/>
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#E46212" />
</View>
</ScreenWrapper>
);
}
const items = tab === "proforma" ? filteredProformas : filteredRequests;
return (
<ScreenWrapper className="bg-background">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 150 }} 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" /> }}
scrollEventThrottle={400}
>
<StandardHeader
title="Proforma"
showBack
showSearch
onSearchPress={() => setSearchOpen(true)}
/>
<View className="px-[16px] pt-6"> <View className="px-[16px] pt-6">
<View className="flex-row items-center bg-card border border-border rounded-xl px-3 h-11 mb-4"> <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} /> <Search size={16} color="#94a3b8" strokeWidth={2} />
<TextInput <TextInput
className="flex-1 ml-2 text-foreground text-sm" className="flex-1 ml-2 text-foreground text-sm"
placeholder="Search by name, number, or amount..." placeholder={
placeholderTextColor="#94a3b8" tab === "proforma"
? "Search by name, number, or amount..."
: "Search by title, description, status..."
}
placeholderTextColor={getPlaceholderColor(isDark)}
value={search} value={search}
onChangeText={setSearch} onChangeText={setSearch}
autoCapitalize="none" autoCapitalize="none"
/> />
</View> </View>
<Button <Button
className="mb-4 h-10 rounded-[10px] bg-primary" className="mb-4 h-10 rounded-lg bg-primary"
onPress={() => nav.go("proforma/create")} onPress={() =>
tab === "proforma"
? nav.go("proforma/create")
: nav.go("proforma-requests/create")
}
> >
<Plus color="white" size={20} strokeWidth={3} /> <Plus color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="text-white text-xs font-sans-semibold uppercase tracking-widest ml-2"> <Text className="text-white text-[11px] font-sans-bold uppercase tracking-widest ml-2">
Create New Proforma {tab === "proforma" ? "Create New Proforma" : "Create Request"}
</Text> </Text>
</Button> </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>
</>
} <View className="gap-2">
ListFooterComponent={ {items.length > 0 ? (
loadingMore ? ( tab === "proforma"
<ActivityIndicator color="#ea580c" className="py-4" /> ? (items as ProformaItem[]).map(renderProformaCard)
) : null : (items as ProformaRequest[]).map(renderRequestCard)
} ) : (
ListEmptyComponent={
!loading ? (
<EmptyState <EmptyState
title="No proformas yet" title={
description="Create your first proforma to get started with invoicing." 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 centered
/> />
) : ( )}
<View className="py-20">
<ActivityIndicator size="large" color="#ea580c" />
</View> </View>
)
} {(loadingMore || reqLoadingMore) && (
<View className="py-4">
<ActivityIndicator color="#E46212" />
</View>
)}
</View>
</ScrollView>
<CommandPalette
visible={searchOpen}
onClose={() => setSearchOpen(false)}
/> />
</ScreenWrapper> </ScreenWrapper>
); );

View File

@ -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.", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try { try {
setLoading(true); setSharing(true);
const pid = Array.isArray(id) ? id[0] : id;
await api.proforma.shareLink({ body: { proformaId: pid, channel } });
toast.success(
"Sent",
`Proforma shared via ${channel === "email" ? "email" : "SMS"}`,
);
setShowSendSheet(false);
} catch (err: any) {
toast.error("Error", err?.message || "Failed to share proforma");
} finally {
setSharing(false);
}
};
const handleDelete = () => setShowDeleteModal(true);
const confirmDelete = async () => {
try {
setDeleting(true);
const pid = Array.isArray(id) ? id[0] : id; const pid = Array.isArray(id) ? id[0] : id;
await api.proforma.delete({ params: { id: pid } }); await api.proforma.delete({ params: { id: pid } });
toast.success("Success", "Proforma deleted"); toast.success("Deleted", "Proforma has been removed.");
setShowDeleteModal(false);
nav.back(); nav.back();
} catch { } catch (err: any) {
toast.error("Error", "Failed to delete proforma"); toast.error("Error", err?.message || "Failed to delete proforma");
setLoading(false); } 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 >
</Text> <Image
<View className="flex-row items-baseline gap-1.5"> source={ticketImage}
<Text className="text-3xl font-sans-black text-foreground tracking-tight"> style={{ width: 150, height: 150 }}
{amount.toLocaleString("en-US", { minimumFractionDigits: 2 })} resizeMode="contain"
</Text> />
<Text className="text-base font-sans-bold text-primary"> </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} {currency}
</Text> </Text>
</View> </Text>
</View> <Text className="text-muted-foreground text-[11px] font-sans-medium mt-1">
<View className={`px-2.5 py-1 rounded-[4px] ${theme.bg}`}> {proforma.proformaNumber
? `Proforma ${proforma.proformaNumber}`
: `Proforma #${(proforma.id || "").slice(0, 8).toUpperCase()}`}
</Text>
{/* Status badge */}
<View className={`px-2.5 py-1 rounded-[4px] mt-4 ${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">
{proforma.issueDate ? new Date(proforma.issueDate).toLocaleDateString() : "—"}
</Text> </Text>
</View> </View>
</View> <View className="flex-row justify-between items-center">
<View className="w-px bg-border" /> <Text className="text-muted-foreground text-[12px] font-sans-medium">
<View className="flex-1">
<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> </Text>
</View> </View>
</View> </View>
</View> </View>
</View> </View>
{/* Tabs */}
<View className="px-5 pt-12">
<View className="flex-row gap-6 border-b border-border">
<Pressable
onPress={() => setActiveTab("details")}
className="pb-2.5"
>
<Text
className={`text-[14px] font-sans-bold ${
activeTab === "details"
? "text-foreground"
: "text-muted-foreground"
}`}
>
Details
</Text>
{activeTab === "details" && (
<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> </View>
{/* Tab content */}
{activeTab === "details" ? (
<View className="px-5 pt-5 gap-6">
{/* Customer */} {/* Customer */}
{proforma.customerName && ( {proforma.customerName && (
<View className="px-5 mb-6"> <View>
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2"> <Text className="font-sans-bold text-xs uppercase tracking-widest text-muted-foreground mb-3">
Customer Customer
</Text> </Text>
<View className="bg-card rounded-[6px] border border-border p-4"> <View className="flex-row items-center gap-3">
<View className="flex-row items-center gap-3 mb-2"> <View className="h-10 w-10 rounded-full bg-primary/10 items-center justify-center">
<View className="h-9 w-9 rounded-full bg-primary/10 items-center justify-center"> <User color="#E46212" size={18} strokeWidth={2} />
<User color="#E46212" size={17} strokeWidth={2} />
</View> </View>
<View className="flex-1"> <View className="flex-1">
<Text className="text-foreground font-sans-bold text-base"> <Text className="text-foreground font-sans-bold text-sm">
{proforma.customerName} {customerName}
</Text> </Text>
{(proforma.customerEmail || proforma.customerPhone) && ( {(proforma.customerEmail || proforma.customerPhone) && (
<Text className="text-muted-foreground text-xs font-sans-medium mt-0.5"> <Text className="text-muted-foreground text-[12px] font-sans-medium mt-0.5">
{[proforma.customerEmail, proforma.customerPhone].filter(Boolean).join(" · ")} {proforma.customerEmail || proforma.customerPhone}
</Text> </Text>
)} )}
</View> </View>
</View> </View>
<View className="flex-row items-center gap-3 pt-2.5 border-t border-border">
<Hash size={12} color="#94a3b8" strokeWidth={2} />
<Text className="text-muted-foreground text-xs font-sans-medium">
#{proforma.id?.slice(0, 8) || "—"}
</Text>
</View>
</View>
</View> </View>
)} )}
{/* Items */} {/* Proforma Details */}
{items.length > 0 && ( <View>
<View className="px-5 mb-6"> <Text className="font-sans-bold text-xs uppercase tracking-widest text-muted-foreground mb-3">
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2"> Proforma Details
Items ({items.length})
</Text> </Text>
<View className="bg-card rounded-[6px] border border-border overflow-hidden"> <View className="gap-4">
{items.map((item: any, idx: number) => ( <View className="flex-row items-center gap-3">
<View <Hash size={15} color="#64748b" />
key={item.id || idx} <View className="flex-1">
className={`px-4 py-3 ${idx < items.length - 1 ? "border-b border-border" : ""}`} <Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
> Proforma Number
<View className="flex-row justify-between items-start mb-0.5">
<Text
className="text-foreground font-sans-bold text-sm flex-1 mr-3"
numberOfLines={2}
>
{item.description || `Item ${idx + 1}`}
</Text> </Text>
<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">
{safeVal(item.quantity)} × {fmt(safeVal(item.unitPrice), currency)}
</Text>
</View> </View>
))}
</View>
</View>
)}
{items.length === 0 && ( <View className="flex-row items-center gap-3">
<View className="px-5 mb-6"> <Calendar size={15} color="#64748b" />
<View className="bg-card rounded-[6px] border border-border p-8 items-center"> <View className="flex-1">
<Package size={32} color="#cbd5e1" className="mb-2" /> <Text className="text-[10px] uppercase tracking-wider text-muted-foreground font-sans-semibold">
<Text className="text-muted-foreground text-sm font-sans-medium">No items</Text> 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>
)} )}
</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>
</View>
</View>
)}
</View>
) : (
<View className="px-5 pt-5">
{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 */} {/* Summary */}
<View className="px-5 mb-6"> <View className="mt-6 gap-2.5">
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2"> <View className="flex-row justify-between items-center">
Summary <Text className="text-muted-foreground text-[14px] font-sans-medium">
</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 Subtotal
</Text> </Text>
<Text className="text-foreground text-sm font-sans-bold"> <Text className="text-foreground text-[14px] font-sans-bold">
{fmt(subtotal, currency)} {fmt(subtotal, currency)}
</Text> </Text>
</View> </View>
{tax > 0 && ( {tax > 0 && (
<View className="flex-row justify-between"> <View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-sm font-sans-medium">Tax</Text> <Text className="text-muted-foreground text-[14px] font-sans-medium">
<Text className="text-foreground text-sm font-sans-bold">+{fmt(tax, currency)}</Text> Tax
</Text>
<Text className="text-foreground text-[14px] font-sans-bold">
+{fmt(tax, currency)}
</Text>
</View> </View>
)} )}
{discount > 0 && ( {discount > 0 && (
<View className="flex-row justify-between"> <View className="flex-row justify-between items-center">
<Text className="text-muted-foreground text-sm font-sans-medium">Discount</Text> <Text className="text-muted-foreground text-[14px] font-sans-medium">
<Text className="text-foreground text-sm font-sans-bold">-{fmt(discount, currency)}</Text> Discount
</Text>
<Text className="text-foreground text-[14px] font-sans-bold">
-{fmt(discount, currency)}
</Text>
</View> </View>
)} )}
<View className="border-t border-border/60 pt-2.5 flex-row justify-between"> <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-foreground font-sans-black text-base">
Total
</Text>
<Text className="text-primary font-sans-black text-base"> <Text className="text-primary font-sans-black text-base">
{fmt(amount, currency)} {fmt(amount, currency)}
</Text> </Text>
</View> </View>
</View> </View>
</View> </View>
) : (
<EmptyState
title="No items yet"
description="This proforma doesn't have any items yet."
/>
)}
</View>
)}
</ScrollView>
{/* Description */} {/* Sticky bottom bar — Send + Download PDF (like invoice detail) */}
{proforma.description ? ( <View
<View className="px-5 mb-6"> className="flex-row gap-3 px-5 py-3 border-t border-border"
<Text className="text-[10px] font-sans-bold uppercase tracking-wider text-muted-foreground mb-2"> style={{ backgroundColor: isDark ? "#0a0505" : "#ffffff" }}
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} /> <Pressable
<Text className="text-white font-sans-bold text-[11px] uppercase tracking-widest"> onPress={() => setShowSendSheet(true)}
Actions 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> </Text>
</Pressable> </Pressable>
</View> </View>
</ScrollView>
{/* Actions Bottom Sheet */} {/* 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={() => setShowMoreSheet(false)}
> >
<Pressable className="flex-1 bg-black/40" onPress={() => setShowActions(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>
);
}

View File

@ -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"
/> />

View File

@ -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"
/> />

View File

@ -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
View 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>
);
}

View File

@ -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."
/> />
)} )}

View File

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 229 KiB

BIN
assets/ticket.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@ -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} /> },

View 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>
);
}

View 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>
);
}

View File

@ -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>
); );
}) })

View File

@ -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,58 +61,49 @@ 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]}
> >
<View style={styles.wrapper}>
<Animated.View <Animated.View
style={[ style={[
styles.toast, styles.toast,
{ {
top: insets.top + 12,
backgroundColor: isDark ? "#1C1C1C" : "#ffffff", backgroundColor: isDark ? "#1C1C1C" : "#ffffff",
borderColor: config.borderColor, borderColor: isDark ? "#2A2A2A" : "#E5E5E5",
borderWidth: 1, transform: [{ translateY }],
transform: [{ translateX }],
opacity, opacity,
}, },
]} ]}
> >
<View <Icon size={22} color={config.iconColor} strokeWidth={1.5} />
style={[
styles.iconContainer,
{ backgroundColor: isDark ? "#2a2a2a" : "#f5f5f5" },
]}
>
{config.icon}
</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> </Text>
{message ? (
<Text className="text-muted-foreground text-[12px] font-sans-medium leading-[16px] mt-1">
{message}
</Text>
) : null}
</View>
<View className="h-6 w-6 rounded-full items-center justify-center">
<X size={14} color="#9ca3af" strokeWidth={2.5} />
</View> </View>
</Animated.View> </Animated.View>
</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,
}, },
}); });

View File

@ -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>
);
}

View File

@ -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" },
}, },
}, },
}, },

View File

@ -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";

View File

@ -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";
}

View File

@ -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" },

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"