Yaltopia-Tickets-App/app/payment-requests/create.tsx
2026-06-17 15:16:40 +03:00

733 lines
24 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 { ChevronDown, Plus, Trash2 } from "@/lib/icons";
type Item = { id: number; description: string; quantity: string; unitPrice: 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,
keyboardType,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder: string;
numeric?: boolean;
center?: boolean;
flex?: number;
multiline?: boolean;
keyboardType?: "default" | "numeric" | "email-address" | "phone-pad";
}) {
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={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 CHANNELS = ["EMAIL", "PHONE"];
const STEPS = [
{ key: "details", label: "Details" },
{ key: "customer", label: "Customer" },
{ key: "schedule", label: "Schedule" },
{ key: "items", label: "Items" },
{ key: "paymentMethod", label: "Payment" },
{ 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 [description, setDescription] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerId, setCustomerId] = useState("");
const [channel, setChannel] = useState("EMAIL");
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const [currency, setCurrency] = useState("ETB");
const [issueDate, setIssueDate] = useState(
new Date().toISOString().split("T")[0],
);
const [dueDate, setDueDate] = useState("");
const [companyPaymentMethodId, setCompanyPaymentMethodId] = useState("");
const [paymentMethods, setPaymentMethods] = useState<any[]>([]);
const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false);
const [items, setItems] = useState<Item[]>([
{ id: 1, description: "", quantity: "1", unitPrice: "" },
]);
const c = useInputColors();
const [showCurrency, setShowCurrency] = useState(false);
const [showIssueDate, setShowIssueDate] = useState(false);
const [showDueDate, setShowDueDate] = useState(false);
const [showChannel, setShowChannel] = useState(false);
const [showPaymentMethod, setShowPaymentMethod] = 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]);
}, []);
useEffect(() => {
(async () => {
setLoadingPaymentMethods(true);
try {
const res = await api.company.paymentMethods();
const list = Array.isArray(res) ? res : res?.data || [];
setPaymentMethods(list);
} catch {
setPaymentMethods([]);
} finally {
setLoadingPaymentMethods(false);
}
})();
}, []);
useEffect(() => {
if (channel === "EMAIL" && customerEmail && !recipient) {
setRecipient(customerEmail);
}
}, [channel, customerEmail]);
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: "", quantity: "1", unitPrice: "" },
]);
const removeItem = (id: number) => {
if (items.length > 1) setItems((prev) => prev.filter((it) => it.id !== id));
};
const subtotal = useMemo(
() =>
items.reduce(
(sum, item) =>
sum +
(parseFloat(item.quantity) || 0) * (parseFloat(item.unitPrice) || 0),
0,
),
[items],
);
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", "Please select a customer");
return;
}
const body = {
paymentRequestNumber,
customerName,
customerEmail: customerEmail || undefined,
channel,
recipient,
amount: amount ? Number(amount) : Number(subtotal.toFixed(2)),
currency,
issueDate: new Date(issueDate).toISOString(),
dueDate: new Date(dueDate).toISOString(),
companyPaymentMethodId: companyPaymentMethodId || undefined,
customerId: customerId || undefined,
...(description ? { description } : {}),
items: items.map((i) => ({
description: i.description || "Item",
quantity: parseFloat(i.quantity) || 0,
unitPrice: parseFloat(i.unitPrice) || 0,
total: Number(
((parseFloat(i.quantity) || 0) * (parseFloat(i.unitPrice) || 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);
}
};
const paymentMethodLabel = companyPaymentMethodId
? paymentMethods.find((pm: any) => pm.id === companyPaymentMethodId)
?.label || paymentMethods.find((pm: any) => pm.id === companyPaymentMethodId)
?.providerName || "Selected"
: "Select";
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"
multiline
/>
</View>
</View>
)}
{step === 1 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Customer Information
</Text>
<View className="bg-card rounded-[6px] gap-4">
<View>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
Customer
</Text>
<CustomerPicker
selectedIds={customerId ? [customerId] : []}
selectedCustomers={
customerId
? [
{
id: customerId,
name: customerName,
email: customerEmail,
phone: "",
},
]
: []
}
onSelect={(ids, customers) => {
setCustomerId(ids[0] || "");
setCustomerName(customers[0]?.name || "");
setCustomerEmail(customers[0]?.email || "");
}}
placeholder="Select a customer"
/>
</View>
<PickerField
label="Invite Channel"
value={channel}
onPress={() => setShowChannel(true)}
/>
<Field
label={channel === "EMAIL" ? "Recipient Email" : "Recipient Phone"}
value={recipient}
onChangeText={setRecipient}
placeholder={
channel === "EMAIL"
? "email@example.com"
: "912345678"
}
keyboardType={channel === "EMAIL" ? "email-address" : "phone-pad"}
/>
</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)}
/>
</View>
<Field
label="Amount"
value={amount}
onChangeText={setAmount}
placeholder={subtotal > 0 ? `${currency} ${subtotal.toFixed(2)}` : "Enter amount"}
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.quantity}
onChangeText={(v) => updateItem(item.id, "quantity", v)}
flex={1}
/>
<Field
label="Unit Price"
placeholder="0.00"
numeric
value={item.unitPrice}
onChangeText={(v) => updateItem(item.id, "unitPrice", v)}
flex={3}
/>
</View>
{parseFloat(item.quantity) > 0 && parseFloat(item.unitPrice) > 0 && (
<View className="flex-row justify-end mt-2">
<Text className="text-[12px] text-muted-foreground font-sans-medium">
= {currency}{" "}
{(
(parseFloat(item.quantity) || 0) *
(parseFloat(item.unitPrice) || 0)
).toFixed(2)}
</Text>
</View>
)}
</View>
))}
</View>
{subtotal > 0 && (
<View className="flex-row justify-end items-center gap-2 pt-2 border-t border-border">
<Text className="text-[14px] text-muted-foreground font-sans-medium">
Subtotal
</Text>
<Text className="text-[15px] text-foreground font-sans-bold">
{currency} {subtotal.toFixed(2)}
</Text>
</View>
)}
</View>
)}
{step === 4 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Payment Method
</Text>
<View className="bg-card rounded-[6px] gap-4">
<View>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
Company Payment Method
</Text>
<Pressable
onPress={() => {
if (paymentMethods.length > 0) {
setShowPaymentMethod(true);
} else {
toast.error(
"No Methods",
"No payment methods available. Please configure one in company settings.",
);
}
}}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
{loadingPaymentMethods ? (
<ActivityIndicator color="#ea580c" size="small" />
) : (
<>
<Text
className="text-xs font-sans-medium flex-1"
style={{
color: companyPaymentMethodId ? c.text : c.placeholder,
}}
numberOfLines={1}
>
{paymentMethodLabel}
</Text>
<ChevronDown size={14} color={c.text} strokeWidth={3} />
</>
)}
</Pressable>
</View>
</View>
</View>
)}
{step === 5 && (
<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">
<SummaryRow label="Request Number" value={paymentRequestNumber} />
{description ? (
<SummaryRow label="Description" value={description} multiline />
) : null}
<SummaryRow label="Customer" value={customerName} />
<SummaryRow label="Channel" value={channel} />
<SummaryRow label="Recipient" value={recipient} />
<View className="border-t border-border/40 my-1" />
<SummaryRow label="Issue Date" value={issueDate} />
<SummaryRow label="Due Date" value={dueDate || "Not set"} />
<SummaryRow label="Currency" value={currency} />
{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.quantity} × {currency}{" "}
{parseFloat(item.unitPrice || "0").toFixed(2)}
</Text>
</View>
))}
</View>
)}
{paymentMethodLabel !== "Select" && (
<SummaryRow label="Payment Method" value={paymentMethodLabel} />
)}
<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) : subtotal).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={showChannel}
onClose={() => setShowChannel(false)}
title="Invite Channel"
>
{CHANNELS.map((ch) => (
<SelectOption
key={ch}
label={ch}
value={ch}
selected={channel === ch}
onSelect={(v) => {
setChannel(v);
setShowChannel(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={showPaymentMethod}
onClose={() => setShowPaymentMethod(false)}
title="Payment Method"
>
{paymentMethods.map((pm: any) => (
<SelectOption
key={pm.id}
label={pm.label || pm.providerName || pm.bankName || "Method"}
value={pm.id}
selected={companyPaymentMethodId === pm.id}
onSelect={(v) => {
setCompanyPaymentMethodId(v);
setShowPaymentMethod(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>
);
}
function SummaryRow({
label,
value,
multiline,
}: {
label: string;
value: string;
multiline?: boolean;
}) {
return (
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-medium">
{label}
</Text>
<Text
className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4"
numberOfLines={multiline ? undefined : 2}
>
{value}
</Text>
</View>
);
}