654 lines
20 KiB
TypeScript
654 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 {
|
|
Trash2,
|
|
Plus,
|
|
Calendar,
|
|
ChevronDown,
|
|
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 { Stack, useLocalSearchParams } from "expo-router";
|
|
import { useRouter } from "expo-router";
|
|
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";
|
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
|
|
|
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 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,
|
|
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">
|
|
<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: "dates", label: "Dates" },
|
|
{ key: "items", label: "Items" },
|
|
{ key: "totals", label: "Totals" },
|
|
{ key: "config", label: "Config" },
|
|
];
|
|
|
|
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);
|
|
const [step, setStep] = useState(0);
|
|
|
|
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("");
|
|
|
|
const [issueDate, setIssueDate] = useState(new Date());
|
|
const [dueDate, setDueDate] = useState(new Date());
|
|
|
|
const [items, setItems] = useState<Item[]>([
|
|
{ id: 1, description: "", qty: "", price: "" },
|
|
]);
|
|
|
|
const [currencyModal, setCurrencyModal] = useState(false);
|
|
const [typeModal, setTypeModal] = useState(false);
|
|
const [issueModal, setIssueModal] = useState(false);
|
|
const [dueModal, setDueModal] = useState(false);
|
|
const c = useInputColors();
|
|
|
|
useEffect(() => {
|
|
if (isEdit) {
|
|
fetchInvoice();
|
|
}
|
|
}, [id]);
|
|
|
|
const fetchInvoice = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await api.invoices.getById({ params: { id: id as string } });
|
|
const original = data.scannedData?.originalData || {};
|
|
|
|
setInvoiceNumber(data.invoiceNumber || original.invoiceNumber || "");
|
|
|
|
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 || "").replace(/^\+251/, ""));
|
|
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()));
|
|
|
|
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 handleNext = () => {
|
|
if (step === 0 && !customerName.trim()) {
|
|
toast.error("Validation", "Customer name is required");
|
|
return;
|
|
}
|
|
if (step === 1 && !invoiceNumber.trim()) {
|
|
toast.error("Validation", "Invoice number is required");
|
|
return;
|
|
}
|
|
setStep(step + 1);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!invoiceNumber || !customerName) {
|
|
toast.error("Error", "Please fill required fields");
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const payload = {
|
|
invoiceNumber,
|
|
customerName,
|
|
customerEmail,
|
|
customerPhone: customerPhone ? `+251${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 }} />
|
|
<FormFlow
|
|
steps={STEPS}
|
|
currentStep={step}
|
|
onNext={handleNext}
|
|
onBack={() => setStep(step - 1)}
|
|
onComplete={handleSubmit}
|
|
loading={submitting}
|
|
completeLabel={isEdit ? "Update Invoice" : "Create Invoice"}
|
|
>
|
|
{step === 0 && (
|
|
<View className="gap-5">
|
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
|
Customer Details
|
|
</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>
|
|
<Field
|
|
label="Customer Email"
|
|
value={customerEmail}
|
|
onChangeText={setCustomerEmail}
|
|
placeholder="billing@acme.com"
|
|
/>
|
|
<View>
|
|
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
|
|
Customer 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="Notes"
|
|
value={notes}
|
|
onChangeText={setNotes}
|
|
placeholder="Additional notes"
|
|
multiline
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<View className="gap-5">
|
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
|
Dates
|
|
</Text>
|
|
<View className="bg-card rounded-[6px] gap-4">
|
|
<PickerField
|
|
label="Issue Date"
|
|
value={issueDate.toLocaleDateString()}
|
|
onPress={() => setIssueModal(true)}
|
|
/>
|
|
<PickerField
|
|
label="Due Date"
|
|
value={dueDate.toLocaleDateString()}
|
|
onPress={() => setDueModal(true)}
|
|
/>
|
|
</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={10} strokeWidth={2.5} />
|
|
<Text className="text-primary text-[8px] font-sans-bold">
|
|
Add
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
<View className="gap-3">
|
|
{items.map((item) => (
|
|
<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-[14px] font-sans-bold text-foreground">
|
|
Item {item.id}
|
|
</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) => updateItem(item.id, "description", v)}
|
|
/>
|
|
<View className="flex-row gap-3 mt-4">
|
|
<Field
|
|
label="Qty"
|
|
placeholder="1"
|
|
numeric
|
|
center
|
|
value={item.qty}
|
|
onChangeText={(v) => updateItem(item.id, "qty", v)}
|
|
flex={1}
|
|
/>
|
|
<Field
|
|
label="Price"
|
|
placeholder="0.00"
|
|
numeric
|
|
value={item.price}
|
|
onChangeText={(v) => updateItem(item.id, "price", v)}
|
|
flex={3}
|
|
/>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{step === 4 && (
|
|
<View className="gap-5">
|
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
|
Totals
|
|
</Text>
|
|
<View className="bg-card rounded-[6px] p-4 border border-border gap-4">
|
|
<View className="flex-row justify-between">
|
|
<Text className="text-foreground font-sans-medium">Subtotal</Text>
|
|
<Text className="text-foreground font-sans-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="border-t border-border pt-4 flex-row justify-between">
|
|
<Text className="text-foreground font-sans-bold text-lg">
|
|
Total
|
|
</Text>
|
|
<Text className="text-foreground font-sans-bold text-lg">
|
|
{currency} {calculateTotal().toFixed(2)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{step === 5 && (
|
|
<View className="gap-5">
|
|
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
|
|
Configuration
|
|
</Text>
|
|
<View className="bg-card rounded-[6px] gap-4">
|
|
<PickerField
|
|
label="Currency"
|
|
value={currency}
|
|
onPress={() => setCurrencyModal(true)}
|
|
/>
|
|
<PickerField
|
|
label="Invoice Type"
|
|
value={type}
|
|
onPress={() => setTypeModal(true)}
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</FormFlow>
|
|
|
|
<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>
|
|
);
|
|
}
|