Yaltopia-Tickets-App/app/payment-requests/create.tsx
2026-03-11 22:48:53 +03:00

796 lines
25 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from "react-native";
import { Stack } from "expo-router";
import { useSirouRouter } from "@sirou/react-native";
import { colorScheme, useColorScheme } from "nativewind";
import { api } from "@/lib/api";
import { AppRoutes } from "@/lib/routes";
import { toast } from "@/lib/toast-store";
import { getPlaceholderColor } from "@/lib/colors";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { PickerModal, SelectOption } from "@/components/PickerModal";
import { CalendarGrid } from "@/components/CalendarGrid";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import {
Calendar,
CalendarSearch,
ChevronDown,
Plus,
Send,
Trash2,
} from "@/lib/icons";
type Item = { id: number; description: string; qty: string; price: string };
type Account = {
id: number;
bankName: string;
accountName: string;
accountNumber: string;
currency: 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: scheme } = useColorScheme();
const dark = scheme === "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,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder: string;
numeric?: boolean;
center?: boolean;
flex?: number;
}) {
const c = useInputColors();
const isDark = colorScheme.get() === "dark";
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={getPlaceholderColor(isDark)}
value={value}
onChangeText={onChangeText}
keyboardType={numeric ? "numeric" : "default"}
autoCorrect={false}
autoCapitalize="none"
returnKeyType="next"
/>
</View>
);
}
function Label({
children,
noMargin,
}: {
children: string;
noMargin?: boolean;
}) {
return (
<Text
variant="small"
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3"}`}
>
{children}
</Text>
);
}
const CURRENCIES = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
const STATUSES = ["DRAFT", "PENDING", "PAID", "CANCELLED"];
export default function CreatePaymentRequestScreen() {
const nav = useSirouRouter<AppRoutes>();
const [submitting, setSubmitting] = useState(false);
const [paymentRequestNumber, setPaymentRequestNumber] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [customerId, setCustomerId] = useState("");
const [amount, setAmount] = useState("");
const [currency, setCurrency] = useState("USD");
const [description, setDescription] = useState("");
const [notes, setNotes] = useState("");
const [taxAmount, setTaxAmount] = useState("0");
const [discountAmount, setDiscountAmount] = useState("0");
const [issueDate, setIssueDate] = useState(
new Date().toISOString().split("T")[0],
);
const [dueDate, setDueDate] = useState("");
const [paymentId, setPaymentId] = useState("");
const [status, setStatus] = useState("DRAFT");
const [items, setItems] = useState<Item[]>([
{ id: 1, description: "", qty: "1", price: "" },
]);
const [accounts, setAccounts] = useState<Account[]>([
{
id: 1,
bankName: "",
accountName: "",
accountNumber: "",
currency: "ETB",
},
]);
const c = useInputColors();
const [showCurrency, setShowCurrency] = useState(false);
const [showIssueDate, setShowIssueDate] = useState(false);
const [showDueDate, setShowDueDate] = useState(false);
const [showStatus, setShowStatus] = useState(false);
useEffect(() => {
const year = new Date().getFullYear();
const random = Math.floor(1000 + Math.random() * 9000);
setPaymentRequestNumber(`PAYREQ-${year}-${random}`);
const d = new Date();
d.setDate(d.getDate() + 30);
setDueDate(d.toISOString().split("T")[0]);
}, []);
const updateItem = (id: number, field: keyof Item, value: string) =>
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, [field]: value } : it)),
);
const addItem = () =>
setItems((prev) => [
...prev,
{ id: Date.now(), description: "", qty: "1", price: "" },
]);
const removeItem = (id: number) => {
if (items.length > 1) setItems((prev) => prev.filter((it) => it.id !== id));
};
const updateAccount = (id: number, field: keyof Account, value: string) =>
setAccounts((prev) =>
prev.map((acc) => (acc.id === id ? { ...acc, [field]: value } : acc)),
);
const addAccount = () =>
setAccounts((prev) => [
...prev,
{
id: Date.now(),
bankName: "",
accountName: "",
accountNumber: "",
currency: "ETB",
},
]);
const removeAccount = (id: number) => {
if (accounts.length > 1)
setAccounts((prev) => prev.filter((acc) => acc.id !== id));
};
const subtotal = useMemo(
() =>
items.reduce(
(sum, item) =>
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
0,
),
[items],
);
const computedTotal = useMemo(
() => subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0),
[subtotal, taxAmount, discountAmount],
);
const isDark = colorScheme.get() === "dark";
const handleSubmit = async () => {
if (!customerName) {
toast.error("Validation Error", "Please enter a customer name");
return;
}
const formattedPhone = customerPhone.startsWith("+")
? customerPhone
: customerPhone.length > 0
? `+251${customerPhone}`
: "";
const body = {
paymentRequestNumber,
customerName,
customerEmail,
customerPhone: formattedPhone,
amount: amount ? Number(amount) : Number(computedTotal.toFixed(2)),
currency,
issueDate: new Date(issueDate).toISOString(),
dueDate: new Date(dueDate).toISOString(),
description: description || `Payment request for ${customerName}`,
notes,
taxAmount: parseFloat(taxAmount) || 0,
discountAmount: parseFloat(discountAmount) || 0,
status,
paymentId,
accounts: accounts.map((a) => ({
bankName: a.bankName,
accountName: a.accountName,
accountNumber: a.accountNumber,
currency: a.currency,
})),
items: items.map((i) => ({
description: i.description || "Item",
quantity: parseFloat(i.qty) || 0,
unitPrice: parseFloat(i.price) || 0,
total: Number(
((parseFloat(i.qty) || 0) * (parseFloat(i.price) || 0)).toFixed(2),
),
})),
customerId: customerId || undefined,
};
try {
setSubmitting(true);
await api.paymentRequests.create({ body });
toast.success("Success", "Payment request created successfully!");
nav.back();
} catch (err: any) {
console.error("[PaymentRequestCreate] Error:", err);
toast.error("Error", err?.message || "Failed to create payment request");
} finally {
setSubmitting(false);
}
};
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Create Payment Request" showBack />
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 30 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Label>General Information</Label>
<ShadowWrapper>
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
<Field
label="Payment Request Number"
value={paymentRequestNumber}
onChangeText={setPaymentRequestNumber}
placeholder="e.g. PAYREQ-2024-001"
/>
<Field
label="Description"
value={description}
onChangeText={setDescription}
placeholder="e.g. Payment request for services"
/>
</View>
</ShadowWrapper>
<Label>Customer Details</Label>
<ShadowWrapper>
<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="+251..."
flex={1}
/>
</View>
<Field
label="Customer ID"
value={customerId}
onChangeText={setCustomerId}
placeholder="Optional"
/>
</View>
</ShadowWrapper>
<Label>Schedule & Currency</Label>
<ShadowWrapper>
<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 "
>
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 "
>
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 "
>
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 "
>
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>
<View className="flex-row gap-4">
<Field
label="Amount"
value={amount}
onChangeText={setAmount}
placeholder="1500"
numeric
flex={1}
/>
<Field
label="Payment ID"
value={paymentId}
onChangeText={setPaymentId}
placeholder="PAY-123456"
flex={1}
/>
</View>
</View>
</ShadowWrapper>
<View className="flex-row items-center justify-between mb-3">
<Label noMargin>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) => (
<ShadowWrapper key={item.id}>
<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. Web Development Service"
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="Unit Price"
placeholder="0.00"
numeric
value={item.price}
onChangeText={(v) => updateItem(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>
</ShadowWrapper>
))}
</View>
<View className="flex-row items-center justify-between mb-3">
<Label noMargin>Accounts</Label>
<Pressable
onPress={addAccount}
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 Account
</Text>
</Pressable>
</View>
<View className="gap-3 mb-5">
{accounts.map((acc, index) => (
<ShadowWrapper key={acc.id}>
<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"
>
Account {index + 1}
</Text>
{accounts.length > 1 && (
<Pressable onPress={() => removeAccount(acc.id)} hitSlop={8}>
<Trash2 color="#ef4444" size={13} />
</Pressable>
)}
</View>
<View className="gap-4">
<Field
label="Bank Name"
value={acc.bankName}
onChangeText={(v) => updateAccount(acc.id, "bankName", v)}
placeholder="e.g. Yaltopia Bank"
/>
<Field
label="Account Name"
value={acc.accountName}
onChangeText={(v) => updateAccount(acc.id, "accountName", v)}
placeholder="e.g. Yaltopia Tech PLC"
/>
<View className="flex-row gap-4">
<Field
label="Account Number"
value={acc.accountNumber}
onChangeText={(v) =>
updateAccount(acc.id, "accountNumber", v)
}
placeholder="123456789"
flex={1}
/>
<Field
label="Currency"
value={acc.currency}
onChangeText={(v) => updateAccount(acc.id, "currency", v)}
placeholder="ETB"
flex={1}
/>
</View>
</View>
</View>
</ShadowWrapper>
))}
</View>
<Label>Totals & Taxes</Label>
<ShadowWrapper>
<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>
</ShadowWrapper>
<Label>Notes</Label>
<ShadowWrapper>
<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 terms: Net 30"
placeholderTextColor={getPlaceholderColor(isDark)}
value={notes}
onChangeText={setNotes}
multiline
/>
</View>
</ShadowWrapper>
<View className="border border-border/60 rounded-[12px] p-5 mb-6">
<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}{" "}
{(amount ? Number(amount) : computedTotal).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 Request
</Text>
</>
)}
</Button>
</View>
</View>
</ScrollView>
<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={showStatus}
onClose={() => setShowStatus(false)}
title="Select Status"
>
{STATUSES.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="Select Issue Date"
>
<CalendarGrid
selectedDate={issueDate}
onSelect={(v) => {
setIssueDate(v);
setShowIssueDate(false);
}}
/>
</PickerModal>
<PickerModal
visible={showDueDate}
onClose={() => setShowDueDate(false)}
title="Select Due Date"
>
<CalendarGrid
selectedDate={dueDate}
onSelect={(v) => {
setDueDate(v);
setShowDueDate(false);
}}
/>
</PickerModal>
</ScreenWrapper>
);
}