855 lines
26 KiB
TypeScript
855 lines
26 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import {
|
|
View,
|
|
ScrollView,
|
|
Pressable,
|
|
TextInput,
|
|
StyleSheet,
|
|
ActivityIndicator,
|
|
useColorScheme,
|
|
Platform,
|
|
} from "react-native";
|
|
import { Text } from "@/components/ui/text";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
ArrowLeft,
|
|
Plus,
|
|
Calendar,
|
|
ChevronDown,
|
|
FileText,
|
|
Trash2,
|
|
DollarSign,
|
|
Send,
|
|
CalendarSearch,
|
|
Upload,
|
|
} from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { useSirouRouter } from "@sirou/react-native";
|
|
import { AppRoutes } from "@/lib/routes";
|
|
import { Stack } from "expo-router";
|
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
|
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 { StandardHeader } from "@/components/StandardHeader";
|
|
import { getPlaceholderColor } from "@/lib/colors";
|
|
|
|
type Item = { id: number; description: string; qty: string; price: string };
|
|
|
|
const S = StyleSheet.create({
|
|
input: {
|
|
height: 44,
|
|
paddingHorizontal: 12,
|
|
fontSize: 12,
|
|
fontWeight: "500",
|
|
borderRadius: 6,
|
|
borderWidth: 1,
|
|
},
|
|
inputCenter: {
|
|
height: 44,
|
|
paddingHorizontal: 12,
|
|
fontSize: 14,
|
|
fontWeight: "500",
|
|
borderRadius: 6,
|
|
borderWidth: 1,
|
|
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
|
|
variant="small"
|
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
|
|
>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
export default function CreateInvoiceScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
// Form Fields
|
|
const [invoiceNumber, setInvoiceNumber] = useState("");
|
|
const [customerName, setCustomerName] = useState("");
|
|
const [customerEmail, setCustomerEmail] = useState("");
|
|
const [customerPhone, setCustomerPhone] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [currency, setCurrency] = useState("USD");
|
|
const [type, setType] = useState("SALES");
|
|
const [status, setStatus] = useState("DRAFT");
|
|
const [taxAmount, setTaxAmount] = useState("0");
|
|
const [discountAmount, setDiscountAmount] = useState("0");
|
|
const [notes, setNotes] = useState("");
|
|
|
|
// Dates
|
|
const [issueDate, setIssueDate] = useState(
|
|
new Date().toISOString().split("T")[0],
|
|
);
|
|
const [dueDate, setDueDate] = useState("");
|
|
|
|
// Items List
|
|
const [items, setItems] = useState<Item[]>([
|
|
{ id: 1, description: "", qty: "1", price: "" },
|
|
]);
|
|
|
|
// Modal states
|
|
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 handlePickImage = async () => {
|
|
try {
|
|
const { status } =
|
|
await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (status !== "granted") {
|
|
toast.error(
|
|
"Permission Denied",
|
|
"We need access to your gallery to upload invoices.",
|
|
);
|
|
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("[CreateInvoice] Pick Image Error:", e);
|
|
toast.error("Picker Failed", "Could not launch gallery picker.");
|
|
}
|
|
};
|
|
|
|
const handleProcessImage = async (uri: string) => {
|
|
setScanning(true);
|
|
toast.info("Processing...", "Uploading invoice to AI extraction engine.");
|
|
try {
|
|
const formData = new FormData();
|
|
const fileExt = uri.split(".").pop() || "jpg";
|
|
const fileName = `invoice-${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/invoice`, {
|
|
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();
|
|
console.log("[CreateInvoice] Extracted scan result:", scanResult);
|
|
|
|
if (!scanResult.success) {
|
|
throw new Error(
|
|
scanResult.message || "AI extraction was unsuccessful.",
|
|
);
|
|
}
|
|
|
|
toast.success("Success!", "Data extracted successfully.");
|
|
|
|
const ocr = scanResult.data || {};
|
|
if (ocr.invoiceNumber) setInvoiceNumber(ocr.invoiceNumber);
|
|
|
|
let name = ocr.customerName?.trim() || "";
|
|
name = name
|
|
.replace(/^Customer Name:\s*/i, "")
|
|
.replace(/^Bill To:\s*/i, "");
|
|
if (name) setCustomerName(name);
|
|
|
|
if (ocr.customerEmail) setCustomerEmail(ocr.customerEmail);
|
|
if (ocr.customerPhone) setCustomerPhone(ocr.customerPhone);
|
|
if (ocr.description) setDescription(ocr.description);
|
|
if (ocr.currency) setCurrency(ocr.currency);
|
|
if (ocr.taxAmount != null) setTaxAmount(String(ocr.taxAmount));
|
|
|
|
if (ocr.issueDate) {
|
|
try {
|
|
const formattedIssue = new Date(ocr.issueDate)
|
|
.toISOString()
|
|
.split("T")[0];
|
|
setIssueDate(formattedIssue);
|
|
} catch (de) {
|
|
console.warn("[CreateInvoice] Issue Date parse error:", de);
|
|
}
|
|
}
|
|
if (ocr.dueDate) {
|
|
try {
|
|
const formattedDue = new Date(ocr.dueDate)
|
|
.toISOString()
|
|
.split("T")[0];
|
|
setDueDate(formattedDue);
|
|
} catch (de) {
|
|
console.warn("[CreateInvoice] Due Date parse error:", de);
|
|
}
|
|
}
|
|
|
|
if (ocr.items && ocr.items.length > 0) {
|
|
setItems(
|
|
ocr.items.map((item: any, idx: number) => ({
|
|
id: idx + 1,
|
|
description: item.description || "Web Development Service",
|
|
qty: String(item.quantity || "1"),
|
|
price: String(item.unitPrice || item.total || "0"),
|
|
})),
|
|
);
|
|
}
|
|
} catch (err: any) {
|
|
console.error("[CreateInvoice] Extraction Error:", err);
|
|
toast.error(
|
|
"Extraction Failed",
|
|
err.message || "AI was unable to extract invoice data.",
|
|
);
|
|
} finally {
|
|
setScanning(false);
|
|
}
|
|
};
|
|
|
|
const colorScheme = useColorScheme();
|
|
const isDark = colorScheme === "dark";
|
|
const c = useInputColors();
|
|
|
|
// Auto-generate invoice number and set default due date on mount
|
|
useEffect(() => {
|
|
const year = new Date().getFullYear();
|
|
const random = Math.floor(1000 + Math.random() * 9000);
|
|
setInvoiceNumber(`INV-${year}-${random}`);
|
|
|
|
// Default Due Date: 30 days from now
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + 30);
|
|
setDueDate(d.toISOString().split("T")[0]);
|
|
}, []);
|
|
|
|
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) => {
|
|
if (items.length > 1) {
|
|
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(
|
|
(sum, item) =>
|
|
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
|
|
0,
|
|
);
|
|
|
|
const total =
|
|
subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!invoiceNumber) {
|
|
toast.error("Validation Error", "Invoice Number is required");
|
|
return;
|
|
}
|
|
if (!customerName) {
|
|
toast.error("Validation Error", "Customer Name is required");
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const payload = {
|
|
invoiceNumber,
|
|
customerName,
|
|
customerEmail,
|
|
customerPhone,
|
|
amount: Number(total.toFixed(2)),
|
|
currency,
|
|
type,
|
|
status,
|
|
issueDate: new Date(issueDate).toISOString(),
|
|
dueDate: new Date(dueDate).toISOString(),
|
|
description: description || `Invoice for ${customerName}`,
|
|
notes,
|
|
taxAmount: parseFloat(taxAmount) || 0,
|
|
discountAmount: parseFloat(discountAmount) || 0,
|
|
isScanned: false,
|
|
scannedData: {
|
|
sellerTIN: "123456",
|
|
items: [],
|
|
},
|
|
items: items.map((item) => ({
|
|
description: item.description || "Web Development Service",
|
|
quantity: parseFloat(item.qty) || 0,
|
|
unitPrice: parseFloat(item.price) || 0,
|
|
total: Number(
|
|
(
|
|
(parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0)
|
|
).toFixed(2),
|
|
),
|
|
})),
|
|
};
|
|
|
|
await api.invoices.create({ body: payload });
|
|
toast.success("Success", "Invoice created successfully!");
|
|
nav.back();
|
|
} catch (error: any) {
|
|
console.error("[CreateInvoice] Error:", error);
|
|
toast.error("Error", error.message || "Failed to create invoice");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const currencies = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
|
|
const invoiceTypes = ["SALES", "PURCHASE", "SERVICE"];
|
|
const invoiceStatuses = ["DRAFT", "PENDING", "PAID"];
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<Stack.Screen options={{ headerShown: false }} />
|
|
<StandardHeader title="Add Invoice" showBack />
|
|
|
|
<ScrollView
|
|
className="flex-1"
|
|
contentContainerStyle={{ padding: 16, paddingBottom: 50 }}
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
{/* Gallery Scanner */}
|
|
<Pressable
|
|
onPress={handlePickImage}
|
|
disabled={scanning}
|
|
className="bg-primary/10 mb-5 border border-primary/20 rounded-[8px] p-4 flex-row items-center gap-3.5"
|
|
>
|
|
{scanning ? (
|
|
<ActivityIndicator color="#ea580c" size="small" />
|
|
) : (
|
|
<Upload color="#ea580c" size={20} strokeWidth={2.5} />
|
|
)}
|
|
<View className="flex-1">
|
|
<Text className="text-primary font-black text-xs uppercase tracking-widest">
|
|
{scanning ? "Extracting Data..." : "Scan From Gallery"}
|
|
</Text>
|
|
<Text className="text-muted-foreground text-[9px] font-bold mt-0.5 uppercase tracking-wider">
|
|
Upload invoice image to automatically prefill form
|
|
</Text>
|
|
</View>
|
|
</Pressable>
|
|
|
|
{/* General Info */}
|
|
<Label>General Information</Label>
|
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
|
<Field
|
|
label="Invoice Number"
|
|
value={invoiceNumber}
|
|
onChangeText={setInvoiceNumber}
|
|
placeholder="e.g. INV-2024-001"
|
|
/>
|
|
<Field
|
|
label="Project Description"
|
|
value={description}
|
|
onChangeText={setDescription}
|
|
placeholder="e.g. Web Development Services"
|
|
/>
|
|
</View>
|
|
|
|
{/* Customer Details */}
|
|
<Label>Customer Details</Label>
|
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
|
<Field
|
|
label="Customer Name"
|
|
value={customerName}
|
|
onChangeText={setCustomerName}
|
|
placeholder="e.g. Acme Corporation"
|
|
/>
|
|
<View className="flex-row gap-4">
|
|
<Field
|
|
label="Email"
|
|
value={customerEmail}
|
|
onChangeText={setCustomerEmail}
|
|
placeholder="billing@acme.com"
|
|
flex={1}
|
|
/>
|
|
<Field
|
|
label="Phone"
|
|
value={customerPhone}
|
|
onChangeText={setCustomerPhone}
|
|
placeholder="+1234567890"
|
|
flex={1}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Schedule & Configuration */}
|
|
<Label>Schedule & Configuration</Label>
|
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
|
<View className="flex-row gap-4">
|
|
<View className="flex-1">
|
|
<Text
|
|
variant="small"
|
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
|
|
>
|
|
Issue Date
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => setShowIssueDate(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-medium" style={{ color: c.text }}>
|
|
{issueDate}
|
|
</Text>
|
|
<CalendarSearch size={14} color="#ea580c" strokeWidth={2.5} />
|
|
</Pressable>
|
|
</View>
|
|
<View className="flex-1">
|
|
<Text
|
|
variant="small"
|
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
|
|
>
|
|
Due Date
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => setShowDueDate(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-medium" style={{ color: c.text }}>
|
|
{dueDate || "Select Date"}
|
|
</Text>
|
|
<Calendar size={14} color="#ea580c" strokeWidth={2.5} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="flex-row gap-4">
|
|
<View className="flex-1">
|
|
<Text
|
|
variant="small"
|
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
|
|
>
|
|
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-bold" style={{ color: c.text }}>
|
|
{currency}
|
|
</Text>
|
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View className="flex-1">
|
|
<Text
|
|
variant="small"
|
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
|
|
>
|
|
Type
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => setShowType(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-bold" style={{ color: c.text }}>
|
|
{type}
|
|
</Text>
|
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
<View>
|
|
<Text
|
|
variant="small"
|
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
|
|
>
|
|
Status
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => setShowStatus(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-bold" style={{ color: c.text }}>
|
|
{status}
|
|
</Text>
|
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
{/* Billable Items */}
|
|
<View className="flex-row items-center justify-between mb-3">
|
|
<Label noMargin>Billable Items</Label>
|
|
<Pressable
|
|
onPress={addItem}
|
|
className="flex-row items-center gap-1 px-3 py-1 rounded-[6px] bg-primary/10 border border-primary/20"
|
|
>
|
|
<Plus color="#ea580c" size={10} strokeWidth={2.5} />
|
|
<Text className="text-primary text-[8px] font-bold uppercase tracking-widest">
|
|
Add Item
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View className="gap-3 mb-5">
|
|
{items.map((item, index) => (
|
|
<View className="bg-card rounded-[6px] p-4">
|
|
<View className="flex-row justify-between items-center mb-3">
|
|
<Text
|
|
variant="muted"
|
|
className="text-[10px] font-bold uppercase tracking-wide opacity-50"
|
|
>
|
|
Item {index + 1}
|
|
</Text>
|
|
{items.length > 1 && (
|
|
<Pressable onPress={() => removeItem(item.id)} hitSlop={8}>
|
|
<Trash2 color="#ef4444" size={13} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
|
|
<Field
|
|
label="Description"
|
|
placeholder="e.g. UI Design"
|
|
value={item.description}
|
|
onChangeText={(v) => updateField(item.id, "description", v)}
|
|
/>
|
|
|
|
<View className="flex-row gap-3 mt-4">
|
|
<Field
|
|
label="Qty"
|
|
placeholder="1"
|
|
numeric
|
|
center
|
|
value={item.qty}
|
|
onChangeText={(v) => updateField(item.id, "qty", v)}
|
|
flex={1}
|
|
/>
|
|
<Field
|
|
label="Price"
|
|
placeholder="0.00"
|
|
numeric
|
|
value={item.price}
|
|
onChangeText={(v) => updateField(item.id, "price", v)}
|
|
flex={2}
|
|
/>
|
|
<View className="flex-1 items-end justify-end pb-1">
|
|
<Text
|
|
variant="muted"
|
|
className="text-[9px] uppercase font-bold opacity-40"
|
|
>
|
|
Total
|
|
</Text>
|
|
<Text
|
|
variant="p"
|
|
className="text-foreground font-bold text-sm"
|
|
>
|
|
{currency}
|
|
{(
|
|
(parseFloat(item.qty) || 0) *
|
|
(parseFloat(item.price) || 0)
|
|
).toFixed(2)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
{/* Totals & Taxes */}
|
|
<Label>Totals & Taxes</Label>
|
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-3">
|
|
<View className="flex-row justify-between items-center">
|
|
<Text variant="muted" className="text-xs font-medium">
|
|
Subtotal
|
|
</Text>
|
|
<Text variant="p" className="text-foreground font-bold">
|
|
{currency} {subtotal.toLocaleString()}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row gap-4">
|
|
<Field
|
|
label="Tax"
|
|
value={taxAmount}
|
|
onChangeText={setTaxAmount}
|
|
placeholder="0"
|
|
numeric
|
|
flex={1}
|
|
/>
|
|
<Field
|
|
label="Discount"
|
|
value={discountAmount}
|
|
onChangeText={setDiscountAmount}
|
|
placeholder="0"
|
|
numeric
|
|
flex={1}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Notes */}
|
|
<Label>Notes</Label>
|
|
|
|
<View className="bg-card rounded-[6px] p-4 mb-6">
|
|
<TextInput
|
|
style={[
|
|
S.input,
|
|
{
|
|
backgroundColor: c.bg,
|
|
borderColor: c.border,
|
|
color: c.text,
|
|
height: 80,
|
|
textAlignVertical: "top",
|
|
paddingTop: 10,
|
|
},
|
|
]}
|
|
placeholder="e.g. Payment due within 30 days"
|
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
|
value={notes}
|
|
onChangeText={setNotes}
|
|
multiline
|
|
/>
|
|
</View>
|
|
|
|
{/* Footer */}
|
|
<View className="border border-border/60 rounded-[12px] p-5">
|
|
<View className="flex-row justify-between items-center mb-5">
|
|
<Text
|
|
variant="muted"
|
|
className="font-bold text-xs uppercase tracking-widest opacity-60"
|
|
>
|
|
Total Amount
|
|
</Text>
|
|
<Text variant="h3" className="text-primary font-black">
|
|
{currency}{" "}
|
|
{total.toLocaleString("en-US", {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
className="flex-1 h-10 rounded-[6px] border border-border"
|
|
onPress={() => nav.back()}
|
|
disabled={submitting}
|
|
>
|
|
<Text className="text-foreground font-bold text-xs uppercase tracking-tighter">
|
|
Discard
|
|
</Text>
|
|
</Button>
|
|
<Button
|
|
className="flex-1 h-10 rounded-[6px] bg-primary"
|
|
onPress={handleSubmit}
|
|
disabled={submitting}
|
|
>
|
|
{submitting ? (
|
|
<ActivityIndicator color="white" size="small" />
|
|
) : (
|
|
<>
|
|
<Send color="white" size={14} strokeWidth={2.5} />
|
|
<Text className="text-white font-bold text-sm ">
|
|
Create Invoice
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Currency Modal */}
|
|
<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>
|
|
|
|
{/* Type Modal */}
|
|
<PickerModal
|
|
visible={showType}
|
|
onClose={() => setShowType(false)}
|
|
title="Select Invoice Type"
|
|
>
|
|
{invoiceTypes.map((t) => (
|
|
<SelectOption
|
|
key={t}
|
|
label={t}
|
|
value={t}
|
|
selected={type === t}
|
|
onSelect={(v) => {
|
|
setType(v);
|
|
setShowType(false);
|
|
}}
|
|
/>
|
|
))}
|
|
</PickerModal>
|
|
|
|
{/* Status Modal */}
|
|
<PickerModal
|
|
visible={showStatus}
|
|
onClose={() => setShowStatus(false)}
|
|
title="Select Invoice Status"
|
|
>
|
|
{invoiceStatuses.map((s) => (
|
|
<SelectOption
|
|
key={s}
|
|
label={s}
|
|
value={s}
|
|
selected={status === s}
|
|
onSelect={(v) => {
|
|
setStatus(v);
|
|
setShowStatus(false);
|
|
}}
|
|
/>
|
|
))}
|
|
</PickerModal>
|
|
|
|
{/* Issue Date Modal */}
|
|
<PickerModal
|
|
visible={showIssueDate}
|
|
onClose={() => setShowIssueDate(false)}
|
|
title="Select Issue Date"
|
|
>
|
|
<CalendarGrid
|
|
selectedDate={issueDate}
|
|
onSelect={(v) => {
|
|
setIssueDate(v);
|
|
setShowIssueDate(false);
|
|
}}
|
|
/>
|
|
</PickerModal>
|
|
|
|
{/* Due Date Modal */}
|
|
<PickerModal
|
|
visible={showDueDate}
|
|
onClose={() => setShowDueDate(false)}
|
|
title="Select Due Date"
|
|
>
|
|
<CalendarGrid
|
|
selectedDate={dueDate}
|
|
onSelect={(v) => {
|
|
setDueDate(v);
|
|
setShowDueDate(false);
|
|
}}
|
|
/>
|
|
</PickerModal>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
function Label({
|
|
children,
|
|
noMargin,
|
|
}: {
|
|
children: string;
|
|
noMargin?: boolean;
|
|
}) {
|
|
return (
|
|
<Text
|
|
variant="small"
|
|
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3 ml-1"}`}
|
|
>
|
|
{children}
|
|
</Text>
|
|
);
|
|
}
|