Yaltopia-Tickets-App/app/invoices/create.tsx
2026-06-05 13:39:37 +03:00

830 lines
27 KiB
TypeScript

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 (
<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,
}: {
label: string;
value: string;
onPress: () => void;
}) {
const c = useInputColors();
return (
<View className="flex-1 mb-4">
<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 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<AppRoutes>();
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<Item[]>([
{ 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 (
<ScreenWrapper className="bg-background">
<FormFlow
steps={STEPS}
currentStep={step}
onNext={handleNext}
onBack={() => setStep(step - 1)}
onComplete={handleSubmit}
loading={submitting}
completeLabel="Create Invoice"
>
{step === 0 && (
<View className="gap-5">
<Pressable
onPress={handlePickImage}
disabled={scanning}
className="bg-primary/10 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-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>
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Customer Information
</Text>
<View className="gap-4">
<View>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
Customer Name
</Text>
<CustomerPicker
value={customerName}
onSelect={(c) => {
setCustomerName(c.name);
setCustomerEmail(c.email);
setCustomerPhone(c.phone.replace("+251", ""));
}}
placeholder="Select or search for a customer"
/>
</View>
<View className="flex-row gap-4 my-4">
<Field
label="Email"
value={customerEmail}
onChangeText={setCustomerEmail}
placeholder="billing@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={customerPhone}
onChangeText={setCustomerPhone}
keyboardType="phone-pad"
maxLength={9}
style={{ textAlignVertical: "center" }}
/>
</View>
</View>
</View>
</View>
)}
{step === 1 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Invoice Details
</Text>
<View className="bg-card rounded-[6px] gap-4">
<Field
label="Invoice Number"
value={invoiceNumber}
onChangeText={setInvoiceNumber}
placeholder="e.g. INV-2024-001"
/>
<Field
label="Description"
value={description}
onChangeText={setDescription}
placeholder="e.g. Web Development Services"
/>
<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)}
/>
<PickerField
label="Type"
value={type}
onPress={() => setShowType(true)}
/>
</View>
<PickerField
label="Status"
value={status}
onPress={() => setShowStatus(true)}
/>
</View>
</View>
)}
{step === 2 && (
<View className="gap-5">
<View className="flex-row items-center justify-between">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Billable 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={10} strokeWidth={2.5} />
<Text className="text-primary text-[8px] font-sans-bold">
Add
</Text>
</Pressable>
</View>
<View className="gap-3">
{items.map((item, index) => (
<View
key={item.id}
className="bg-card rounded-[6px] p-4 border 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. 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={3}
/>
</View>
</View>
))}
</View>
</View>
)}
{step === 3 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Taxes & Notes
</Text>
<View className="bg-card rounded-[6px] gap-4">
<View className="flex-row gap-4">
<Field
label="Tax Amount"
value={taxAmount}
onChangeText={setTaxAmount}
placeholder="0.00"
numeric
flex={1}
/>
<Field
label="Discount"
value={discountAmount}
onChangeText={setDiscountAmount}
placeholder="0.00"
numeric
flex={1}
/>
</View>
</View>
<View className="bg-card rounded-[6px]">
<Field
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="e.g. Payment due within 30 days"
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-bold">
Customer
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{customerName}
</Text>
</View>
{(customerEmail || customerPhone) && (
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-bold">
Contact
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{customerEmail || (customerPhone ? `+251${customerPhone}` : "")}
</Text>
</View>
)}
<View className="border-t border-border/40 my-1" />
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-bold">
Invoice #
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{invoiceNumber}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-bold">
Items
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{items.filter((i) => i.description.trim()).length} items
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-bold">
Subtotal
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{currency} {subtotal.toLocaleString()}
</Text>
</View>
{parseFloat(taxAmount) > 0 && (
<View className="flex-row justify-between">
<Text className="text-[14px] text-muted-foreground font-sans-bold">
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-muted-foreground font-sans-bold">
Discount
</Text>
<Text className="text-[14px] text-destructive font-sans-bold">
-{currency} {parseFloat(discountAmount).toFixed(2)}
</Text>
</View>
)}
<View className="border-t border-border/40 my-1" />
<View className="flex-row justify-between">
<Text className="text-[18px] text-foreground font-sans-bold">
Total
</Text>
<Text className="text-[18px] text-primary font-sans-bold">
{currency}{" "}
{total.toLocaleString("en-US", { minimumFractionDigits: 2 })}
</Text>
</View>
</View>
</View>
)}
</FormFlow>
<PickerModal
visible={showCurrency}
onClose={() => setShowCurrency(false)}
title="Currency"
>
{currencies.map((c) => (
<SelectOption
key={c}
label={c}
value={c}
selected={currency === c}
onSelect={(v) => {
setCurrency(v);
setShowCurrency(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={showType}
onClose={() => setShowType(false)}
title="Invoice Type"
>
{invoiceTypes.map((t) => (
<SelectOption
key={t}
label={t}
value={t}
selected={type === t}
onSelect={(v) => {
setType(v);
setShowType(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={showStatus}
onClose={() => setShowStatus(false)}
title="Status"
>
{invoiceStatuses.map((s) => (
<SelectOption
key={s}
label={s}
value={s}
selected={status === s}
onSelect={(v) => {
setStatus(v);
setShowStatus(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={showIssueDate}
onClose={() => setShowIssueDate(false)}
title="Issue Date"
>
<CalendarGrid
selectedDate={issueDate}
onSelect={(v) => {
setIssueDate(v);
setShowIssueDate(false);
}}
/>
</PickerModal>
<PickerModal
visible={showDueDate}
onClose={() => setShowDueDate(false)}
title="Due Date"
>
<CalendarGrid
selectedDate={dueDate}
onSelect={(v) => {
setDueDate(v);
setShowDueDate(false);
}}
/>
</PickerModal>
</ScreenWrapper>
);
}