Yaltopia-Tickets-App/app/invoices/edit.tsx

660 lines
20 KiB
TypeScript

import React, { useState, useEffect } from "react";
import {
View,
ScrollView,
Pressable,
TextInput,
StyleSheet,
ActivityIndicator,
} from "react-native";
import { useColorScheme } from "nativewind";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import {
ArrowLeft,
ArrowRight,
Trash2,
Send,
Plus,
Calendar,
ChevronDown,
CalendarSearch,
FileText,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router";
import { useRouter } from "expo-router";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { api } from "@/lib/api";
import { toast } from "@/lib/toast-store";
import { PickerModal, SelectOption } from "@/components/PickerModal";
import { CalendarGrid } from "@/components/CalendarGrid";
import { StandardHeader } from "@/components/StandardHeader";
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 isDark = colorScheme === "dark";
return {
bg: isDark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
border: isDark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
text: isDark ? "#f1f5f9" : "#0f172a",
placeholder: "rgba(100,116,139,0.45)",
};
}
function Field({
label,
value,
onChangeText,
placeholder,
numeric = false,
center = false,
flex,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder: string;
numeric?: boolean;
center?: boolean;
flex?: number;
}) {
const c = useInputColors();
return (
<View style={flex != null ? { flex } : undefined}>
<Text
variant="small"
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5"
>
{label}
</Text>
<TextInput
style={[
center ? S.inputCenter : S.input,
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
]}
placeholder={placeholder}
placeholderTextColor={c.placeholder}
value={value}
onChangeText={onChangeText}
keyboardType={numeric ? "numeric" : "default"}
/>
</View>
);
}
export default function EditInvoiceScreen() {
const nav = useSirouRouter<AppRoutes>();
const router = useRouter();
const { id } = useLocalSearchParams();
const isEdit = !!id;
const [loading, setLoading] = useState(isEdit);
const [submitting, setSubmitting] = useState(false);
// Form fields
const [invoiceNumber, setInvoiceNumber] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [currency, setCurrency] = useState("ETB");
const [type, setType] = useState("SALES");
const [notes, setNotes] = useState("");
const [taxAmount, setTaxAmount] = useState("");
const [discountAmount, setDiscountAmount] = useState("");
// Dates
const [issueDate, setIssueDate] = useState(new Date());
const [dueDate, setDueDate] = useState(new Date());
// Items
const [items, setItems] = useState<Item[]>([
{ id: 1, description: "", qty: "", price: "" },
]);
// Modals
const [currencyModal, setCurrencyModal] = useState(false);
const [typeModal, setTypeModal] = useState(false);
const [issueModal, setIssueModal] = useState(false);
const [dueModal, setDueModal] = useState(false);
// Fetch existing data for edit
useEffect(() => {
if (isEdit) {
fetchInvoice();
}
}, [id]);
const fetchInvoice = async () => {
try {
setLoading(true);
const data = await api.invoices.getById({ params: { id: id as string } });
// Robust fallbacks for scanned invoices
const original = data.scannedData?.originalData || {};
setInvoiceNumber(data.invoiceNumber || original.invoiceNumber || "");
// Clean up common OCR artifacts
let name = data.customerName || original.customerName || "";
name = name
.replace(/^Customer Name:\s*/i, "")
.replace(/^Bill To:\s*/i, "");
setCustomerName(name);
setCustomerEmail(data.customerEmail || original.customerEmail || "");
setCustomerPhone(data.customerPhone || original.customerPhone || "");
setCurrency(data.currency || original.currency || "ETB");
setType(data.type || "SALES");
setNotes(data.notes || "");
const taxVal =
typeof data.taxAmount === "object"
? data.taxAmount?.value
: data.taxAmount || original.taxAmount || "0";
setTaxAmount(String(taxVal));
const discVal =
typeof data.discountAmount === "object"
? data.discountAmount?.value
: data.discountAmount || original.discountAmount || "0";
setDiscountAmount(String(discVal));
setIssueDate(
new Date(
data.createdAt || data.issueDate || original.issueDate || Date.now(),
),
);
setDueDate(new Date(data.dueDate || original.dueDate || Date.now()));
// Populate items with fallback to original scanned data
const apiItems = data.items || [];
const sourceItems = apiItems.length > 0 ? apiItems : original.items || [];
if (sourceItems.length > 0) {
setItems(
sourceItems.map((item: any, idx: number) => ({
id: idx + 1,
description: item.description || "",
qty: String(item.quantity || "1"),
price: String(item.unitPrice?.value || item.unitPrice || "0"),
})),
);
} else {
setItems([{ id: 1, description: "", qty: "", price: "" }]);
}
} catch (error) {
console.error("[EditInvoice] Error:", error);
toast.error("Error", "Failed to load invoice details");
} finally {
setLoading(false);
}
};
const addItem = () => {
const newId =
items.length > 0 ? Math.max(...items.map((i) => i.id)) + 1 : 1;
setItems([...items, { id: newId, description: "", qty: "", price: "" }]);
};
const removeItem = (id: number) => {
if (items.length > 1) {
setItems(items.filter((i) => i.id !== id));
}
};
const updateItem = (id: number, field: keyof Item, value: string) => {
setItems(items.map((i) => (i.id === id ? { ...i, [field]: value } : i)));
};
const calculateSubtotal = () => {
return items.reduce((acc, item) => {
const qty = parseFloat(item.qty) || 0;
const price = parseFloat(item.price) || 0;
return acc + qty * price;
}, 0);
};
const calculateTotal = () => {
const subtotal = calculateSubtotal();
const tax = parseFloat(taxAmount) || 0;
const discount = parseFloat(discountAmount) || 0;
return subtotal + tax - discount;
};
const handleSubmit = async () => {
// Validation
if (!invoiceNumber || !customerName) {
toast.error("Error", "Please fill required fields");
return;
}
setSubmitting(true);
try {
const payload = {
invoiceNumber,
customerName,
customerEmail,
customerPhone,
amount: calculateTotal(),
currency,
type,
issueDate: issueDate.toISOString(),
dueDate: dueDate.toISOString(),
notes,
taxAmount: parseFloat(taxAmount) || 0,
discountAmount: parseFloat(discountAmount) || 0,
items: items.map((item) => ({
description: item.description,
quantity: parseFloat(item.qty) || 0,
unitPrice: parseFloat(item.price) || 0,
total: (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
})),
status: "PENDING",
};
if (isEdit) {
await api.invoices.update({
params: { id: id as string },
body: payload,
});
toast.success("Success", "Invoice updated successfully");
} else {
await api.invoices.create({ body: payload });
toast.success("Success", "Invoice created successfully");
}
nav.back();
} catch (error: any) {
toast.error("Error", error.message || "Failed to save invoice");
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title={isEdit ? "Edit Invoice" : "Create Invoice"}
showBack
/>
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" />
</View>
</ScreenWrapper>
);
}
const currencies = ["ETB", "USD", "EUR", "GBP"];
const invoiceTypes = ["SALES", "PURCHASE", "SERVICE"];
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title={isEdit ? "Edit Invoice" : "Create Invoice"}
showBack
/>
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
>
{/* Invoice Details */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<View className="flex-row items-center gap-3 mb-4">
<View className="bg-primary/10 p-2 rounded-[8px]">
<FileText color="#ea580c" size={16} strokeWidth={2.5} />
</View>
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
Invoice Details
</Text>
</View>
<View className="gap-4">
<Field
label="Invoice Number"
value={invoiceNumber}
onChangeText={setInvoiceNumber}
placeholder="Enter invoice number"
/>
<Field
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Additional notes"
/>
</View>
</View>
</ShadowWrapper>
{/* Customer Details */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
Customer Details
</Text>
<View className="gap-4">
<Field
label="Customer Name"
value={customerName}
onChangeText={setCustomerName}
placeholder="Enter customer name"
/>
<Field
label="Customer Email"
value={customerEmail}
onChangeText={setCustomerEmail}
placeholder="Enter customer email"
/>
<Field
label="Customer Phone"
value={customerPhone}
onChangeText={setCustomerPhone}
placeholder="Enter customer phone"
/>
</View>
</View>
</ShadowWrapper>
{/* Dates */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
Dates
</Text>
<View className="gap-4">
<Pressable
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
onPress={() => setIssueModal(true)}
>
<View className="flex-row items-center gap-2">
<Calendar color="#64748b" size={16} />
<Text className="text-foreground font-medium">
Issue Date: {issueDate.toLocaleDateString()}
</Text>
</View>
<ChevronDown color="#64748b" size={16} />
</Pressable>
<Pressable
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
onPress={() => setDueModal(true)}
>
<View className="flex-row items-center gap-2">
<CalendarSearch color="#64748b" size={16} />
<Text className="text-foreground font-medium">
Due Date: {dueDate.toLocaleDateString()}
</Text>
</View>
<ChevronDown color="#64748b" size={16} />
</Pressable>
</View>
</View>
</ShadowWrapper>
{/* Items */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<View className="flex-row items-center justify-between mb-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
Items
</Text>
<Button
className="h-8 px-3 rounded-[6px] bg-primary"
onPress={addItem}
>
<Plus color="#ffffff" size={14} />
<Text className="ml-1 text-white text-xs font-bold">
Add Item
</Text>
</Button>
</View>
{items.map((item) => (
<View
key={item.id}
className="flex-row items-center gap-3 mb-3 p-3 bg-muted rounded-[6px]"
>
<Field
flex={3}
label="Description"
value={item.description}
onChangeText={(v) => updateItem(item.id, "description", v)}
placeholder="Item description"
/>
<Field
flex={1}
label="Qty"
value={item.qty}
onChangeText={(v) => updateItem(item.id, "qty", v)}
placeholder="0"
numeric
center
/>
<Field
flex={1.5}
label="Price"
value={item.price}
onChangeText={(v) => updateItem(item.id, "price", v)}
placeholder="0.00"
numeric
center
/>
<Pressable
className="mt-4 p-2"
onPress={() => removeItem(item.id)}
>
<Trash2 color="#dc2626" size={16} />
</Pressable>
</View>
))}
</View>
</ShadowWrapper>
{/* Totals */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
Totals
</Text>
<View className="gap-3">
<View className="flex-row justify-between">
<Text className="text-foreground font-medium">Subtotal</Text>
<Text className="text-foreground font-bold">
{currency} {calculateSubtotal().toFixed(2)}
</Text>
</View>
<Field
label="Tax Amount"
value={taxAmount}
onChangeText={setTaxAmount}
placeholder="0.00"
numeric
/>
<Field
label="Discount Amount"
value={discountAmount}
onChangeText={setDiscountAmount}
placeholder="0.00"
numeric
/>
<View className="flex-row justify-between pt-2 border-t border-border">
<Text className="text-foreground font-bold text-lg">Total</Text>
<Text className="text-foreground font-bold text-lg">
{currency} {calculateTotal().toFixed(2)}
</Text>
</View>
</View>
</View>
</ShadowWrapper>
{/* Configuration */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
Configuration
</Text>
<View className="gap-4">
<View>
<Text
variant="small"
className="font-semibold text-[10px] uppercase mb-1.5 ml-1"
>
Currency
</Text>
<Pressable
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
onPress={() => setCurrencyModal(true)}
>
<Text className="text-foreground font-medium">
{currency}
</Text>
<ChevronDown color="#64748b" size={16} />
</Pressable>
</View>
<View>
<Text
variant="small"
className="font-semibold text-[10px] uppercase mb-1.5 ml-1"
>
Invoice Type
</Text>
<Pressable
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
onPress={() => setTypeModal(true)}
>
<Text className="text-foreground font-medium">{type}</Text>
<ChevronDown color="#64748b" size={16} />
</Pressable>
</View>
</View>
</View>
</ShadowWrapper>
</ScrollView>
{/* Bottom Action */}
<View className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t border-border">
<Button
className="h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
onPress={handleSubmit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<>
<Send color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
{isEdit ? "Update Invoice" : "Create Invoice"}
</Text>
</>
)}
</Button>
</View>
{/* Modals */}
<PickerModal
visible={currencyModal}
title="Select Currency"
onClose={() => setCurrencyModal(false)}
>
{currencies.map((curr) => (
<SelectOption
key={curr}
label={curr}
value={curr}
selected={curr === currency}
onSelect={(v) => {
setCurrency(v);
setCurrencyModal(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={typeModal}
title="Select Invoice Type"
onClose={() => setTypeModal(false)}
>
{invoiceTypes.map((t) => (
<SelectOption
key={t}
label={t}
value={t}
selected={t === type}
onSelect={(v) => {
setType(v);
setTypeModal(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={issueModal}
title="Select Issue Date"
onClose={() => setIssueModal(false)}
>
<CalendarGrid
onSelect={(dateStr: string) => {
setIssueDate(new Date(dateStr));
setIssueModal(false);
}}
selectedDate={issueDate.toISOString().substring(0, 10)}
/>
</PickerModal>
<PickerModal
visible={dueModal}
title="Select Due Date"
onClose={() => setDueModal(false)}
>
<CalendarGrid
onSelect={(dateStr: string) => {
setDueDate(new Date(dateStr));
setDueModal(false);
}}
selectedDate={dueDate.toISOString().substring(0, 10)}
/>
</PickerModal>
</ScreenWrapper>
);
}