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

891 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Pressable,
TextInput,
StyleSheet,
View,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { useColorScheme } from "nativewind";
import { api } from "@/lib/api";
import { AppRoutes } from "@/lib/routes";
import { toast } from "@/lib/toast-store";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { FormFlow } from "@/components/FormFlow";
import { PickerModal, SelectOption } from "@/components/PickerModal";
import { CalendarGrid } from "@/components/CalendarGrid";
import { CustomerPicker } from "@/components/CustomerPicker";
import { Text } from "@/components/ui/text";
import {
Calendar,
CalendarSearch,
ChevronDown,
Plus,
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: {
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">
<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 STEPS = [
{ key: "details", label: "Details" },
{ key: "customer", label: "Customer" },
{ key: "schedule", label: "Schedule" },
{ key: "items", label: "Items" },
{ key: "accounts", label: "Accounts" },
{ key: "totals", label: "Totals" },
{ key: "notes", label: "Notes" },
{ key: "summary", label: "Summary" },
];
export default function CreatePaymentRequestScreen() {
const nav = useSirouRouter<AppRoutes>();
const [step, setStep] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [paymentRequestNumber, setPaymentRequestNumber] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [amount, setAmount] = useState("");
const [currency, setCurrency] = useState("ETB");
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 [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 handleNext = () => {
if (step === 0 && !paymentRequestNumber.trim()) {
toast.error("Validation", "Payment request number is required");
return;
}
if (step === 1 && !customerName.trim()) {
toast.error("Validation", "Customer name is required");
return;
}
setStep(step + 1);
};
const handleSubmit = async () => {
if (!customerName) {
toast.error("Validation Error", "Please enter a customer name");
return;
}
const formattedPhone = customerPhone ? `+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,
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),
),
})),
};
try {
setSubmitting(true);
await api.paymentRequests.create({
body,
headers: {
"Content-Type": "application/json",
},
});
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">
<FormFlow
steps={STEPS}
currentStep={step}
onNext={handleNext}
onBack={() => setStep(step - 1)}
onComplete={handleSubmit}
loading={submitting}
completeLabel="Create Request"
>
{step === 0 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Request Details
</Text>
<View className="bg-card rounded-[6px] gap-4">
<Field
label="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>
</View>
)}
{step === 1 && (
<View className="gap-5">
<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">
<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 === 2 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Schedule & Currency
</Text>
<View className="bg-card rounded-[6px] gap-4">
<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="Status"
value={status}
onPress={() => setShowStatus(true)}
/>
</View>
<Field
label="Amount"
value={amount}
onChangeText={setAmount}
placeholder="1500"
numeric
/>
</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={14} strokeWidth={2.5} />
<Text className="text-primary text-[10px] font-sans-bold">
Add
</Text>
</Pressable>
</View>
<View className="gap-3">
{items.map((item, index) => (
<View
key={item.id}
className={`bg-card pb-4 ${index < items.length - 1 ? "border-b 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. Web Development"
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">
<View className="flex-row items-center justify-between">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Accounts
</Text>
<Pressable
onPress={addAccount}
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={14} strokeWidth={2.5} />
<Text className="text-primary text-[12px] font-sans-bold">
Add
</Text>
</Pressable>
</View>
<View className="gap-3">
{accounts.map((acc, index) => (
<View
key={acc.id}
className={`bg-card pb-4 ${index < accounts.length - 1 ? "border-b border-border" : ""}`}
>
<View className="flex-row justify-between items-center mb-3">
<Text className="text-[16px] font-sans-bold text-foreground">
Account {index + 1}
</Text>
<Pressable
onPress={() => removeAccount(acc.id)}
hitSlop={8}
>
<Trash2 color="#ef4444" size={16} />
</Pressable>
</View>
<Field
label="Bank Name"
value={acc.bankName}
onChangeText={(v) => updateAccount(acc.id, "bankName", v)}
placeholder="e.g. Yaltopia Bank"
/>
<View className="flex-row gap-4 mt-4">
<Field
label="Account Name"
value={acc.accountName}
onChangeText={(v) =>
updateAccount(acc.id, "accountName", v)
}
placeholder="e.g. Yaltopia Tech PLC"
flex={1}
/>
</View>
<View className="flex-row gap-4 mt-4">
<Field
label="Account Number"
value={acc.accountNumber}
onChangeText={(v) =>
updateAccount(acc.id, "accountNumber", v)
}
placeholder="123456789"
flex={2}
/>
<Field
label="Currency"
value={acc.currency}
onChangeText={(v) => updateAccount(acc.id, "currency", v)}
placeholder="ETB"
flex={1}
/>
</View>
</View>
))}
</View>
</View>
)}
{step === 5 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Totals
</Text>
<View className="bg-card rounded-[6px] pb-4 gap-4">
<View className="flex-row justify-between items-center">
<Text className="text-foreground font-sans-medium text-sm">
Subtotal
</Text>
<Text className="text-foreground font-sans-bold">
{currency}{" "}
{subtotal.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</Text>
</View>
<View className="flex-row gap-4">
<Field
label="Tax Amount"
value={taxAmount}
onChangeText={setTaxAmount}
placeholder="0"
numeric
flex={1}
/>
<Field
label="Discount"
value={discountAmount}
onChangeText={setDiscountAmount}
placeholder="0"
numeric
flex={1}
/>
</View>
<View className="border-t border-border/40 pt-4 flex-row justify-between">
<Text className="text-foreground font-sans-bold text-[16px]">
Total
</Text>
<Text className="text-primary font-sans-bold text-[16px]">
{currency}{" "}
{(amount ? Number(amount) : computedTotal).toLocaleString(
"en-US",
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
)}
</Text>
</View>
</View>
</View>
)}
{step === 6 && (
<View className="bg-card rounded-[6px]">
<Field
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="e.g. Payment terms: Net 30"
multiline
/>
</View>
)}
{step === 7 && (
<>
<View className="gap-5 pb-4">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Summary
</Text>
<View className="bg-card rounded-[6px] gap-3">
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
Request Number
</Text>
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
{paymentRequestNumber}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
Customer
</Text>
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
{customerName}
</Text>
</View>
{customerEmail ? (
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
Email
</Text>
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
{customerEmail}
</Text>
</View>
) : null}
{customerPhone ? (
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
Phone
</Text>
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
+251{customerPhone}
</Text>
</View>
) : null}
{description ? (
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
Description
</Text>
<Text
className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4"
numberOfLines={2}
>
{description}
</Text>
</View>
) : null}
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
Issue Date
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{issueDate}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
Due Date
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{dueDate || "Not set"}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
Status
</Text>
<Text className="text-[14px] text-foreground font-sans-bold">
{status}
</Text>
</View>
{items.length > 0 && (
<View className="border-t border-border/40 pt-3">
<Text className="text-[12px] text-muted-foreground font-sans-bold uppercase tracking-widest mb-2">
Items ({items.length})
</Text>
{items.map((item, i) => (
<View
key={item.id}
className="flex-row justify-between py-1"
>
<Text
className="text-[13px] text-foreground font-sans-medium flex-1"
numberOfLines={1}
>
{item.description || `Item ${i + 1}`}
</Text>
<Text className="text-[13px] text-foreground font-sans-bold">
{item.qty} × {currency}{" "}
{parseFloat(item.price || "0").toFixed(2)}
</Text>
</View>
))}
</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 text-right flex-1 ml-4"
numberOfLines={2}
>
{notes}
</Text>
</View>
) : null}
{parseFloat(taxAmount) > 0 && (
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
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-foreground font-sans-medium">
Discount
</Text>
<Text className="text-[14px] text-foreground 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-[16px] font-sans-bold text-foreground">
Total Amount
</Text>
<Text className="text-[16px] font-sans-bold text-primary">
{currency}{" "}
{(amount ? Number(amount) : computedTotal).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={showStatus}
onClose={() => setShowStatus(false)}
title="Select Status"
>
{["DRAFT", "SENT", "OPENED", "PAID", "EXPIRED", "CANCELLED"].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>
);
}