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

693 lines
22 KiB
TypeScript

import React, { useEffect, useState, useMemo } from "react";
import {
View,
ScrollView,
Pressable,
TextInput,
StyleSheet,
ActivityIndicator,
Platform,
PermissionsAndroid,
} from "react-native";
import { useColorScheme } from "nativewind";
import { Text } from "@/components/ui/text";
import { ChevronDown, CalendarSearch, Search, Link2 } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { api } from "@/lib/api";
import { toast } from "@/lib/toast-store";
import { EmptyState } from "@/components/EmptyState";
import { PickerModal, SelectOption } from "@/components/PickerModal";
import { CalendarGrid } from "@/components/CalendarGrid";
import { FormFlow } from "@/components/FormFlow";
import { CustomerPicker } from "@/components/CustomerPicker";
import { getPlaceholderColor } from "@/lib/colors";
let SmsAndroid: any = null;
if (Platform.OS === "android") {
try {
const smsModule = require("react-native-get-sms-android");
SmsAndroid = smsModule.default || smsModule;
} catch (e) {
console.log("[CreatePayment] SMS module unavailable");
}
}
const S = StyleSheet.create({
input: {
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 12,
fontWeight: "500",
borderRadius: 6,
borderWidth: 1,
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,
flex,
multiline = false,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder: string;
numeric?: 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={[
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 CURRENCIES = ["ETB", "USD", "EUR", "GBP", "KES", "ZAR"];
const PAYMENT_METHODS = [
"Telebirr",
"CBE",
"Dashen",
"DECSI",
"Bank Transfer",
"Cash",
"Other",
];
function parseSmsMessage(body: string) {
const text = body.toUpperCase();
let bank = "";
let ref = "";
let amount = "";
if (text.includes("CBE")) {
bank = "CBE";
const dirMatch = body.match(/(debited|credited)\s+with\s+ETB([\d,.]+)/i);
if (dirMatch) amount = dirMatch[2];
const refMatch = body.match(/id=(\w+)/i);
if (refMatch) ref = refMatch[1];
} else if (text.includes("TELEBIRR")) {
bank = "Telebirr";
const dirMatch = body.match(/(received|sent|paid)\s+ETB\s*([\d,.]+)/i);
if (dirMatch) amount = dirMatch[2];
const refMatch = body.match(/transaction number\s+(\w+)/i);
if (refMatch) ref = refMatch[1];
}
return { bank, ref, amount };
}
export default function CreatePaymentScreen() {
const nav = useSirouRouter<AppRoutes>();
const [step, setStep] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [transactionId, setTransactionId] = useState("");
const [amount, setAmount] = useState("");
const [currency, setCurrency] = useState("ETB");
const [paymentMethod, setPaymentMethod] = useState("Telebirr");
const [paymentDate, setPaymentDate] = useState(
new Date().toISOString().split("T")[0],
);
const [notes, setNotes] = useState("");
const [selectedInvoice, setSelectedInvoice] = useState<any>(null);
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [showCurrency, setShowCurrency] = useState(false);
const [showPaymentMethod, setShowPaymentMethod] = useState(false);
const [showPaymentDate, setShowPaymentDate] = useState(false);
const [showInvoicePicker, setShowInvoicePicker] = useState(false);
const [invoices, setInvoices] = useState<any[]>([]);
const [invoiceSearch, setInvoiceSearch] = useState("");
const [loadingInvoices, setLoadingInvoices] = useState(false);
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const c = useInputColors();
useEffect(() => {
if (Platform.OS !== "android" || !SmsAndroid) return;
(async () => {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_SMS,
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) return;
const fiveMinsAgo = Date.now() - 5 * 60 * 1000;
const filter = {
box: "inbox",
minDate: fiveMinsAgo,
maxCount: 30,
};
SmsAndroid.list(
JSON.stringify(filter),
() => {},
(_count: number, smsList: string) => {
const messages = JSON.parse(smsList);
const match = messages.find((m: any) => {
const addr = (m.address || "").toUpperCase();
const body = (m.body || "").toUpperCase();
return (
addr === "127" ||
addr === "CBE" ||
body.includes("TELEBIRR") ||
body.includes("CBE")
);
});
if (!match) return;
const {
bank,
ref,
amount: parsedAmount,
} = parseSmsMessage(match.body);
if (ref || parsedAmount) {
setTransactionId(ref || match.body.slice(0, 20));
if (parsedAmount) setAmount(parsedAmount.replace(/,/g, ""));
if (bank) setPaymentMethod(bank);
}
},
);
} catch {
// silent
}
})();
}, []);
const openInvoicePicker = async () => {
setLoadingInvoices(true);
try {
const response = await api.invoices.getAll({ query: { limit: 50 } });
const list = Array.isArray(response)
? response
: (response as any).data || [];
setInvoices(list);
setInvoiceSearch("");
setShowInvoicePicker(true);
} catch (err: any) {
toast.error("Error", "Failed to fetch invoices.");
} finally {
setLoadingInvoices(false);
}
};
const filteredInvoices = useMemo(() => {
if (!invoiceSearch) return invoices;
const q = invoiceSearch.toLowerCase();
return invoices.filter(
(inv) =>
(inv.invoiceNumber || "").toLowerCase().includes(q) ||
(inv.customerName || "").toLowerCase().includes(q),
);
}, [invoices, invoiceSearch]);
const STEPS = [
{ key: "details", label: "Payment Details" },
{ key: "invoice", label: "Invoice" },
{ key: "info", label: "Customer Info" },
{ key: "summary", label: "Summary" },
];
const handleNext = () => {
if (step === 0) {
if (!transactionId.trim()) {
toast.error("Validation Error", "Transaction ID is required");
return;
}
if (!amount || parseFloat(amount) <= 0) {
toast.error(
"Validation Error",
"Amount is required and must be greater than 0",
);
return;
}
}
setStep(step + 1);
};
const handleSubmit = async () => {
if (!transactionId.trim()) {
toast.error("Validation Error", "Transaction ID is required");
return;
}
if (!amount || parseFloat(amount) <= 0) {
toast.error(
"Validation Error",
"Amount is required and must be greater than 0",
);
return;
}
setSubmitting(true);
try {
const payload = {
transactionId: transactionId.trim(),
amount: parseFloat(amount),
currency,
paymentDate: new Date(paymentDate).toISOString(),
paymentMethod,
notes,
customerName: customerName.trim() || undefined,
customerEmail: customerEmail.trim() || undefined,
customerPhone: customerPhone.trim() ? `+251${customerPhone.trim()}` : undefined,
...(selectedInvoice?.id ? { invoiceId: selectedInvoice.id } : {}),
};
await api.payments.create({ body: payload });
toast.success("Success", "Payment created successfully!");
nav.back();
} catch (error: any) {
console.error("[CreatePayment] Error:", error);
const msg =
error?.response?.data?.message ||
error?.data?.message ||
error?.message ||
"Failed to create payment";
toast.error("Error", msg);
} finally {
setSubmitting(false);
}
};
return (
<ScreenWrapper className="bg-background">
<FormFlow
steps={STEPS}
currentStep={step}
onNext={handleNext}
onBack={() => setStep(step - 1)}
onComplete={handleSubmit}
loading={submitting}
completeLabel="Create Payment"
>
{step === 0 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Payment Details
</Text>
<View className="bg-card rounded-[6px] gap-4">
<Field
label="Transaction ID"
value={transactionId}
onChangeText={setTransactionId}
placeholder="e.g. TXN-2024-001"
/>
<View className="flex-row gap-4">
<Field
label="Amount"
value={amount}
onChangeText={setAmount}
placeholder="0.00"
numeric
flex={1}
/>
<PickerField
label="Currency"
value={currency}
onPress={() => setShowCurrency(true)}
/>
</View>
<PickerField
label="Payment Method"
value={paymentMethod}
onPress={() => setShowPaymentMethod(true)}
/>
<PickerField
label="Payment Date"
value={paymentDate}
onPress={() => setShowPaymentDate(true)}
/>
</View>
</View>
)}
{step === 1 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Invoice
</Text>
<View className="bg-card rounded-[6px] gap-4">
<View>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
Link Invoice
</Text>
<Pressable
onPress={openInvoicePicker}
disabled={loadingInvoices}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
{loadingInvoices ? (
<ActivityIndicator color="#ea580c" size="small" />
) : (
<>
<Text
className="text-xs font-sans-medium flex-1"
style={{
color: selectedInvoice ? c.text : c.placeholder,
}}
numberOfLines={1}
>
{selectedInvoice
? `#${selectedInvoice.invoiceNumber || selectedInvoice.id}${selectedInvoice.customerName || ""}`
: "Select an invoice (optional)"}
</Text>
<Link2 size={14} color="#ea580c" strokeWidth={2.5} />
</>
)}
</Pressable>
</View>
<Field
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="e.g. Payment for invoice INV-2024-001"
multiline
/>
</View>
</View>
)}
{step === 2 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Customer Info
</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">
<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 === 3 && (
<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">
Transaction ID
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{transactionId}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-bold">
Payment Method
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{paymentMethod}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-bold">
Payment Date
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{paymentDate}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-bold">
Linked Invoice
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{selectedInvoice
? `#${selectedInvoice.invoiceNumber || selectedInvoice.id}`
: "None"}
</Text>
</View>
{notes ? (
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
Notes
</Text>
<Text
className="text-[14px] text-foreground font-sans-bold flex-1 text-right ml-4"
numberOfLines={2}
>
{notes}
</Text>
</View>
) : null}
<View className="border-t border-border/40 my-1" />
<View className="flex-row justify-between">
<Text className="text-[16px] font-sans-bold text-foreground">
Total Amount
</Text>
<Text className="text-[16px] font-sans-bold text-primary">
{currency}{" "}
{(parseFloat(amount) || 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</Text>
</View>
</View>
</View>
)}
</FormFlow>
<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>
<PickerModal
visible={showPaymentMethod}
onClose={() => setShowPaymentMethod(false)}
title="Select Payment Method"
>
{PAYMENT_METHODS.map((method) => (
<SelectOption
key={method}
label={method}
value={method}
selected={paymentMethod === method}
onSelect={(v) => {
setPaymentMethod(v);
setShowPaymentMethod(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={showPaymentDate}
onClose={() => setShowPaymentDate(false)}
title="Select Payment Date"
>
<CalendarGrid
selectedDate={paymentDate}
onSelect={(v) => {
setPaymentDate(v);
setShowPaymentDate(false);
}}
/>
</PickerModal>
<PickerModal
visible={showInvoicePicker}
onClose={() => setShowInvoicePicker(false)}
title="Link Invoice (Optional)"
>
<View className="px-4 pb-3">
<View className="flex-row items-center rounded-xl px-3 border border-border h-10">
<Search size={16} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-2 text-foreground py-0 text-sm"
placeholder="Search by number or customer..."
placeholderTextColor={getPlaceholderColor(isDark)}
value={invoiceSearch}
onChangeText={setInvoiceSearch}
autoCorrect={false}
style={{ textAlignVertical: "center" }}
/>
</View>
</View>
{loadingInvoices ? (
<View className="py-10 items-center">
<ActivityIndicator color="#ea580c" />
</View>
) : (
<ScrollView className="max-h-96" keyboardShouldPersistTaps="handled">
<Pressable
onPress={() => {
setSelectedInvoice(null);
setShowInvoicePicker(false);
}}
className="px-4 py-3 border-b border-border/40 flex-row items-center"
>
<Text className="text-muted-foreground text-sm font-sans-medium">
None skip linking
</Text>
</Pressable>
{filteredInvoices.length > 0 ? (
filteredInvoices.map((inv) => (
<Pressable
key={inv.id}
onPress={() => {
setSelectedInvoice(inv);
setShowInvoicePicker(false);
}}
className="px-4 py-3 border-b border-border/40 flex-row items-center"
>
<View className="flex-1">
<Text className="text-foreground font-sans-bold text-sm">
{inv.customerName || "Unknown"}
</Text>
<Text className="text-muted-foreground text-[10px] font-sans-semibold mt-0.5">
#{inv.invoiceNumber || inv.id} · {inv.currency || "ETB"}{" "}
{Number(inv.amount || 0).toLocaleString()}
</Text>
</View>
<Link2 size={16} color="#ea580c" strokeWidth={2.5} />
</Pressable>
))
) : (
<EmptyState
title={
invoiceSearch
? "No invoices match your search"
: "No invoices available"
}
/>
)}
</ScrollView>
)}
</PickerModal>
</ScreenWrapper>
);
}