import React, { useState, useEffect } from "react"; import { View, Pressable, TextInput, StyleSheet, ActivityIndicator, Platform, } from "react-native"; import { useColorScheme } from "nativewind"; import { Text } from "@/components/ui/text"; import { ArrowLeft, Plus, Calendar, ChevronDown, Trash2, Upload, CalendarSearch, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { FormFlow } from "@/components/FormFlow"; 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 { CustomerPicker } from "@/components/CustomerPicker"; import { getPlaceholderColor } from "@/lib/colors"; import { getScanData } from "@/lib/scan-cache"; type Item = { id: number; description: string; qty: string; price: 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, }: { label: string; value: string; onChangeText: (v: string) => void; placeholder: string; numeric?: boolean; center?: boolean; flex?: number; multiline?: boolean; }) { const c = useInputColors(); return ( {label} ); } function PickerField({ label, value, onPress, }: { label: string; value: string; onPress: () => void; }) { const c = useInputColors(); return ( {label} {value} ); } const STEPS = [ { key: "customer", label: "Customer" }, { key: "details", label: "Details" }, { key: "items", label: "Items" }, { key: "taxes", label: "Taxes & Notes" }, { key: "review", label: "Review" }, ]; export default function CreateInvoiceScreen() { const nav = useSirouRouter(); const [step, setStep] = useState(0); const [submitting, setSubmitting] = useState(false); const [scanFailures, setScanFailures] = useState(0); const [invoiceNumber, setInvoiceNumber] = useState(""); const [customerName, setCustomerName] = useState(""); const [customerEmail, setCustomerEmail] = useState(""); const [customerPhone, setCustomerPhone] = useState(""); const [description, setDescription] = useState(""); const [currency, setCurrency] = useState("ETB "); const [type, setType] = useState("SALES"); const [status, setStatus] = useState("DRAFT"); const [taxAmount, setTaxAmount] = useState("0"); const [discountAmount, setDiscountAmount] = useState("0"); const [notes, setNotes] = useState(""); const [issueDate, setIssueDate] = useState( new Date().toISOString().split("T")[0], ); const [dueDate, setDueDate] = useState(""); const [items, setItems] = useState([ { id: 1, description: "", qty: "1", price: "" }, ]); const [showCurrency, setShowCurrency] = useState(false); const [showType, setShowType] = useState(false); const [showStatus, setShowStatus] = useState(false); const [showIssueDate, setShowIssueDate] = useState(false); const [showDueDate, setShowDueDate] = useState(false); const [scanning, setScanning] = useState(false); const token = useAuthStore((s) => s.token); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const c = useInputColors(); useEffect(() => { const year = new Date().getFullYear(); const random = Math.floor(1000 + Math.random() * 9000); setInvoiceNumber(`INV-${year}-${random}`); const d = new Date(); d.setDate(d.getDate() + 30); setDueDate(d.toISOString().split("T")[0]); const scanData = getScanData(); if (scanData) { if (scanData.invoiceNumber) setInvoiceNumber(scanData.invoiceNumber); const name = scanData.customerName ?.trim() ?.replace(/^(Customer Name:|Bill To:)\s*/i, "") || ""; if (name) setCustomerName(name); if (scanData.customerEmail) setCustomerEmail(scanData.customerEmail); if (scanData.customerPhone) setCustomerPhone(scanData.customerPhone.replace(/^\+251/, "")); if (scanData.description) setDescription(scanData.description); if (scanData.currency) setCurrency(scanData.currency); if (scanData.taxAmount != null) setTaxAmount(String(scanData.taxAmount)); if (scanData.issueDate) { try { setIssueDate( new Date(scanData.issueDate).toISOString().split("T")[0], ); } catch (_) {} } if (scanData.dueDate) { try { setDueDate(new Date(scanData.dueDate).toISOString().split("T")[0]); } catch (_) {} } if (scanData.items && scanData.items.length > 0) { setItems( scanData.items.map((item: any, idx: number) => ({ id: idx + 1, description: item.description || "", qty: String(item.quantity || "1"), price: String(item.unitPrice || item.total || ""), })), ); } } }, []); const handlePickImage = async () => { try { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== "granted") { toast.error( "Permission Denied", "Need gallery access to scan invoices.", ); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, quality: 0.8, }); if (!result.canceled && result.assets?.[0]) { await processImage(result.assets[0].uri); } } catch (e: any) { toast.error("Picker Failed", "Could not launch gallery."); } }; const processImage = async (uri: string) => { setScanning(true); toast.info("Processing...", "Uploading for AI extraction."); try { const formData = new FormData(); const fileExt = uri.split(".").pop() || "jpg"; formData.append("file", { uri: Platform.OS === "android" ? uri : uri.replace("file://", ""), name: `invoice-${Date.now()}.${fileExt}`, type: `image/${fileExt === "jpg" ? "jpeg" : fileExt}`, } as any); const response = await fetch(`${BASE_URL}scan/invoice`, { method: "POST", headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, body: formData, }); if (!response.ok) throw new Error( (await response.json().catch(() => ({}))).message || "Extraction failed", ); const scanResult = await response.json(); if (!scanResult.success) throw new Error(scanResult.message || "Extraction failed"); toast.success("Success!", "Data extracted."); const ocr = scanResult.data || {}; if (ocr.invoiceNumber) setInvoiceNumber(ocr.invoiceNumber); const name = (ocr.customerName?.trim() || "").replace( /^(Customer Name:|Bill To:)\s*/i, "", ); if (name) setCustomerName(name); if (ocr.customerEmail) setCustomerEmail(ocr.customerEmail); if (ocr.customerPhone) setCustomerPhone(ocr.customerPhone.replace(/^\+251/, "")); if (ocr.description) setDescription(ocr.description); if (ocr.currency) setCurrency(ocr.currency); if (ocr.taxAmount != null) setTaxAmount(String(ocr.taxAmount)); if (ocr.issueDate) { try { setIssueDate(new Date(ocr.issueDate).toISOString().split("T")[0]); } catch (_) {} } if (ocr.dueDate) { try { setDueDate(new Date(ocr.dueDate).toISOString().split("T")[0]); } catch (_) {} } if (ocr.items?.length) { setItems( ocr.items.map((item: any, idx: number) => ({ id: idx + 1, description: item.description || "", qty: String(item.quantity || "1"), price: String(item.unitPrice || item.total || ""), })), ); } await handleSubmit(); } catch (err: any) { const failures = scanFailures + 1; setScanFailures(failures); if (failures >= 2) { toast.warning("Scan failed", "Scan failed, fill details below"); } else { toast.warning("Extraction failed", "Extraction failed, try again"); } } finally { setScanning(false); } }; const addItem = () => { const newId = items.length > 0 ? Math.max(...items.map((i) => i.id)) + 1 : 1; setItems([...items, { id: newId, description: "", qty: "1", price: "" }]); }; const removeItem = (id: number) => setItems(items.filter((i) => i.id !== id)); const updateField = (id: number, field: keyof Item, value: string) => setItems(items.map((i) => (i.id === id ? { ...i, [field]: value } : i))); const subtotal = items.reduce( (s, i) => s + (parseFloat(i.qty) || 0) * (parseFloat(i.price) || 0), 0, ); const total = subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0); const handleNext = () => { if (step === 0) { if (!customerName.trim()) { toast.error("Validation", "Customer name is required"); return; } } if (step === 1) { if (!invoiceNumber) { toast.error("Validation", "Invoice number is required"); return; } } setStep(step + 1); }; const handleSubmit = async () => { const validItems = items.filter((i) => i.description.trim()); if (validItems.length === 0) { toast.error( "Validation", "At least one item with description is required", ); return; } setSubmitting(true); try { await api.invoices.create({ body: { invoiceNumber, customerName, customerEmail, customerPhone: customerPhone ? `+251${customerPhone}` : "", amount: Number(total.toFixed(2)), currency, type, status, issueDate: new Date(issueDate).toISOString(), dueDate: new Date(dueDate).toISOString(), description, notes, taxAmount: parseFloat(taxAmount) || 0, discountAmount: parseFloat(discountAmount) || 0, isScanned: false, scannedData: { sellerTIN: "123456", items: [] }, items: validItems.map((item) => ({ description: item.description.trim(), quantity: parseFloat(item.qty) || 0, unitPrice: parseFloat(item.price) || 0, total: Number( ( (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0) ).toFixed(2), ), })), }, }); toast.success("Success", "Invoice created!"); nav.back(); } catch (error: any) { const msg = error?.response?.data?.message || error?.data?.message || error?.message || "Failed to create invoice"; toast.error("Error", msg); throw error; } finally { setSubmitting(false); } }; const currencies = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"]; const invoiceTypes = ["SALES", "PURCHASE", "SERVICE"]; const invoiceStatuses = ["DRAFT", "PENDING", "PAID"]; return ( setStep(step - 1)} onComplete={handleSubmit} loading={submitting} completeLabel="Create Invoice" > {step === 0 && ( {scanning ? ( ) : ( )} Scan from Gallery Upload image to auto-fill form Customer Information Customer Name { setCustomerName(c.name); setCustomerEmail(c.email); setCustomerPhone(c.phone.replace("+251", "")); }} placeholder="Select or search for a customer" /> Phone +251 )} {step === 1 && ( Invoice Details setShowIssueDate(true)} /> setShowDueDate(true)} /> setShowCurrency(true)} /> setShowType(true)} /> setShowStatus(true)} /> )} {step === 2 && ( Billable Items Add {items.map((item, index) => ( Item {index + 1} removeItem(item.id)} hitSlop={8}> updateField(item.id, "description", v)} /> updateField(item.id, "qty", v)} flex={1} /> updateField(item.id, "price", v)} flex={3} /> ))} )} {step === 3 && ( Taxes & Notes )} {step === 4 && ( Summary Customer {customerName} {(customerEmail || customerPhone) && ( Contact {customerEmail || (customerPhone ? `+251${customerPhone}` : "")} )} Invoice # {invoiceNumber} Items {items.filter((i) => i.description.trim()).length} items Subtotal {currency} {subtotal.toLocaleString()} {parseFloat(taxAmount) > 0 && ( Tax {currency} {parseFloat(taxAmount).toFixed(2)} )} {parseFloat(discountAmount) > 0 && ( Discount -{currency} {parseFloat(discountAmount).toFixed(2)} )} Total {currency}{" "} {total.toLocaleString("en-US", { minimumFractionDigits: 2 })} )} setShowCurrency(false)} title="Currency" > {currencies.map((c) => ( { setCurrency(v); setShowCurrency(false); }} /> ))} setShowType(false)} title="Invoice Type" > {invoiceTypes.map((t) => ( { setType(v); setShowType(false); }} /> ))} setShowStatus(false)} title="Status" > {invoiceStatuses.map((s) => ( { setStatus(v); setShowStatus(false); }} /> ))} setShowIssueDate(false)} title="Issue Date" > { setIssueDate(v); setShowIssueDate(false); }} /> setShowDueDate(false)} title="Due Date" > { setDueDate(v); setShowDueDate(false); }} /> ); }