diff --git a/app.json b/app.json index 169e41b..479adf0 100644 --- a/app.json +++ b/app.json @@ -22,7 +22,10 @@ "com.googleusercontent.apps.1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi" ] } - ] + ], + "NSAppTransportSecurity": { + "NSAllowsArbitraryLoads": true + } } }, "android": { diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index c4cd1ca..5075619 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -19,18 +19,16 @@ import { DollarSign, FileText, ShieldCheck, - Receipt, Wallet, ChevronRight, AlertTriangle, - Banknote, FileCheck, + Building2, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; import { EmptyState } from "@/components/EmptyState"; import { useAuthStore } from "@/lib/auth-store"; -import { getProviderLogo, isCash } from "@/lib/payment-providers"; interface NewsItem { id: string; @@ -49,6 +47,8 @@ interface Payment { currency: string; paymentDate: string; paymentMethod: string; + financialInstitution?: string; + financialInstitutionLogoUrl?: string; isFlagged: boolean; senderName?: string; receiverName?: string; @@ -222,11 +222,6 @@ export default function HomeScreen() { label="Proforma" onPress={() => nav.go("proforma")} /> - } - label="Receipt" - onPress={() => nav.go("add-receipt")} - /> @@ -241,6 +236,13 @@ export default function HomeScreen() { label="Declaration" onPress={() => nav.go("declarations/index")} /> + + } + label="Company" + onPress={() => nav.go("company-details")} + /> @@ -273,8 +275,7 @@ export default function HomeScreen() { const dateStr = new Date( pay.paymentDate, ).toLocaleDateString(); - const logo = getProviderLogo(pay.paymentMethod); - const cash = isCash(pay.paymentMethod); + const logoUrl = pay.financialInstitutionLogoUrl; const hasFlag = pay.isFlagged; return ( - {logo ? ( - + {logoUrl ? ( + @@ -294,11 +295,7 @@ export default function HomeScreen() { ) : ( {hasFlag ? ( @@ -307,12 +304,6 @@ export default function HomeScreen() { size={18} strokeWidth={2} /> - ) : cash ? ( - ) : ( { try { @@ -171,7 +173,7 @@ export default function InvoicesTabScreen() { {/* Create button */} ); diff --git a/app/customers/[id].tsx b/app/customers/[id].tsx index f73039e..7e8e48a 100644 --- a/app/customers/[id].tsx +++ b/app/customers/[id].tsx @@ -1,12 +1,10 @@ -import React, { useState, useCallback, useMemo } from "react"; +import React, { useState, useCallback } from "react"; import { View, ScrollView, ActivityIndicator, Pressable, - TextInput, Modal, - Dimensions, } from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; @@ -22,19 +20,13 @@ import { Tag, ShieldCheck, BookOpen, - FileText, - Wallet, - Plus, - Search, - X, + Pencil, + Trash2, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; import { api } from "@/lib/api"; import { toast } from "@/lib/toast-store"; -import { useColorScheme } from "nativewind"; - -const { height: SCREEN_HEIGHT } = Dimensions.get("window"); export default function CustomerDetailScreen() { const nav = useSirouRouter(); @@ -42,14 +34,8 @@ export default function CustomerDetailScreen() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - - const [proformaItems, setProformaItems] = useState([]); - const [paymentRequestItems, setPaymentRequestItems] = useState([]); - const [showProformaSheet, setShowProformaSheet] = useState(false); - const [showPaymentRequestSheet, setShowPaymentRequestSheet] = useState(false); - const [sheetLoading, setSheetLoading] = useState(false); - const [proformaSearch, setProformaSearch] = useState(""); - const [paymentReqSearch, setPaymentReqSearch] = useState(""); + const [deleting, setDeleting] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); const fetch = useCallback(async () => { try { @@ -68,56 +54,21 @@ export default function CustomerDetailScreen() { useFocusEffect(useCallback(() => { fetch(); }, [fetch])); - const openProformaSheet = async () => { - setSheetLoading(true); - setShowProformaSheet(true); - setProformaSearch(""); + const handleDelete = async () => { try { - const res = await api.proforma.getAll({ query: { page: 1, limit: 50 } }); - setProformaItems(res?.data || []); - } catch { - setProformaItems([]); + setDeleting(true); + const cId = Array.isArray(id) ? id[0] : id; + await api.customers.delete({ params: { id: cId } }); + toast.success("Deleted", "Customer has been deleted"); + setShowDeleteModal(false); + nav.back(); + } catch (err: any) { + toast.error("Error", err?.message || "Failed to delete customer"); } finally { - setSheetLoading(false); + setDeleting(false); } }; - const openPaymentRequestSheet = async () => { - setSheetLoading(true); - setShowPaymentRequestSheet(true); - setPaymentReqSearch(""); - try { - const res = await api.paymentRequests.getAll({ query: { page: 1, limit: 50 } }); - setPaymentRequestItems(res?.data || []); - } catch { - setPaymentRequestItems([]); - } finally { - setSheetLoading(false); - } - }; - - const filteredProformas = useMemo(() => { - if (!proformaSearch.trim()) return proformaItems; - const q = proformaSearch.toLowerCase(); - return proformaItems.filter( - (p: any) => - (p.proformaNumber || "")?.toLowerCase().includes(q) || - (p.customerName || "")?.toLowerCase().includes(q) || - (String(p.amount || "")).includes(q), - ); - }, [proformaItems, proformaSearch]); - - const filteredPaymentRequests = useMemo(() => { - if (!paymentReqSearch.trim()) return paymentRequestItems; - const q = paymentReqSearch.toLowerCase(); - return paymentRequestItems.filter( - (r: any) => - (r.paymentRequestNumber || "")?.toLowerCase().includes(q) || - (r.customerName || "")?.toLowerCase().includes(q) || - (String(r.amount || "")).includes(q), - ); - }, [paymentRequestItems, paymentReqSearch]); - if (loading) { return ( @@ -148,9 +99,6 @@ export default function CustomerDetailScreen() { const isCompany = data?.type === "COMPANY"; const d = data || {}; - const goProformaCreate = () => nav.go("proforma/create"); - const goPaymentRequestCreate = () => nav.go("payment-requests/create"); - return ( @@ -283,218 +231,87 @@ export default function CustomerDetailScreen() { {/* Action Buttons */} { + const cId = Array.isArray(id) ? id[0] : id; + nav.go("customers/edit", { id: cId }); + }} + className="bg-primary h-11 rounded-[6px] flex-row items-center justify-center gap-2" > - + - Proformas + Edit Customer setShowDeleteModal(true)} + className="bg-red-500 h-11 rounded-[6px] flex-row items-center justify-center gap-2" > - + - Payment Requests + Delete Customer - {/* Proforma Bottom Sheet */} - setShowProformaSheet(false)} - loading={sheetLoading} - items={filteredProformas} - search={proformaSearch} - onSearchChange={setProformaSearch} - onCreateNew={goProformaCreate} - onSelectItem={(id: string) => { - setShowProformaSheet(false); - nav.go("proforma/[id]", { id }); - }} - /> - - {/* Payment Request Bottom Sheet */} - setShowPaymentRequestSheet(false)} - loading={sheetLoading} - items={filteredPaymentRequests} - search={paymentReqSearch} - onSearchChange={setPaymentReqSearch} - onCreateNew={goPaymentRequestCreate} - onSelectItem={(id: string) => { - setShowPaymentRequestSheet(false); - nav.go("payment-requests/[id]", { id }); - }} - type="payment" - /> - - ); -} - -function ProformaSheet({ - visible, - onClose, - loading, - items, - search, - onSearchChange, - onCreateNew, - onSelectItem, - type = "proforma", -}: { - visible: boolean; - onClose: () => void; - loading: boolean; - items: any[]; - search: string; - onSearchChange: (v: string) => void; - onCreateNew: () => void; - onSelectItem: (id: string) => void; - type?: "proforma" | "payment"; -}) { - const { colorScheme } = useColorScheme(); - const isDark = colorScheme === "dark"; - const label = type === "proforma" ? "Proforma" : "Payment Request"; - - return ( - - - + {/* Delete Confirmation Modal */} + setShowDeleteModal(false)} + > + setShowDeleteModal(false)} + > e.stopPropagation()} > - {/* Header */} - - - - {label}s - - - - - - - {/* Search */} - - - - - {search.length > 0 && ( - onSearchChange("")}> - - - )} + + + + + Delete Customer? + + + This will permanently delete{" "} + + {d.displayName} + {" "} + and all associated data. This action cannot be undone. + - {/* Create New */} - + - - - Create New {label} + {deleting ? ( + + ) : ( + + Yes, Delete + + )} + + setShowDeleteModal(false)} + className="bg-secondary h-12 rounded-[6px] items-center justify-center border border-border" + > + + Cancel - - {/* List */} - - {loading ? ( - - - - ) : items.length === 0 ? ( - - - {search ? `No ${label}s match your search` : `No ${label}s found`} - - - ) : ( - items.map((item: any) => { - const num = type === "proforma" - ? item.proformaNumber - : item.paymentRequestNumber; - const status = (item.status || "DRAFT").toUpperCase(); - const st: Record = { - PAID: { label: "Paid", bg: "bg-emerald-500/10", text: "text-emerald-600" }, - PENDING: { label: "Pending", bg: "bg-amber-500/10", text: "text-amber-600" }, - DRAFT: { label: "Draft", bg: "bg-blue-500/10", text: "text-blue-600" }, - CANCELLED: { label: "Cancelled", bg: "bg-slate-500/10", text: "text-slate-600" }, - }; - const s = st[status] || st.DRAFT; - return ( - onSelectItem(item.id)} - className="bg-card rounded-[6px] border border-border p-4 mb-3" - > - - - - {num || item.id?.slice(0, 8) || "—"} - - - {item.customerName || "—"} - - - - {item.amount != null ? Number(item.amount).toLocaleString("en-US", { minimumFractionDigits: 2 }) : "—"} - - - - - - {s.label} - - - {item.issueDate && ( - - {new Date(item.issueDate).toLocaleDateString()} - - )} - - - ); - }) - )} - - - - + + + ); } diff --git a/app/customers/edit.tsx b/app/customers/edit.tsx new file mode 100644 index 0000000..5f229df --- /dev/null +++ b/app/customers/edit.tsx @@ -0,0 +1,525 @@ +import React, { useState, useEffect } from "react"; +import { View, Pressable, TextInput, StyleSheet, ActivityIndicator } from "react-native"; +import { useSirouRouter } from "@sirou/react-native"; +import { useColorScheme } from "nativewind"; +import { useLocalSearchParams } from "expo-router"; + +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 { Text } from "@/components/ui/text"; + +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 ( + + + {label} + + + + ); +} + +const TYPES = ["INDIVIDUAL", "COMPANY"] as const; + +const STEPS = [ + { key: "type", label: "Type" }, + { key: "details", label: "Details" }, + { key: "documents", label: "Documents" }, + { key: "notes", label: "Notes" }, + { key: "summary", label: "Summary" }, +]; + +function stripPhone(p?: string | null): string { + if (!p) return ""; + return p.replace(/^\+?251/, ""); +} + +export default function EditCustomerScreen() { + const nav = useSirouRouter(); + const { id } = useLocalSearchParams<{ id: string }>(); + + const [step, setStep] = useState(0); + const [submitting, setSubmitting] = useState(false); + const [loadingData, setLoadingData] = useState(true); + const c = useInputColors(); + + const [type, setType] = useState<"INDIVIDUAL" | "COMPANY">("INDIVIDUAL"); + const [displayName, setDisplayName] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [companyName, setCompanyName] = useState(""); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + const [tin, setTin] = useState(""); + const [vatReg, setVatReg] = useState(""); + const [businessLicense, setBusinessLicense] = useState(""); + const [address, setAddress] = useState(""); + const [notes, setNotes] = useState(""); + + useEffect(() => { + (async () => { + try { + setLoadingData(true); + const cId = Array.isArray(id) ? id[0] : id; + if (!cId) return; + const data = await api.customers.getById({ params: { id: cId } }); + setType(data.type || "INDIVIDUAL"); + setDisplayName(data.displayName || ""); + setFirstName(data.firstName || ""); + setLastName(data.lastName || ""); + setCompanyName(data.companyName || ""); + setEmail(data.email || ""); + setPhone(stripPhone(data.phone)); + setTin(data.tin || ""); + setVatReg(data.vatRegistrationNumber || ""); + setBusinessLicense(data.businessLicenseNumber || ""); + setAddress(data.address || ""); + setNotes(data.notes || ""); + } catch { + toast.error("Error", "Failed to load customer"); + } finally { + setLoadingData(false); + } + })(); + }, [id]); + + const handleNext = () => { + if (step === 0 && !displayName.trim()) { + toast.error("Validation", "Display name is required"); + return; + } + if (step === 1 && type === "INDIVIDUAL" && !firstName.trim()) { + toast.error("Validation", "First name is required"); + return; + } + if (step === 1 && type === "COMPANY" && !companyName.trim()) { + toast.error("Validation", "Company name is required"); + return; + } + setStep(step + 1); + }; + + const handleSubmit = async () => { + const body: Record = { + type, + displayName, + email: email || undefined, + phone: phone ? `+251${phone.replace(/^\+/, "")}` : undefined, + tin: tin || undefined, + vatRegistrationNumber: vatReg || undefined, + businessLicenseNumber: businessLicense || undefined, + address: address || undefined, + firstName: firstName || undefined, + lastName: lastName || undefined, + companyName: companyName || undefined, + notes: notes || undefined, + }; + + Object.keys(body).forEach((k) => body[k] === undefined && delete body[k]); + + try { + setSubmitting(true); + const cId = Array.isArray(id) ? id[0] : id; + await api.customers.update({ params: { id: cId }, body }); + toast.success("Success", "Customer updated successfully!"); + nav.back(); + } catch (err: any) { + toast.error("Error", err?.message || "Failed to update customer"); + } finally { + setSubmitting(false); + } + }; + + if (loadingData) { + return ( + + + + + + ); + } + + return ( + + setStep(step - 1)} + onComplete={handleSubmit} + loading={submitting} + completeLabel="Update Customer" + > + {step === 0 && ( + + + Customer Type + + + + {TYPES.map((t) => ( + setType(t)} + className={`flex-1 py-3 rounded-[6px] items-center border ${ + type === t + ? "bg-primary border-primary" + : "bg-card border-border" + }`} + > + + {t === "INDIVIDUAL" ? "Individual" : "Company"} + + + ))} + + + {type === "INDIVIDUAL" && ( + + )} + + + )} + + {step === 1 && ( + + + {type === "INDIVIDUAL" ? "Personal Details" : "Company Details"} + + + {type === "INDIVIDUAL" ? ( + <> + + + + + + + Phone + + + +251 + + + + + ) : ( + <> + + + + + + + Phone + + + +251 + + + + + )} + + + + )} + + {step === 2 && ( + + + Documents + + + + + + + + )} + + {step === 3 && ( + + + + + + )} + + {step === 4 && ( + + + Summary + + + + + Type + + + {type === "INDIVIDUAL" ? "Individual" : "Company"} + + + + + Display Name + + + {displayName} + + + {firstName ? ( + + + First Name + + + {firstName} + + + ) : null} + {lastName ? ( + + + Last Name + + + {lastName} + + + ) : null} + {companyName ? ( + + + Company + + + {companyName} + + + ) : null} + {email ? ( + + + Email + + + {email} + + + ) : null} + {phone ? ( + + + Phone + + + +251{phone} + + + ) : null} + {tin ? ( + + + TIN + + + {tin} + + + ) : null} + {vatReg ? ( + + + VAT Reg + + + {vatReg} + + + ) : null} + {businessLicense ? ( + + + Business License + + + {businessLicense} + + + ) : null} + {address ? ( + + + Address + + + {address} + + + ) : null} + {notes ? ( + + + Notes + + + {notes} + + + ) : null} + + + )} + + + ); +} diff --git a/app/declarations/create.tsx b/app/declarations/create.tsx index b73efa6..24e03a3 100644 --- a/app/declarations/create.tsx +++ b/app/declarations/create.tsx @@ -30,6 +30,8 @@ import { useAuthStore } from "@/lib/auth-store"; import { PickerModal, SelectOption } from "@/components/PickerModal"; import { FormFlow } from "@/components/FormFlow"; import { getPlaceholderColor } from "@/lib/colors"; +import { CalendarGrid } from "@/components/CalendarGrid"; +import { getScanData } from "@/lib/scan-cache"; const { height: SCREEN_HEIGHT } = Dimensions.get("window"); @@ -187,6 +189,9 @@ export default function CreateDeclarationScreen() { // Pickers & Modals const [showTypePicker, setShowTypePicker] = useState(false); const [showPeriodPicker, setShowPeriodPicker] = useState(false); + const [showPeriodStart, setShowPeriodStart] = useState(false); + const [showPeriodEnd, setShowPeriodEnd] = useState(false); + const [showDueDate, setShowDueDate] = useState(false); const [showInvoicePicker, setShowInvoicePicker] = useState(false); const [invoices, setInvoices] = useState([]); const [invoiceSearch, setInvoiceSearch] = useState(""); @@ -200,6 +205,29 @@ export default function CreateDeclarationScreen() { { key: "review", label: "Review" }, ]; + useEffect(() => { + const scan = getScanData(); + if (scan?.type === "declaration" && scan.data) { + const d = scan.data; + if (d.type) setType(d.type); + if (d.header?.declarationNumber) setDeclarationNumber(d.header.declarationNumber); + if (d.header?.title) setTitle(d.header.title); + if (d.suggestedTitle) setTitle(d.suggestedTitle); + if (d.suggestedPeriodStart) { + const start = new Date(d.suggestedPeriodStart).toISOString().split("T")[0]; + setPeriodStart(start); + } + if (d.suggestedPeriodEnd) { + const end = new Date(d.suggestedPeriodEnd).toISOString().split("T")[0]; + setPeriodEnd(end); + } + if (d.header?.tin) setTin(d.header.tin); + if (d.header?.taxAccountNumber) setTaxAccountNumber(d.header.taxAccountNumber); + if (d.suggestedFilename) setSuggestedFilename(d.suggestedFilename); + toast.success("Scan Complete", "Declaration data extracted from scan."); + } + }, []); + const openInvoicePicker = async () => { setLoadingInvoices(true); try { @@ -411,28 +439,23 @@ export default function CreateDeclarationScreen() { required /> - setShowPeriodStart(true)} required - flex={1} /> - setShowPeriodEnd(true)} required - flex={1} /> - setShowDueDate(true)} /> @@ -785,6 +808,45 @@ export default function CreateDeclarationScreen() { /> ))} + setShowPeriodStart(false)} + title="Period Start" + > + { + setPeriodStart(v); + setShowPeriodStart(false); + }} + /> + + setShowPeriodEnd(false)} + title="Period End" + > + { + setPeriodEnd(v); + setShowPeriodEnd(false); + }} + /> + + setShowDueDate(false)} + title="Due Date" + > + { + setDueDate(v); + setShowDueDate(false); + }} + /> + ); } diff --git a/app/declarations/index.tsx b/app/declarations/index.tsx index 60580d3..4765f7f 100644 --- a/app/declarations/index.tsx +++ b/app/declarations/index.tsx @@ -14,12 +14,13 @@ import { api } from "@/lib/api"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Plus, Search, FileText, Calendar, ChevronRight } from "@/lib/icons"; +import { Plus, Search, FileText } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; import { EmptyState } from "@/components/EmptyState"; import { useColorScheme } from "nativewind"; import { getPlaceholderColor } from "@/lib/colors"; +import { CreateMethodSheet } from "@/components/CreateMethodSheet"; const TYPE_OPTIONS = ["All", "VAT", "WITHHOLDING_TAX"]; const STATUS_OPTIONS = ["All", "DRAFT", "SUBMITTED", "PAID", "CANCELLED"]; @@ -69,6 +70,7 @@ export default function DeclarationsScreen() { const [search, setSearch] = useState(""); const [typeFilter, setTypeFilter] = useState("All"); const [statusFilter, setStatusFilter] = useState("All"); + const [showCreateSheet, setShowCreateSheet] = useState(false); const fetchPage = useCallback( async (pageNum: number, replace = false) => { @@ -178,7 +180,7 @@ export default function DeclarationsScreen() { + nav.back()} + className="mt-4 border border-border w-3/4 rounded-[12px] py-3 flex-row justify-center items-center" + > + Go Back + + + ); + } + + return ( + + {previewUri ? ( + + + + + + Preview + + setPreviewUri(null)} + className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20" + > + + + + + + + + + + + + ) : ( + + + {/* Top bar */} + + setTorch(!torch)} + className={`h-12 w-12 rounded-full items-center justify-center border border-white/20 ${torch ? "bg-primary" : "bg-black/40"}`} + > + + + + nav.back()} + className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20" + > + + + + + {/* Scan Frame */} + + + + + + + {/* Capture Button */} + + + + + + + Tap to Capture + + + + + + )} + + ); +} diff --git a/app/edit-profile.tsx b/app/edit-profile.tsx index 81c58b7..74c80b2 100644 --- a/app/edit-profile.tsx +++ b/app/edit-profile.tsx @@ -2,31 +2,57 @@ import React, { useState } from "react"; import { View, ScrollView, - Pressable, TextInput, ActivityIndicator, - useColorScheme, + StyleSheet, } from "react-native"; import { useSirouRouter } from "@sirou/react-native"; +import { useColorScheme } from "nativewind"; import { AppRoutes } from "@/lib/routes"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; import { ScreenWrapper } from "@/components/ScreenWrapper"; -import { ArrowLeft, User, Mail, Check } from "@/lib/icons"; +import { StandardHeader } from "@/components/StandardHeader"; import { useAuthStore } from "@/lib/auth-store"; import { api } from "@/lib/api"; import { toast } from "@/lib/toast-store"; +const S = StyleSheet.create({ + input: { + paddingHorizontal: 14, + paddingVertical: 13, + fontSize: 15, + fontWeight: "500", + borderRadius: 10, + 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)", + }; +} + export default function EditProfileScreen() { const nav = useSirouRouter(); const { user, updateUser } = useAuthStore(); - const isDark = useColorScheme() === "dark"; - const iconColor = isDark ? "#94a3b8" : "#64748b"; + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + const c = useInputColors(); const [loading, setLoading] = useState(false); const [firstName, setFirstName] = useState(user?.firstName || ""); const [lastName, setLastName] = useState(user?.lastName || ""); + const initials = `${user?.firstName?.[0] || ""}${user?.lastName?.[0] || ""}`.toUpperCase(); + const handleSave = async () => { if (!firstName.trim() || !lastName.trim()) { toast.error("Error", "First and last name are required"); @@ -53,66 +79,57 @@ export default function EditProfileScreen() { return ( - - nav.back()} - className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" - > - - - - Edit Profile - - - + - + + + + {initials || "?"} + + + + {user?.firstName} {user?.lastName} + + + {user?.email} + + + + First Name - - - - {firstName.trim().length > 0 && ( - - )} - + Last Name - - - - {lastName.trim().length > 0 && ( - - )} - + {user?.email && ( @@ -120,40 +137,47 @@ export default function EditProfileScreen() { Email - - - - {user.email} - + + {user.email} )} + - - - - nav.back()} - className="h-12 rounded-[8px] border border-border items-center justify-center" - disabled={loading} - > - - Cancel + + + + diff --git a/app/invoices/[id].tsx b/app/invoices/[id].tsx index f58f2ff..abbe7f1 100644 --- a/app/invoices/[id].tsx +++ b/app/invoices/[id].tsx @@ -8,6 +8,7 @@ import { Pressable, Modal, Dimensions, + Image, } from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; @@ -28,11 +29,12 @@ import { X, MoreVertical, FileText, - Receipt, CreditCard, ChevronRight, Check, Edit, + Camera, + ArrowUpRight, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; @@ -40,7 +42,10 @@ import { api, BASE_URL } from "@/lib/api"; import { toast } from "@/lib/toast-store"; import { useAuthStore } from "@/lib/auth-store"; import { ActionModal } from "@/components/ActionModal"; +import { EmptyState } from "@/components/EmptyState"; +import { WebView } from "react-native-webview"; import { UploadIcon } from "lucide-react-native"; +import ticketImage from "@/assets/ticket.png"; const { height: SCREEN_HEIGHT } = Dimensions.get("window"); @@ -56,7 +61,13 @@ export default function InvoiceDetailScreen() { const [showShareSheet, setShowShareSheet] = useState(false); const [showMoreSheet, setShowMoreSheet] = useState(false); const [sharing, setSharing] = useState(false); - const [activeTab, setActiveTab] = useState<"details" | "activity">("details"); + const [activeTab, setActiveTab] = useState<"details" | "items" | "image">( + "details", + ); + const [showImageFullScreen, setShowImageFullScreen] = useState(false); + const [imageLoading, setImageLoading] = useState(false); + + const token = useAuthStore((state) => state.token); useFocusEffect( useCallback(() => { @@ -163,7 +174,11 @@ export default function InvoiceDetailScreen() { // Robust data extraction const originalData = invoice.scannedData?.originalData || {}; const items = - (invoice.items?.length > 0 ? invoice.items : originalData.items) || []; + (invoice.items?.length > 0 + ? invoice.items + : invoice.scannedData?.items?.length > 0 + ? invoice.scannedData.items + : originalData.items) || []; const taxAmountValue = Number( typeof invoice.taxAmount === "object" @@ -208,6 +223,25 @@ export default function InvoiceDetailScreen() { CANCELLED: "Cancelled", }; + // Scanned image URL — try several common fields + const scannedImageRaw = + invoice.scannedData?.imageUrl || + invoice.scannedData?.image || + invoice.scannedData?.imagePath || + invoice.scannedData?.originalData?.imageUrl || + invoice.imageUrl || + invoice.imagePath || + invoice.receiptPath || + null; + + const scannedImageUrl = scannedImageRaw + ? scannedImageRaw.startsWith("http") + ? scannedImageRaw + : `${BASE_URL}${scannedImageRaw.replace(/^\//, "")}` + : null; + + const hasScannedImage = Boolean(invoice?.isScanned && scannedImageUrl); + const customerName = ( invoice.customerName?.replace("Customer Name: ", "") || "Walking Client" ).trim(); @@ -218,6 +252,8 @@ export default function InvoiceDetailScreen() { ? new Date(invoice.issueDate) : new Date(invoice.createdAt); + const paidDate = invoice.paidDate ? new Date(invoice.paidDate) : null; + const formatLongDate = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", @@ -250,96 +286,13 @@ export default function InvoiceDetailScreen() { - - - - - - - - - - $ - - - - + - {isPaid ? "Payment Date" : "Due Date"} + Due Date - {isPaid - ? `Paid at ${formatLongDate(paymentDate)}` - : formatLongDate(paymentDate)} + {formatLongDate(paymentDate)} + {paidDate && ( + + + Paid Date + + + + + {formatLongDate(paidDate)} + + + + )} @@ -428,23 +392,39 @@ export default function InvoiceDetailScreen() { )} - setActiveTab("activity")} - className="pb-2.5" - > + setActiveTab("items")} className="pb-2.5"> - Activity Log + Items - {activeTab === "activity" && ( + {activeTab === "items" && ( )} + {hasScannedImage && ( + setActiveTab("image")} + className="pb-2.5" + > + + Image + + {activeTab === "image" && ( + + )} + + )} @@ -479,17 +459,18 @@ export default function InvoiceDetailScreen() { - - - Notes - - - {invoice.notes || "-"} - - + {invoice.notes && ( + + + Note + + + + {invoice.notes} + + + + )} {items.length > 0 ? ( @@ -527,7 +508,7 @@ export default function InvoiceDetailScreen() { className="text-foreground text-[14px] font-sans-bold" numberOfLines={1} > - {item.description || `Item ${idx + 1}`} + {item.description || "No item"} {qty} ×{" "} @@ -619,79 +600,76 @@ export default function InvoiceDetailScreen() { - ) : ( + ) : activeTab === "image" ? ( - {items.length > 0 ? ( + {hasScannedImage ? ( - Items + Scanned Document - - {items.map((item: any, idx: number) => { - const qty = Number( - item.quantity?.value || item.quantity || 1, - ); - const unitPrice = Number( - item.unitPrice?.value || item.unitPrice || 0, - ); - const lineTotal = Number( - item.total?.value || item.total || qty * unitPrice, - ); - return ( - - - - - - - {item.description || `Item ${idx + 1}`} - - - {qty} ×{" "} - {unitPrice.toLocaleString("en-US", { - minimumFractionDigits: 2, - })}{" "} - {invoice.currency || "USD"} - - - - {lineTotal.toLocaleString("en-US", { - minimumFractionDigits: 2, - })}{" "} - {invoice.currency || "USD"} + setShowImageFullScreen(true)} + className="rounded-[10px] overflow-hidden border border-border bg-card active:opacity-80" + > + setImageLoading(true)} + onLoadEnd={() => setImageLoading(false)} + onError={() => { + setImageLoading(false); + toast.error( + "Image Error", + "Failed to load scanned image.", + ); + }} + renderError={() => ( + + + + Failed to load image - ); - })} - + )} + /> + {imageLoading && ( + + + + )} + + + + + + Tap to view full screen + ) : ( - + - No activity yet + No image available - Events related to this invoice will show up here. + The scanned image could not be found. )} - )} + ) : null} + {/* Full screen image viewer */} + setShowImageFullScreen(false)} + > + setShowImageFullScreen(false)} + > + + {scannedImageUrl && ( + + )} + + setShowImageFullScreen(false)} + className="absolute top-12 right-5 h-10 w-10 rounded-full bg-black/60 items-center justify-center border border-white/20" + > + + + + + setShowDeleteModal(false)} diff --git a/app/invoices/create.tsx b/app/invoices/create.tsx index de9bb89..5e9d7bf 100644 --- a/app/invoices/create.tsx +++ b/app/invoices/create.tsx @@ -29,6 +29,7 @@ import * as ImagePicker from "expo-image-picker"; import { PickerModal, SelectOption } from "@/components/PickerModal"; import { CalendarGrid } from "@/components/CalendarGrid"; import { CustomerPicker } from "@/components/CustomerPicker"; +import { ConfirmSubmitModal } from "@/components/ConfirmSubmitModal"; import { getPlaceholderColor } from "@/lib/colors"; import { getScanData } from "@/lib/scan-cache"; @@ -155,11 +156,15 @@ export default function CreateInvoiceScreen() { const [step, setStep] = useState(0); const [submitting, setSubmitting] = useState(false); const [scanFailures, setScanFailures] = useState(0); + const [showConfirm, setShowConfirm] = useState(false); + const [scanRecordId, setScanRecordId] = useState(null); const [invoiceNumber, setInvoiceNumber] = useState(""); + const [customerId, setCustomerId] = useState(""); const [customerName, setCustomerName] = useState(""); const [customerEmail, setCustomerEmail] = useState(""); const [customerPhone, setCustomerPhone] = useState(""); + const [selectedCustomers, setSelectedCustomers] = useState([]); const [description, setDescription] = useState(""); const [currency, setCurrency] = useState("ETB "); const [type, setType] = useState("SALES"); @@ -198,41 +203,42 @@ export default function CreateInvoiceScreen() { d.setDate(d.getDate() + 30); setDueDate(d.toISOString().split("T")[0]); - const scanData = getScanData(); - if (scanData) { - if (scanData.invoiceNumber) setInvoiceNumber(scanData.invoiceNumber); - const name = - scanData.customerName - ?.trim() - ?.replace(/^(Customer Name:|Bill To:)\s*/i, "") || ""; - if (name) setCustomerName(name); - if (scanData.customerEmail) setCustomerEmail(scanData.customerEmail); - if (scanData.customerPhone) setCustomerPhone(scanData.customerPhone.replace(/^\+251/, "")); - if (scanData.description) setDescription(scanData.description); - if (scanData.currency) setCurrency(scanData.currency); - if (scanData.taxAmount != null) setTaxAmount(String(scanData.taxAmount)); - if (scanData.issueDate) { - try { - setIssueDate( - new Date(scanData.issueDate).toISOString().split("T")[0], - ); - } catch (_) {} - } - if (scanData.dueDate) { - try { - setDueDate(new Date(scanData.dueDate).toISOString().split("T")[0]); - } catch (_) {} - } - if (scanData.items && scanData.items.length > 0) { - setItems( - scanData.items.map((item: any, idx: number) => ({ - id: idx + 1, - description: item.description || "", - qty: String(item.quantity || "1"), - price: String(item.unitPrice || item.total || ""), - })), + const payload = getScanData(); + if (!payload || payload.type !== "invoice" || !payload.id) return; + setScanRecordId(payload.id); + const scanData = payload.data || {}; + if (scanData.invoiceNumber) setInvoiceNumber(scanData.invoiceNumber); + const name = + scanData.customerName + ?.trim() + ?.replace(/^(Customer Name:|Bill To:)\s*/i, "") || ""; + if (name) setCustomerName(name); + if (scanData.customerEmail) setCustomerEmail(scanData.customerEmail); + if (scanData.customerPhone) setCustomerPhone(scanData.customerPhone.replace(/^\+251/, "")); + if (scanData.description) setDescription(scanData.description); + if (scanData.currency) setCurrency(scanData.currency); + if (scanData.taxAmount != null) setTaxAmount(String(scanData.taxAmount)); + if (scanData.issueDate) { + try { + setIssueDate( + new Date(scanData.issueDate).toISOString().split("T")[0], ); - } + } catch (_) {} + } + if (scanData.dueDate) { + try { + setDueDate(new Date(scanData.dueDate).toISOString().split("T")[0]); + } catch (_) {} + } + if (scanData.items && scanData.items.length > 0) { + setItems( + scanData.items.map((item: any, idx: number) => ({ + id: idx + 1, + description: item.description || "", + qty: String(item.quantity || "1"), + price: String(item.unitPrice || item.total || ""), + })), + ); } }, []); @@ -290,6 +296,7 @@ export default function CreateInvoiceScreen() { throw new Error(scanResult.message || "Extraction failed"); toast.success("Success!", "Data extracted."); const ocr = scanResult.data || {}; + if (scanResult.invoiceId) setScanRecordId(scanResult.invoiceId); if (ocr.invoiceNumber) setInvoiceNumber(ocr.invoiceNumber); const name = (ocr.customerName?.trim() || "").replace( /^(Customer Name:|Bill To:)\s*/i, @@ -379,44 +386,51 @@ export default function CreateInvoiceScreen() { } setSubmitting(true); try { - await api.invoices.create({ - body: { - invoiceNumber, - customerName, - customerEmail, - customerPhone: customerPhone ? `+251${customerPhone}` : "", - amount: Number(total.toFixed(2)), - currency, - type, - status, - issueDate: new Date(issueDate).toISOString(), - dueDate: new Date(dueDate).toISOString(), - description, - notes, - taxAmount: parseFloat(taxAmount) || 0, - discountAmount: parseFloat(discountAmount) || 0, - isScanned: false, - scannedData: { sellerTIN: "123456", items: [] }, - items: validItems.map((item) => ({ - description: item.description.trim(), - quantity: parseFloat(item.qty) || 0, - unitPrice: parseFloat(item.price) || 0, - total: Number( - ( - (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0) - ).toFixed(2), - ), - })), - }, - }); - toast.success("Success", "Invoice created!"); + const body = { + invoiceNumber, + customerName, + customerEmail, + customerPhone: customerPhone ? `+251${customerPhone}` : "", + amount: Number(total.toFixed(2)), + currency, + type, + status, + issueDate: new Date(issueDate).toISOString(), + dueDate: new Date(dueDate).toISOString(), + description, + notes, + taxAmount: parseFloat(taxAmount) || 0, + discountAmount: parseFloat(discountAmount) || 0, + isScanned: !!scanRecordId, + scannedData: scanRecordId + ? undefined + : { sellerTIN: "123456", items: [] }, + items: validItems.map((item) => ({ + description: item.description.trim(), + quantity: parseFloat(item.qty) || 0, + unitPrice: parseFloat(item.price) || 0, + total: Number( + ( + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0) + ).toFixed(2), + ), + })), + }; + + if (scanRecordId) { + await api.invoices.update({ params: { id: scanRecordId }, body }); + toast.success("Success", "Invoice updated!"); + } else { + await api.invoices.create({ body }); + toast.success("Success", "Invoice created!"); + } nav.back(); } catch (error: any) { const msg = error?.response?.data?.message || error?.data?.message || error?.message || - "Failed to create invoice"; + (scanRecordId ? "Failed to update invoice" : "Failed to create invoice"); toast.error("Error", msg); throw error; } finally { @@ -435,9 +449,9 @@ export default function CreateInvoiceScreen() { currentStep={step} onNext={handleNext} onBack={() => setStep(step - 1)} - onComplete={handleSubmit} + onComplete={() => setShowConfirm(true)} loading={submitting} - completeLabel="Create Invoice" + completeLabel={scanRecordId ? "Update Invoice" : "Create Invoice"} > {step === 0 && ( @@ -470,11 +484,20 @@ export default function CreateInvoiceScreen() { Customer Name { - setCustomerName(c.name); - setCustomerEmail(c.email); - setCustomerPhone(c.phone.replace("+251", "")); + selectedIds={customerId ? [customerId] : []} + selectedCustomers={selectedCustomers} + onSelect={(ids, customers) => { + setCustomerId(ids[0] || ""); + setSelectedCustomers(customers); + if (customers[0]) { + setCustomerName(customers[0].name); + setCustomerEmail(customers[0].email); + setCustomerPhone(customers[0].phone?.replace("+251", "") || ""); + } else { + setCustomerName(""); + setCustomerEmail(""); + setCustomerPhone(""); + } }} placeholder="Select or search for a customer" /> diff --git a/app/invoices/edit.tsx b/app/invoices/edit.tsx index 76efed4..bc35bac 100644 --- a/app/invoices/edit.tsx +++ b/app/invoices/edit.tsx @@ -160,9 +160,11 @@ export default function EditInvoiceScreen() { const [step, setStep] = useState(0); const [invoiceNumber, setInvoiceNumber] = useState(""); + const [customerId, setCustomerId] = useState(""); const [customerName, setCustomerName] = useState(""); const [customerEmail, setCustomerEmail] = useState(""); const [customerPhone, setCustomerPhone] = useState(""); + const [selectedCustomers, setSelectedCustomers] = useState([]); const [currency, setCurrency] = useState("ETB"); const [type, setType] = useState("SALES"); const [notes, setNotes] = useState(""); @@ -383,11 +385,20 @@ export default function EditInvoiceScreen() { Customer Name { - setCustomerName(c.name); - setCustomerEmail(c.email); - setCustomerPhone(c.phone.replace("+251", "")); + selectedIds={customerId ? [customerId] : []} + selectedCustomers={selectedCustomers} + onSelect={(ids, customers) => { + setCustomerId(ids[0] || ""); + setSelectedCustomers(customers); + if (customers[0]) { + setCustomerName(customers[0].name); + setCustomerEmail(customers[0].email); + setCustomerPhone(customers[0].phone?.replace("+251", "") || ""); + } else { + setCustomerName(""); + setCustomerEmail(""); + setCustomerPhone(""); + } }} placeholder="Select or search for a customer" /> diff --git a/app/notifications/index.tsx b/app/notifications/index.tsx index d525cbf..23e4432 100644 --- a/app/notifications/index.tsx +++ b/app/notifications/index.tsx @@ -1,19 +1,58 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { View, ActivityIndicator, FlatList, RefreshControl } from "react-native"; +import React, { useCallback, useEffect, useState, useMemo } from "react"; +import { View, ActivityIndicator, FlatList, RefreshControl, Pressable } from "react-native"; import { Text } from "@/components/ui/text"; -import { Card, CardContent } from "@/components/ui/card"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; import { api } from "@/lib/api"; import { EmptyState } from "@/components/EmptyState"; +import { Bell, Clock } from "@/lib/icons"; type NotificationItem = { id: string; title?: string; body?: string; - message?: string; + icon?: string; + url?: string; + sentAt?: string; createdAt?: string; - read?: boolean; + isSent?: boolean; +}; + +function formatRelativeTime(dateString: string): string { + const now = new Date(); + const date = new Date(dateString); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? "s" : ""} ago`; + if (diffHours < 24) return `${diffHours} hr${diffHours > 1 ? "s" : ""} ago`; + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return `${diffDays} days ago`; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +function getDateGroup(dateString: string): string { + const now = new Date(); + const date = new Date(dateString); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const itemDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + if (itemDate.getTime() === today.getTime()) return "Today"; + if (itemDate.getTime() === yesterday.getTime()) return "Yesterday"; + if (now.getTime() - itemDate.getTime() < 7 * 86400000) return "This Week"; + return date.toLocaleDateString("en-US", { month: "long", year: "numeric" }); +} + +type SectionItem = { + type: "header" | "item"; + key: string; + item?: NotificationItem; + isLast?: boolean; }; export default function NotificationsScreen() { @@ -64,26 +103,85 @@ export default function NotificationsScreen() { if (!loading && !loadingMore && hasMore) fetchNotifications(page + 1, "more"); }; - const renderItem = ({ item }: { item: NotificationItem }) => { - const message = item.body ?? item.message ?? ""; - const time = item.createdAt - ? new Date(item.createdAt).toLocaleString() + const grouped = useMemo(() => { + const groups: Record = {}; + for (const item of items) { + const dateStr = item.sentAt || item.createdAt; + const group = dateStr ? getDateGroup(dateStr) : "Other"; + if (!groups[group]) groups[group] = []; + groups[group].push(item); + } + return Object.entries(groups); + }, [items]); + + const sections = useMemo(() => { + const data: SectionItem[] = []; + for (const [title, groupItems] of grouped) { + data.push({ type: "header", key: `header-${title}` }); + groupItems.forEach((item, idx) => { + data.push({ + type: "item", + key: item.id, + item, + isLast: idx === groupItems.length - 1, + }); + }); + } + return data; + }, [grouped]); + + const renderSectionHeader = (title: string) => ( + + + {title} + + + ); + + const renderItem = ({ item, isLast }: { item: NotificationItem; isLast: boolean }) => { + const time = item.sentAt || item.createdAt + ? formatRelativeTime(item.sentAt || item.createdAt!) : ""; + const iconName = item.icon || "bell"; + return ( - - - - {item.title ?? "Notification"} - - {message ? ( - {message} - ) : null} - {time ? ( - {time} - ) : null} - - + + + {/* Icon */} + + + + + {/* Content */} + + + {item.title || "Notification"} + + {item.body ? ( + + {item.body} + + ) : null} + + + {/* Time + Unread dot */} + + {time ? ( + + {time} + + ) : null} + + + + ); }; @@ -97,24 +195,29 @@ export default function NotificationsScreen() { {loading ? ( - + ) : ( i.id} - renderItem={renderItem} - contentContainerStyle={{ padding: 16, paddingBottom: 32 }} + data={sections} + keyExtractor={(i) => i.key} + renderItem={({ item }) => { + if (item.type === "header") { + return renderSectionHeader(item.key.replace("header-", "")); + } + return renderItem({ item: item.item!, isLast: item.isLast! }); + }} + contentContainerStyle={{ paddingBottom: 32 }} onEndReached={onEndReached} onEndReachedThreshold={0.4} refreshControl={ } ListEmptyComponent={ - + @@ -122,7 +225,7 @@ export default function NotificationsScreen() { ListFooterComponent={ loadingMore ? ( - + ) : null } diff --git a/app/payment-requests/[id].tsx b/app/payment-requests/[id].tsx index e44fc3b..d204d79 100644 --- a/app/payment-requests/[id].tsx +++ b/app/payment-requests/[id].tsx @@ -1,17 +1,28 @@ import React, { useState, useCallback } from "react"; -import { View, ScrollView, ActivityIndicator } from "react-native"; +import { View, ScrollView, ActivityIndicator, Pressable, TextInput, Modal } from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; -import { User, Calendar, Clock, Building2, Hash, Send } from "@/lib/icons"; +import { User, Calendar, Clock, Building2, Send, Pencil, ChevronRight } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; import { api } from "@/lib/api"; import { useColorScheme } from "nativewind"; import { toast } from "@/lib/toast-store"; +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)", + }; +} + const STATUS_THEME: Record< string, { label: string; bg: string; text: string; dot: string } @@ -69,11 +80,16 @@ export default function PaymentRequestDetailScreen() { const { id } = useLocalSearchParams(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; + const c = useInputColors(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); + const [showSendModal, setShowSendModal] = useState(false); + const [sendChannel, setSendChannel] = useState("EMAIL"); + const [sendRecipient, setSendRecipient] = useState(""); + const fetch = useCallback(async () => { try { setLoading(true); @@ -97,14 +113,33 @@ export default function PaymentRequestDetailScreen() { }, [fetch]), ); - const handleSendEmail = async () => { + const openSendModal = () => { + setSendChannel("EMAIL"); + setSendRecipient(data?.customerEmail || ""); + setShowSendModal(true); + }; + + const handleSend = async () => { + if (!sendRecipient.trim()) { + toast.error("Validation", "Recipient is required"); + return; + } try { setSending(true); const reqId = Array.isArray(id) ? id[0] : id; - await api.paymentRequests.sendEmail({ params: { id: reqId } }); - toast.success("Sent", "Payment request emailed to customer"); + await api.paymentRequests.send({ + params: { id: reqId }, + body: { + channel: sendChannel, + recipient: sendRecipient.trim(), + }, + headers: { "Content-Type": "application/json" }, + }); + toast.success("Sent", `Payment request sent via ${sendChannel.toLowerCase()}`); + setShowSendModal(false); + fetch(); } catch (err: any) { - toast.error("Error", err?.message || "Failed to send email"); + toast.error("Error", err?.message || "Failed to send payment request"); } finally { setSending(false); } @@ -142,8 +177,20 @@ export default function PaymentRequestDetailScreen() { contentContainerStyle={{ paddingBottom: 120 }} showsVerticalScrollIndicator={false} > + {/* Status Badge */} + + + + + + {theme.label} + + + + + {/* Customer + Dates cluster */} - + @@ -346,28 +393,128 @@ export default function PaymentRequestDetailScreen() { {/* Actions */} - {!data.customerEmail && ( - - No customer email on file + + + Send - )} + + + + {/* Send Modal */} + setShowSendModal(false)} + > + setShowSendModal(false)} + > + + e.stopPropagation()} + > + + + Send Payment Request + + setShowSendModal(false)} + className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center" + > + + + + + {/* Channel Toggle */} + + {(["EMAIL", "PHONE"] as const).map((ch) => ( + { + setSendChannel(ch); + if (ch === "EMAIL") { + setSendRecipient(data?.customerEmail || ""); + } else { + setSendRecipient(data?.customerPhone || ""); + } + }} + className={`flex-1 h-11 rounded-[6px] items-center justify-center border ${ + sendChannel === ch + ? "bg-primary border-primary" + : "bg-card border-border" + }`} + > + + {ch === "EMAIL" ? "Email" : "SMS"} + + + ))} + + + {/* Recipient Input */} + + + {sendChannel === "EMAIL" ? "Email Address" : "Phone Number"} + + + + + + + + + ); } diff --git a/app/payment-requests/create.tsx b/app/payment-requests/create.tsx index a1f72e6..9feed1e 100644 --- a/app/payment-requests/create.tsx +++ b/app/payment-requests/create.tsx @@ -20,23 +20,9 @@ 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"; +import { 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; -}; +type Item = { id: number; description: string; quantity: string; unitPrice: string }; const S = StyleSheet.create({ input: { @@ -80,6 +66,7 @@ function Field({ center = false, flex, multiline = false, + keyboardType, }: { label: string; value: string; @@ -89,6 +76,7 @@ function Field({ center?: boolean; flex?: number; multiline?: boolean; + keyboardType?: "default" | "numeric" | "email-address" | "phone-pad"; }) { const c = useInputColors(); return ( @@ -108,7 +96,7 @@ function Field({ placeholderTextColor={c.placeholder} value={value} onChangeText={onChangeText} - keyboardType={numeric ? "numeric" : "default"} + keyboardType={keyboardType || (numeric ? "numeric" : "default")} multiline={multiline} autoCorrect={false} autoCapitalize="none" @@ -147,15 +135,14 @@ function PickerField({ } 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: "accounts", label: "Accounts" }, - { key: "totals", label: "Totals" }, - { key: "notes", label: "Notes" }, + { key: "paymentMethod", label: "Payment" }, { key: "summary", label: "Summary" }, ]; @@ -165,38 +152,29 @@ export default function CreatePaymentRequestScreen() { const [submitting, setSubmitting] = useState(false); const [paymentRequestNumber, setPaymentRequestNumber] = useState(""); + const [description, setDescription] = useState(""); + const [customerName, setCustomerName] = useState(""); const [customerEmail, setCustomerEmail] = useState(""); - const [customerPhone, setCustomerPhone] = useState(""); + const [customerId, setCustomerId] = useState(""); + + const [channel, setChannel] = useState("EMAIL"); + const [recipient, setRecipient] = 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 [companyPaymentMethodId, setCompanyPaymentMethodId] = useState(""); + const [paymentMethods, setPaymentMethods] = useState([]); + const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false); const [items, setItems] = useState([ - { id: 1, description: "", qty: "1", price: "" }, - ]); - - const [accounts, setAccounts] = useState([ - { - id: 1, - bankName: "", - accountName: "", - accountNumber: "", - currency: "ETB", - }, + { id: 1, description: "", quantity: "1", unitPrice: "" }, ]); const c = useInputColors(); @@ -204,7 +182,8 @@ export default function CreatePaymentRequestScreen() { const [showCurrency, setShowCurrency] = useState(false); const [showIssueDate, setShowIssueDate] = useState(false); const [showDueDate, setShowDueDate] = useState(false); - const [showStatus, setShowStatus] = useState(false); + const [showChannel, setShowChannel] = useState(false); + const [showPaymentMethod, setShowPaymentMethod] = useState(false); useEffect(() => { const year = new Date().getFullYear(); @@ -216,6 +195,27 @@ export default function CreatePaymentRequestScreen() { 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)), @@ -224,53 +224,24 @@ export default function CreatePaymentRequestScreen() { const addItem = () => setItems((prev) => [ ...prev, - { id: Date.now(), description: "", qty: "1", price: "" }, + { id: Date.now(), description: "", quantity: "1", unitPrice: "" }, ]); 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), + sum + + (parseFloat(item.quantity) || 0) * (parseFloat(item.unitPrice) || 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"); @@ -285,38 +256,29 @@ export default function CreatePaymentRequestScreen() { const handleSubmit = async () => { if (!customerName) { - toast.error("Validation Error", "Please enter a customer name"); + toast.error("Validation", "Please select a customer"); return; } - const formattedPhone = customerPhone ? `+251${customerPhone}` : ""; - const body = { paymentRequestNumber, customerName, - customerEmail, - customerPhone: formattedPhone, - amount: amount ? Number(amount) : Number(computedTotal.toFixed(2)), + customerEmail: customerEmail || undefined, + channel, + recipient, + amount: amount ? Number(amount) : Number(subtotal.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, - })), + companyPaymentMethodId: companyPaymentMethodId || undefined, + customerId: customerId || undefined, + ...(description ? { description } : {}), items: items.map((i) => ({ description: i.description || "Item", - quantity: parseFloat(i.qty) || 0, - unitPrice: parseFloat(i.price) || 0, + quantity: parseFloat(i.quantity) || 0, + unitPrice: parseFloat(i.unitPrice) || 0, total: Number( - ((parseFloat(i.qty) || 0) * (parseFloat(i.price) || 0)).toFixed(2), + ((parseFloat(i.quantity) || 0) * (parseFloat(i.unitPrice) || 0)).toFixed(2), ), })), }; @@ -339,6 +301,12 @@ export default function CreatePaymentRequestScreen() { } }; + const paymentMethodLabel = companyPaymentMethodId + ? paymentMethods.find((pm: any) => pm.id === companyPaymentMethodId) + ?.label || paymentMethods.find((pm: any) => pm.id === companyPaymentMethodId) + ?.providerName || "Selected" + : "Select"; + return ( @@ -377,48 +346,49 @@ export default function CreatePaymentRequestScreen() { Customer Information - + - Customer Name + Customer { - setCustomerName(c.name); - setCustomerEmail(c.email); - setCustomerPhone(c.phone.replace("+251", "")); + 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 or search for a customer" + placeholder="Select a customer" /> - - - - - - Phone - - - +251 - - - + setShowChannel(true)} + /> + )} @@ -447,17 +417,12 @@ export default function CreatePaymentRequestScreen() { value={currency} onPress={() => setShowCurrency(true)} /> - setShowStatus(true)} - /> 0 ? `${currency} ${subtotal.toFixed(2)}` : "Enter amount"} numeric /> @@ -506,142 +471,144 @@ export default function CreatePaymentRequestScreen() { placeholder="1" numeric center - value={item.qty} - onChangeText={(v) => updateItem(item.id, "qty", v)} + value={item.quantity} + onChangeText={(v) => updateItem(item.id, "quantity", v)} flex={1} /> updateItem(item.id, "price", v)} + value={item.unitPrice} + onChangeText={(v) => updateItem(item.id, "unitPrice", v)} flex={3} /> + {parseFloat(item.quantity) > 0 && parseFloat(item.unitPrice) > 0 && ( + + + = {currency}{" "} + {( + (parseFloat(item.quantity) || 0) * + (parseFloat(item.unitPrice) || 0) + ).toFixed(2)} + + + )} ))} + {subtotal > 0 && ( + + + Subtotal + + + {currency} {subtotal.toFixed(2)} + + + )} )} {step === 4 && ( - - - Accounts - - - - - Add + + Payment Method + + + + + Company Payment Method - - - - {accounts.map((acc, index) => ( - { + 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 }} > - - - Account {index + 1} - - removeAccount(acc.id)} - hitSlop={8} - > - - - - updateAccount(acc.id, "bankName", v)} - placeholder="e.g. Yaltopia Bank" - /> - - - updateAccount(acc.id, "accountName", v) - } - placeholder="e.g. Yaltopia Tech PLC" - flex={1} - /> - - - - updateAccount(acc.id, "accountNumber", v) - } - placeholder="123456789" - flex={2} - /> - updateAccount(acc.id, "currency", v)} - placeholder="ETB" - flex={1} - /> - - - ))} + {loadingPaymentMethods ? ( + + ) : ( + <> + + {paymentMethodLabel} + + + + )} + + )} {step === 5 && ( - + - Totals + Summary - - - - Subtotal + + + {description ? ( + + ) : null} + + + + + + + + {items.length > 0 && ( + + + Items ({items.length}) + + {items.map((item, i) => ( + + + {item.description || `Item ${i + 1}`} + + + {item.quantity} × {currency}{" "} + {parseFloat(item.unitPrice || "0").toFixed(2)} + + + ))} + + )} + {paymentMethodLabel !== "Select" && ( + + )} + + + + Total Amount - + {currency}{" "} - {subtotal.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - - - - - - - - - Total - - - {currency}{" "} - {(amount ? Number(amount) : computedTotal).toLocaleString( + {(amount ? Number(amount) : subtotal).toLocaleString( "en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }, )} @@ -650,172 +617,6 @@ export default function CreatePaymentRequestScreen() { )} - {step === 6 && ( - - - - )} - - {step === 7 && ( - <> - - - Summary - - - - - Request Number - - - {paymentRequestNumber} - - - - - Customer - - - {customerName} - - - {customerEmail ? ( - - - Email - - - {customerEmail} - - - ) : null} - {customerPhone ? ( - - - Phone - - - +251{customerPhone} - - - ) : null} - {description ? ( - - - Description - - - {description} - - - ) : null} - - - Issue Date - - - {issueDate} - - - - - Due Date - - - {dueDate || "Not set"} - - - - - Status - - - {status} - - - {items.length > 0 && ( - - - Items ({items.length}) - - {items.map((item, i) => ( - - - {item.description || `Item ${i + 1}`} - - - {item.qty} × {currency}{" "} - {parseFloat(item.price || "0").toFixed(2)} - - - ))} - - )} - {notes ? ( - - - Notes - - - {notes} - - - ) : null} - {parseFloat(taxAmount) > 0 && ( - - - Tax - - - {currency} {parseFloat(taxAmount).toFixed(2)} - - - )} - {parseFloat(discountAmount) > 0 && ( - - - Discount - - - -{currency} {parseFloat(discountAmount).toFixed(2)} - - - )} - - - - Total Amount - - - {currency}{" "} - {(amount ? Number(amount) : computedTotal).toLocaleString( - "en-US", - { minimumFractionDigits: 2, maximumFractionDigits: 2 }, - )} - - - - - - )} setShowStatus(false)} - title="Select Status" + visible={showChannel} + onClose={() => setShowChannel(false)} + title="Invite Channel" > - {["DRAFT", "SENT", "OPENED", "PAID", "EXPIRED", "CANCELLED"].map( - (s) => ( - { - setStatus(v); - setShowStatus(false); - }} - /> - ), - )} + {CHANNELS.map((ch) => ( + { + setChannel(v); + setShowChannel(false); + }} + /> + ))} + + + setShowPaymentMethod(false)} + title="Payment Method" + > + {paymentMethods.map((pm: any) => ( + { + setCompanyPaymentMethodId(v); + setShowPaymentMethod(false); + }} + /> + ))} ); } + +function SummaryRow({ + label, + value, + multiline, +}: { + label: string; + value: string; + multiline?: boolean; +}) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/app/payment-requests/edit.tsx b/app/payment-requests/edit.tsx new file mode 100644 index 0000000..653ea2b --- /dev/null +++ b/app/payment-requests/edit.tsx @@ -0,0 +1,781 @@ +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 { useLocalSearchParams } from "expo-router"; + +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 ( + + + {label} + + + + ); +} + +function PickerField({ + label, + value, + onPress, +}: { + label: string; + value: string; + onPress: () => void; +}) { + const c = useInputColors(); + return ( + + + {label} + + + + {value} + + + + + ); +} + +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" }, +]; + +function formatDate(d: string | null | undefined): string { + if (!d) return ""; + try { + return new Date(d).toISOString().split("T")[0]; + } catch { + return ""; + } +} + +export default function EditPaymentRequestScreen() { + const nav = useSirouRouter(); + const { id } = useLocalSearchParams<{ id: string }>(); + const [step, setStep] = useState(0); + const [submitting, setSubmitting] = useState(false); + const [loadingData, setLoadingData] = useState(true); + + 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([]); + const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false); + + const [items, setItems] = useState([ + { 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(() => { + (async () => { + try { + setLoadingData(true); + const reqId = Array.isArray(id) ? id[0] : id; + if (!reqId) return; + const data = await api.paymentRequests.getById({ params: { id: reqId } }); + setPaymentRequestNumber(data.paymentRequestNumber || ""); + setDescription(data.description || ""); + setCustomerName(data.customerName || ""); + setCustomerEmail(data.customerEmail || ""); + setCustomerId(data.customerId || ""); + setChannel(data.channel || "EMAIL"); + setRecipient(data.recipient || data.customerEmail || ""); + setAmount(data.amount != null ? String(data.amount) : ""); + setCurrency(data.currency || "ETB"); + setIssueDate(formatDate(data.issueDate) || new Date().toISOString().split("T")[0]); + setDueDate(formatDate(data.dueDate)); + setCompanyPaymentMethodId(data.companyPaymentMethodId || ""); + if (data.items?.length) { + setItems( + data.items.map((it: any, idx: number) => ({ + id: idx + 1, + description: it.description || "", + quantity: String(it.quantity ?? 1), + unitPrice: String(it.unitPrice ?? ""), + })), + ); + } + } catch { + toast.error("Error", "Failed to load payment request"); + } finally { + setLoadingData(false); + } + })(); + }, [id]); + + 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: dueDate ? new Date(dueDate).toISOString() : undefined, + 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); + const reqId = Array.isArray(id) ? id[0] : id; + await api.paymentRequests.update({ + params: { id: reqId }, + body, + headers: { "Content-Type": "application/json" }, + }); + toast.success("Success", "Payment request updated successfully!"); + nav.back(); + } catch (err: any) { + console.error("[PaymentRequestEdit] Error:", err); + toast.error("Error", err?.message || "Failed to update 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"; + + if (loadingData) { + return ( + + + + + + ); + } + + return ( + + setStep(step - 1)} + onComplete={handleSubmit} + loading={submitting} + completeLabel="Update Request" + > + {step === 0 && ( + + + Request Details + + + + + + + )} + + {step === 1 && ( + + + Customer Information + + + + + Customer + + { + setCustomerId(ids[0] || ""); + setCustomerName(customers[0]?.name || ""); + setCustomerEmail(customers[0]?.email || ""); + }} + placeholder="Select a customer" + /> + + setShowChannel(true)} + /> + + + + )} + + {step === 2 && ( + + + Schedule & Currency + + + + setShowIssueDate(true)} + /> + setShowDueDate(true)} + /> + + + setShowCurrency(true)} + /> + + 0 ? `${currency} ${subtotal.toFixed(2)}` : "Enter amount"} + numeric + /> + + + )} + + {step === 3 && ( + + + + Items + + + + + Add + + + + + {items.map((item, index) => ( + + + + Item {index + 1} + + removeItem(item.id)} hitSlop={8}> + + + + updateItem(item.id, "description", v)} + /> + + updateItem(item.id, "quantity", v)} + flex={1} + /> + updateItem(item.id, "unitPrice", v)} + flex={3} + /> + + {parseFloat(item.quantity) > 0 && parseFloat(item.unitPrice) > 0 && ( + + + = {currency}{" "} + {( + (parseFloat(item.quantity) || 0) * + (parseFloat(item.unitPrice) || 0) + ).toFixed(2)} + + + )} + + ))} + + {subtotal > 0 && ( + + + Subtotal + + + {currency} {subtotal.toFixed(2)} + + + )} + + )} + + {step === 4 && ( + + + Payment Method + + + + + Company Payment Method + + { + 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 ? ( + + ) : ( + <> + + {paymentMethodLabel} + + + + )} + + + + + )} + + {step === 5 && ( + + + Summary + + + + {description ? ( + + ) : null} + + + + + + + + {items.length > 0 && ( + + + Items ({items.length}) + + {items.map((item, i) => ( + + + {item.description || `Item ${i + 1}`} + + + {item.quantity} × {currency}{" "} + {parseFloat(item.unitPrice || "0").toFixed(2)} + + + ))} + + )} + {paymentMethodLabel !== "Select" && ( + + )} + + + + Total Amount + + + {currency}{" "} + {(amount ? Number(amount) : subtotal).toLocaleString( + "en-US", + { minimumFractionDigits: 2, maximumFractionDigits: 2 }, + )} + + + + + )} + + + setShowCurrency(false)} + title="Select Currency" + > + {CURRENCIES.map((curr) => ( + { + setCurrency(v); + setShowCurrency(false); + }} + /> + ))} + + + setShowChannel(false)} + title="Invite Channel" + > + {CHANNELS.map((ch) => ( + { + setChannel(v); + setShowChannel(false); + }} + /> + ))} + + + setShowPaymentMethod(false)} + title="Payment Method" + > + {paymentMethods.map((pm: any) => ( + { + setCompanyPaymentMethodId(v); + setShowPaymentMethod(false); + }} + /> + ))} + + + setShowIssueDate(false)} + title="Select Issue Date" + > + { + setIssueDate(v); + setShowIssueDate(false); + }} + /> + + + setShowDueDate(false)} + title="Select Due Date" + > + { + setDueDate(v); + setShowDueDate(false); + }} + /> + + + ); +} + +function SummaryRow({ + label, + value, + multiline, +}: { + label: string; + value: string; + multiline?: boolean; +}) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/app/payments/[id].tsx b/app/payments/[id].tsx index 9dbd9a7..4449d1e 100644 --- a/app/payments/[id].tsx +++ b/app/payments/[id].tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useEffect } from "react"; import { View, ScrollView, @@ -11,7 +11,10 @@ import { Alert, Platform, PermissionsAndroid, + Dimensions, + Image, } from "react-native"; +import { WebView } from "react-native-webview"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router"; @@ -33,8 +36,20 @@ import { Info, Search, ChevronDown, + ChevronRight, X, Scan, + MoreVertical, + Edit, + Package, + Share2, + Mail, + MessageSquare, + Calendar, + Check, + Camera, + ArrowUpRight, + Flag, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; @@ -45,6 +60,8 @@ import { ActionModal } from "@/components/ActionModal"; import { PickerModal, SelectOption } from "@/components/PickerModal"; import { CalendarGrid } from "@/components/CalendarGrid"; import { getPlaceholderColor } from "@/lib/colors"; +import ticketImage from "@/assets/ticket.png"; +import { CheckCircle2, CreditCardIcon } from "lucide-react-native"; let SmsAndroid: any = null; if (Platform.OS === "android") { @@ -56,6 +73,8 @@ if (Platform.OS === "android") { } } +const { height: SCREEN_HEIGHT } = Dimensions.get("window"); + const S = StyleSheet.create({ input: { paddingHorizontal: 12, @@ -94,6 +113,19 @@ export default function PaymentDetailScreen() { const [showEditModal, setShowEditModal] = useState(false); const [showInvoicePicker, setShowInvoicePicker] = useState(false); const [editSaving, setEditSaving] = useState(false); + const [showMoreSheet, setShowMoreSheet] = useState(false); + const [activeTab, setActiveTab] = useState<"details" | "items" | "image">( + "details", + ); + const [linkedInvoice, setLinkedInvoice] = useState(null); + const [showImageFullScreen, setShowImageFullScreen] = useState(false); + const [imageLoading, setImageLoading] = useState(false); + const [showFlagModal, setShowFlagModal] = useState(false); + const [flagReason, setFlagReason] = useState<"FAKE" | "SCAM" | "OTHER">( + "FAKE", + ); + const [flagNotes, setFlagNotes] = useState(""); + const [flagging, setFlagging] = useState(false); // Edit form state const [editTxnId, setEditTxnId] = useState(""); @@ -147,6 +179,25 @@ export default function PaymentDetailScreen() { }, [fetchPayment]), ); + useEffect(() => { + const fetchLinked = async () => { + if (!payment?.invoiceId) { + setLinkedInvoice(null); + return; + } + try { + const inv = await api.invoices.getById({ + params: { id: payment.invoiceId }, + }); + setLinkedInvoice(inv); + } catch (err) { + console.log("[PaymentDetail] could not load linked invoice", err); + setLinkedInvoice(null); + } + }; + fetchLinked(); + }, [payment?.invoiceId]); + const handleDelete = () => setShowDeleteModal(true); const confirmDelete = async () => { @@ -360,6 +411,48 @@ export default function PaymentDetailScreen() { } }; + const openFlagModal = () => { + if (!payment) return; + setFlagReason(payment.flagReason || "FAKE"); + setFlagNotes(payment.flagNotes || ""); + setShowMoreSheet(false); + setShowFlagModal(true); + }; + + const handleLinkInvoicePress = () => { + if (payment?.invoiceId) { + toast.warning( + "Already Linked", + "This payment is already linked to an invoice.", + ); + return; + } + openMatchPicker(); + }; + + const handleFlag = async () => { + if (!paymentId) return; + setFlagging(true); + try { + await api.payments.flag({ + params: { id: paymentId }, + body: { flagReason, flagNotes }, + }); + toast.success("Flagged", "Payment has been flagged for review."); + setShowFlagModal(false); + fetchPayment(); + } catch (err: any) { + const msg = + err?.response?.data?.message || + err?.data?.message || + err?.message || + "Failed to flag payment"; + toast.error("Error", msg); + } finally { + setFlagging(false); + } + }; + if (loading) { return ( @@ -406,6 +499,37 @@ export default function PaymentDetailScreen() { const isFlagged = payment.isFlagged === true; + const scannedImageRaw = + payment.scannedData?.imageUrl || + payment.scannedData?.image || + payment.scannedData?.imagePath || + payment.scannedData?.originalData?.imageUrl || + payment.imageUrl || + payment.imagePath || + payment.receiptPath || + null; + + const scannedImageUrl = scannedImageRaw + ? scannedImageRaw.startsWith("http") + ? scannedImageRaw + : `${BASE_URL}${scannedImageRaw.replace(/^\//, "")}` + : null; + + const hasScannedImage = Boolean(payment?.isScanned && scannedImageUrl); + + const hasSms = Boolean( + payment.smsContent || + payment.smsVerified || + payment.smsBody || + payment.verifiedAt || + payment.smsId, + ); + + const linkedItems = + (linkedInvoice?.items?.length > 0 + ? linkedInvoice.items + : linkedInvoice?.scannedData?.originalData?.items) || []; + const filteredInvoices = invoices.filter((inv) => { if (!invoiceSearch) return true; const q = invoiceSearch.toLowerCase(); @@ -415,264 +539,751 @@ export default function PaymentDetailScreen() { ); }); + const formatLongDate = (d: Date) => + d.toLocaleDateString("en-US", { + day: "numeric", + month: "short", + year: "numeric", + }); + + const ActionOption = ({ + icon, + label, + description, + onPress, + destructive, + }: { + icon: React.ReactNode; + label: string; + description: string; + onPress: () => void; + destructive?: boolean; + }) => ( + + + {icon} + + + + {label} + + + {description} + + + + ); + return ( setShowMoreSheet(true)} + className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" + > + + + } /> - {/* Flagged Alert */} - {isFlagged && ( - - - - - - - Security Flag ({payment.flagReason || "Audit Needed"}) - - - {payment.flagNotes || "System flagged this for manual review."} - - - - )} - - {/* Hero Section */} - - - Total Amount - - - - {amountValue.toLocaleString()} - - - {payment.currency || "USD"} - + {/* Hero Card — illustration overflows the top */} + + + - - - - - + + + {amountValue.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + + {payment.currency || "USD"} + + + + {payment.transactionId + ? `Txn ${payment.transactionId}` + : payment.paymentMethod || "Direct"} + + + + + + Method + + + {payment.financialInstitutionLogoUrl ? ( + + ) : ( + + )} + + {payment.financialInstitution || + payment.paymentMethod || + "Direct"} + + + + + Sender {payment.senderName || "Unknown"} - - - - - - - Method + + + + Date - - {payment.paymentMethod || "Direct"} + + {formatLongDate(paymentDate)} - - + + + {isFlagged && ( + + + Flagged + + + {payment.flagReason || "Audit Needed"} + + + )} + {payment.isReferenceVerified && ( + + + Reference Verified + + + Yes + + + )} + - {/* Details Section */} - - - - Transaction Details - - - - - - - - Transaction ID - - - {payment.transactionId || "—"} - - - - - - - - - Receiver - - - {payment.receiverName || "—"} - - - - - - - - - Payment Date - - - {paymentDate.toLocaleString()} - - - - - {payment.invoiceId && ( - - nav.go("invoices/[id]", { id: payment.invoiceId }) - } - className="flex-row items-center gap-3 active:opacity-60" + {/* Tabs */} + + + setActiveTab("details")} + className="pb-2.5" + > + + Details + + {activeTab === "details" && ( + + )} + + {hasScannedImage && ( + setActiveTab("image")} + className="pb-2.5" + > + - + Image + + {activeTab === "image" && ( + + )} + + )} + + + + {/* Tab content */} + {activeTab === "details" ? ( + + {/* Transaction Details */} + + + + - Linked Invoice + Transaction ID - - {payment.invoiceId} + + {payment.transactionId || "—"} - - )} + - - - - - Created - - - {new Date(payment.createdAt).toLocaleString()} - + + + + + Payment Method + + + {payment.paymentMethod || "—"} + + + + + + {payment.financialInstitutionLogoUrl ? ( + + ) : ( + + )} + + + Financial Institution + + + {payment.financialInstitution || "—"} + + + + + + + + + Sender + + + {payment.senderName || "—"} + + + + + + + + + Receiver + + + {payment.receiverName || "—"} + + + + + + + + + Payment Date + + + {paymentDate.toLocaleString()} + + + + + + + + + Reference + + + {payment.isReferenceVerified + ? "Verified" + : "Not verified"} + + + + + {isFlagged && ( + + + + + Flagged for review + + + {payment.flagReason ? ( + + + Reason + + + {payment.flagReason} + + + ) : null} + {payment.flagNotes ? ( + + {payment.flagNotes} + + ) : null} + + )} + + {payment.invoiceId ? ( + + nav.go("invoices/[id]", { id: payment.invoiceId }) + } + className="flex-row items-center gap-3 active:opacity-60" + > + + + + Linked Invoice + + + {payment.invoiceId} + + + + + ) : ( + + + Linked Invoice + + + {matching ? ( + + ) : ( + <> + + + Link Invoice + + + )} + + + )} + + + + + + Created + + + {new Date(payment.createdAt).toLocaleString()} + + - - - {/* Notes */} - {payment.notes && ( - - - Notes - - - - - "{payment.notes}" + {/* Note */} + {payment.notes && ( + + + Note - - + + + {payment.notes} + + + + )} + + ) : activeTab === "image" ? ( + + {hasScannedImage ? ( + + + Scanned Document + + setShowImageFullScreen(true)} + className="rounded-[10px] overflow-hidden border border-border bg-card active:opacity-80" + > + setImageLoading(true)} + onLoadEnd={() => setImageLoading(false)} + onError={() => { + setImageLoading(false); + toast.error( + "Image Error", + "Failed to load scanned image.", + ); + }} + renderError={() => ( + + + + Failed to load image + + + )} + /> + {imageLoading && ( + + + + )} + + + + + + Tap to view full screen + + + ) : ( + + + + + + No image available + + + The scanned image could not be found. + + + )} + + ) : ( + + {linkedItems.length > 0 ? ( + + + Items + + + {linkedItems.map((item: any, idx: number) => { + const qty = Number( + item.quantity?.value || item.quantity || 1, + ); + const unitPrice = Number( + item.unitPrice?.value || item.unitPrice || 0, + ); + const lineTotal = Number( + item.total?.value || item.total || qty * unitPrice, + ); + return ( + + + + + + + {item.description || `Item ${idx + 1}`} + + + {qty} ×{" "} + {unitPrice.toLocaleString("en-US", { + minimumFractionDigits: 2, + })}{" "} + {payment.currency || "USD"} + + + + {lineTotal.toLocaleString("en-US", { + minimumFractionDigits: 2, + })}{" "} + {payment.currency || "USD"} + + + ); + })} + + + ) : payment.invoiceId ? ( + + + + Loading items from linked invoice... + + + ) : ( + + + + + + No items linked + + + Link an invoice to see its items here. + + {!payment.invoiceId && ( + + )} + + )} )} - {/* Actions */} - - - {payment.receiptPath && ( - - )} - - - - {!payment.invoiceId && ( - - )} - - - + + + View Receipt + + + )} + {/* Sticky bottom bar — Scan SMS + Link Invoice side by side (like invoice detail) */} + + + {scanningSms ? ( + + ) : ( + <> + + + {hasSms ? "SMS Verified" : "Scan SMS"} + + + )} + + + {matching ? ( + + ) : ( + <> + + + {payment.invoiceId ? "Linked" : "Link Invoice"} + + + )} + + + + {/* More bottom sheet */} + setShowMoreSheet(false)} + > + setShowMoreSheet(false)} + > + + e.stopPropagation()} + > + + + + Payment + + setShowMoreSheet(false)} + className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10" + > + + + + + + } + label="Edit Payment" + description="Update amount, method, or dates" + onPress={() => { + setShowMoreSheet(false); + openEdit(); + }} + /> + } + label={isFlagged ? "Update Flag" : "Flag Payment"} + description="Mark this payment for manual review" + onPress={openFlagModal} + /> + } + label="Delete Payment" + description="Permanently remove this record" + onPress={() => { + setShowMoreSheet(false); + handleDelete(); + }} + destructive + /> + + + + + + {/* Edit Modal */} + + {/* Flag Modal */} + setShowFlagModal(false)} + > + setShowFlagModal(false)} + > + e.stopPropagation()} + className="bg-card rounded-t-[36px] overflow-hidden border-t-[3px] border-border/20" + style={{ maxHeight: SCREEN_HEIGHT * 0.8 }} + > + + + + Flag Payment + + setShowFlagModal(false)} + className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10" + > + + + + + + + Mark this payment as suspicious. The reason will be visible to + your team and recorded in the audit log. + + + + Reason + + + {(["FAKE", "SCAM", "OTHER"] as const).map((r) => ( + setFlagReason(r)} + className={`h-11 px-3 border rounded-[6px] flex-row items-center justify-between ${ + flagReason === r + ? "border-primary bg-primary/10" + : "border-border" + }`} + style={{ + backgroundColor: + flagReason === r + ? isDark + ? "rgba(234,88,12,0.15)" + : "rgba(234,88,12,0.08)" + : c.bg, + }} + > + + {r === "FAKE" + ? "Fake Payment" + : r === "SCAM" + ? "Scam / Phishing" + : "Other"} + + {flagReason === r && ( + + )} + + ))} + + + + Notes (optional) + + + + + {flagging ? ( + + ) : ( + <> + + + {isFlagged ? "Update Flag" : "Submit Flag"} + + + )} + + + + + + + {/* Full screen image viewer */} + setShowImageFullScreen(false)} + > + setShowImageFullScreen(false)} + > + + {scannedImageUrl && ( + + )} + + setShowImageFullScreen(false)} + className="absolute top-12 right-5 h-10 w-10 rounded-full bg-black/60 items-center justify-center border border-white/20" + > + + + + ); } diff --git a/app/payments/create.tsx b/app/payments/create.tsx index 7a80c05..5cbdf31 100644 --- a/app/payments/create.tsx +++ b/app/payments/create.tsx @@ -8,6 +8,7 @@ import { ActivityIndicator, Platform, PermissionsAndroid, + Switch, } from "react-native"; import { useColorScheme } from "nativewind"; import { Text } from "@/components/ui/text"; @@ -21,8 +22,8 @@ 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"; +import { getScanData } from "@/lib/scan-cache"; let SmsAndroid: any = null; if (Platform.OS === "android") { @@ -118,7 +119,7 @@ function PickerField({ @@ -138,8 +139,24 @@ const PAYMENT_METHODS = [ "DECSI", "Bank Transfer", "Cash", + "Credit Card", "Other", ]; +const FINANCIAL_INSTITUTIONS = ["CBE", "ABYSSINIA", "TELE", "DASHEN"]; + +const PROVIDER_ALIASES: Record = { + cbe: "CBE", + telebirr: "TELE", + tele: "TELE", + dashen: "DASHEN", + abyssinia: "ABYSSINIA", +}; + +function normalizeFinancialInstitution(input: string | undefined | null): string | null { + if (!input) return null; + const key = String(input).trim().toLowerCase(); + return PROVIDER_ALIASES[key] ?? null; +} function parseSmsMessage(body: string) { const text = body.toUpperCase(); @@ -168,23 +185,25 @@ export default function CreatePaymentScreen() { const nav = useSirouRouter(); const [step, setStep] = useState(0); const [submitting, setSubmitting] = useState(false); + const [scanRecordId, setScanRecordId] = useState(null); const [transactionId, setTransactionId] = useState(""); const [amount, setAmount] = useState(""); const [currency, setCurrency] = useState("ETB"); const [paymentMethod, setPaymentMethod] = useState("Telebirr"); + const [financialInstitution, setFinancialInstitution] = useState("CBE"); + const [isReferenceVerified, setIsReferenceVerified] = useState(false); const [paymentDate, setPaymentDate] = useState( new Date().toISOString().split("T")[0], ); const [notes, setNotes] = useState(""); const [selectedInvoice, setSelectedInvoice] = useState(null); - - const [customerName, setCustomerName] = useState(""); - const [customerEmail, setCustomerEmail] = useState(""); - const [customerPhone, setCustomerPhone] = useState(""); + const [customerId, setCustomerId] = useState(""); const [showCurrency, setShowCurrency] = useState(false); const [showPaymentMethod, setShowPaymentMethod] = useState(false); + const [showFinancialInstitution, setShowFinancialInstitution] = + useState(false); const [showPaymentDate, setShowPaymentDate] = useState(false); const [showInvoicePicker, setShowInvoicePicker] = useState(false); @@ -249,6 +268,46 @@ export default function CreatePaymentScreen() { })(); }, []); + useEffect(() => { + const payload = getScanData(); + if (!payload) return; + if (payload.type !== "payment" || !payload.id) return; + setScanRecordId(payload.id); + const scanData = payload.data || {}; + if (scanData.transactionId) + setTransactionId(String(scanData.transactionId)); + if (scanData.amount != null) setAmount(String(scanData.amount)); + if (scanData.currency) setCurrency(scanData.currency); + if (scanData.paymentMethod) setPaymentMethod(scanData.paymentMethod); + if (scanData.provider) { + const normalized = normalizeFinancialInstitution(scanData.provider); + if (normalized) setFinancialInstitution(normalized); + } + if (scanData.paymentDate) { + try { + setPaymentDate( + new Date(scanData.paymentDate).toISOString().split("T")[0], + ); + } catch (_) {} + } + if ( + scanData.referenceNumber || + scanData.merchantName || + scanData.merchantId + ) { + const parts: string[] = []; + if (scanData.referenceNumber) + parts.push(`Ref: ${scanData.referenceNumber}`); + if (scanData.merchantName) + parts.push(`Merchant: ${scanData.merchantName}`); + if (scanData.merchantId) + parts.push(`Merchant ID: ${scanData.merchantId}`); + setNotes((prev) => + prev ? `${prev}\n${parts.join(" · ")}` : parts.join(" · "), + ); + } + }, []); + const openInvoicePicker = async () => { setLoadingInvoices(true); try { @@ -278,8 +337,8 @@ export default function CreatePaymentScreen() { const STEPS = [ { key: "details", label: "Payment Details" }, - { key: "invoice", label: "Invoice" }, - { key: "info", label: "Customer Info" }, + { key: "schedule", label: "Schedule" }, + { key: "method", label: "Method" }, { key: "summary", label: "Summary" }, ]; @@ -321,15 +380,23 @@ export default function CreatePaymentScreen() { currency, paymentDate: new Date(paymentDate).toISOString(), paymentMethod, - notes, - customerName: customerName.trim() || undefined, - customerEmail: customerEmail.trim() || undefined, - customerPhone: customerPhone.trim() ? `+251${customerPhone.trim()}` : undefined, + financialInstitution, + isReferenceVerified, + notes: notes.trim() || undefined, ...(selectedInvoice?.id ? { invoiceId: selectedInvoice.id } : {}), + ...(customerId ? { customerId } : {}), }; - await api.payments.create({ body: payload }); - toast.success("Success", "Payment created successfully!"); + if (scanRecordId) { + await api.payments.update({ + params: { id: scanRecordId }, + body: payload, + }); + toast.success("Success", "Payment updated successfully!"); + } else { + await api.payments.create({ body: payload }); + toast.success("Success", "Payment created successfully!"); + } nav.back(); } catch (error: any) { console.error("[CreatePayment] Error:", error); @@ -337,7 +404,9 @@ export default function CreatePaymentScreen() { error?.response?.data?.message || error?.data?.message || error?.message || - "Failed to create payment"; + (scanRecordId + ? "Failed to update payment" + : "Failed to create payment"); toast.error("Error", msg); } finally { setSubmitting(false); @@ -353,7 +422,7 @@ export default function CreatePaymentScreen() { onBack={() => setStep(step - 1)} onComplete={handleSubmit} loading={submitting} - completeLabel="Create Payment" + completeLabel={scanRecordId ? "Update Payment" : "Create Payment"} > {step === 0 && ( @@ -382,11 +451,6 @@ export default function CreatePaymentScreen() { onPress={() => setShowCurrency(true)} /> - setShowPaymentMethod(true)} - /> - Invoice + Link Invoice @@ -446,49 +510,37 @@ export default function CreatePaymentScreen() { {step === 2 && ( - Customer Info + Method - - - - Customer Name - - { - setCustomerName(c.name); - setCustomerEmail(c.email); - setCustomerPhone(c.phone.replace("+251", "")); - }} - placeholder="Select or search for a customer" - /> - - - - - - - Phone - - - +251 - + + setShowPaymentMethod(true)} + /> + setShowFinancialInstitution(true)} + /> + + + + + + + Reference verified + + + Transaction reference has been confirmed + + @@ -508,6 +560,18 @@ export default function CreatePaymentScreen() { {transactionId} + + + Amount + + + {currency}{" "} + {(parseFloat(amount) || 0).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + Payment Method @@ -516,6 +580,22 @@ export default function CreatePaymentScreen() { {paymentMethod} + + + Financial Institution + + + {financialInstitution} + + + + + Reference Verified + + + {isReferenceVerified ? "Yes" : "No"} + + Payment Date @@ -524,6 +604,16 @@ export default function CreatePaymentScreen() { {paymentDate} + {selectedInvoice?.customerName ? ( + + + Customer + + + {selectedInvoice?.customerName} + + + ) : null} Linked Invoice @@ -603,6 +693,25 @@ export default function CreatePaymentScreen() { ))} + setShowFinancialInstitution(false)} + title="Select Financial Institution" + > + {FINANCIAL_INSTITUTIONS.map((fi) => ( + { + setFinancialInstitution(v); + setShowFinancialInstitution(false); + }} + /> + ))} + + setShowPaymentDate(false)} @@ -645,6 +754,7 @@ export default function CreatePaymentScreen() { { setSelectedInvoice(null); + setCustomerId(""); setShowInvoicePicker(false); }} className="px-4 py-3 border-b border-border/40 flex-row items-center" @@ -659,6 +769,7 @@ export default function CreatePaymentScreen() { key={inv.id} onPress={() => { setSelectedInvoice(inv); + setCustomerId(inv.customerId || ""); setShowInvoicePicker(false); }} className="px-4 py-3 border-b border-border/40 flex-row items-center" diff --git a/app/pin-lock.tsx b/app/pin-lock.tsx index 6d89631..b763385 100644 --- a/app/pin-lock.tsx +++ b/app/pin-lock.tsx @@ -10,6 +10,7 @@ import { api } from "@/lib/api"; import { usePinStore } from "@/lib/pin-store"; import { useAuthStore } from "@/lib/auth-store"; import { toast } from "@/lib/toast-store"; +import bcrypt from "react-native-bcrypt"; const LOCKOUT_THRESHOLD = 5; @@ -34,7 +35,11 @@ export default function PinLockScreen() { if (loading || value.length < 6) return; setLoading(true); try { - await api.auth.verifyPin({ query: { pin: value } }); + const res = await api.auth.verifyPin({ query: { pin: value } }); + const match = bcrypt.compareSync(value, res.pin); + if (!match) { + throw new Error("PIN mismatch"); + } unlock(); nav.go("(tabs)"); } catch (err: any) { @@ -90,7 +95,6 @@ export default function PinLockScreen() { {user?.firstName ?? "User"} - r diff --git a/app/profile.tsx b/app/profile.tsx index 9f302ff..88943cb 100644 --- a/app/profile.tsx +++ b/app/profile.tsx @@ -13,8 +13,6 @@ import { User, Lock, Globe, - Building2, - Users, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { useColorScheme } from "nativewind"; @@ -212,28 +210,6 @@ export default function ProfileScreen() { /> */} - - } - label="Overview" - sublabel="View company details" - onPress={() => nav.go("company-details")} - /> - {/* } - label="Edit Company Info" - sublabel="Update business details" - onPress={() => nav.go("company/edit")} - /> */} - } - label="Workers" - sublabel="Manage team members" - onPress={() => nav.go("team/index")} - isLast - /> - - } diff --git a/app/proforma-requests/[id].tsx b/app/proforma-requests/[id].tsx new file mode 100644 index 0000000..1df077e --- /dev/null +++ b/app/proforma-requests/[id].tsx @@ -0,0 +1,622 @@ +import React, { useState, useCallback } from "react"; +import { + View, + ScrollView, + ActivityIndicator, + Linking, + Pressable, + Share, + Modal, +} from "react-native"; +import { useSirouRouter } from "@sirou/react-native"; +import { AppRoutes } from "@/lib/routes"; +import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router"; +import { Text } from "@/components/ui/text"; +import { + Inbox, + Calendar, + Clock, + Hash, + Share2, + Package, + X, + Mail, + Link2, + Info, + Truck, + Send, + AlertCircle, + CheckCircle2, + XCircle, + Hourglass, +} from "@/lib/icons"; +import { ScreenWrapper } from "@/components/ScreenWrapper"; +import { StandardHeader } from "@/components/StandardHeader"; +import { api } from "@/lib/api"; +import { useColorScheme } from "nativewind"; +import { toast } from "@/lib/toast-store"; + +const STATUS_THEME: Record< + string, + { label: string; bg: string; text: string; dot: string; pillBg: string } +> = { + DRAFT: { + label: "Draft", + bg: "bg-slate-500/10", + text: "text-slate-600", + dot: "bg-slate-500", + pillBg: "#6b728015", + }, + OPEN: { + label: "Open", + bg: "bg-primary/10", + text: "text-primary", + dot: "bg-primary", + pillBg: "#E4621215", + }, + UNDER_REVIEW: { + label: "Under Review", + bg: "bg-blue-500/10", + text: "text-blue-600", + dot: "bg-blue-500", + pillBg: "#2563eb15", + }, + REVISION_REQUESTED: { + label: "Revision Requested", + bg: "bg-red-500/10", + text: "text-red-600", + dot: "bg-red-500", + pillBg: "#dc262615", + }, + CLOSED: { + label: "Closed", + bg: "bg-emerald-500/10", + text: "text-emerald-600", + dot: "bg-emerald-500", + pillBg: "#16a34a15", + }, + CANCELLED: { + label: "Cancelled", + bg: "bg-slate-500/10", + text: "text-slate-600", + dot: "bg-slate-500", + pillBg: "#6b728015", + }, +}; + +const CATEGORY_THEME: Record = { + EQUIPMENT: { color: "#2563eb", bg: "#2563eb15" }, + SERVICE: { color: "#16a34a", bg: "#16a34a15" }, + MIXED: { color: "#E46212", bg: "#E4621215" }, +}; + +const INVITE_STATUS_ICON: Record< + string, + { Icon: React.ComponentType; color: string; label: string } +> = { + PENDING: { Icon: Hourglass, color: "#94a3b8", label: "Pending" }, + SENT: { Icon: CheckCircle2, color: "#16a34a", label: "Sent" }, + FAILED: { Icon: XCircle, color: "#dc2626", label: "Failed" }, +}; + +function fmtDate(d?: string) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(); +} + +export default function ProformaRequestDetailScreen() { + const nav = useSirouRouter(); + const { id } = useLocalSearchParams(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [showShareSheet, setShowShareSheet] = useState(false); + + const fetch = useCallback(async () => { + try { + setLoading(true); + const reqId = Array.isArray(id) ? id[0] : id; + if (!reqId) return; + const result = await api.proformaRequests.getById({ + params: { id: reqId }, + }); + setData(result); + } catch (err: any) { + toast.error("Error", "Failed to load proforma request"); + } finally { + setLoading(false); + } + }, [id]); + + useFocusEffect( + useCallback(() => { + fetch(); + }, [fetch]), + ); + + const handleShare = async (channel: "system" | "email") => { + if (!data?.inviteUrl) { + toast.error("No invite link", "This request has no invite URL yet"); + return; + } + try { + if (channel === "email") { + await Linking.openURL( + `mailto:?subject=${encodeURIComponent( + data.title || "Proforma Request", + )}&body=${encodeURIComponent(data.inviteUrl)}`, + ); + } else { + await Share.share({ + message: `${data.title || "Proforma Request"}\n${data.inviteUrl}`, + }); + } + setShowShareSheet(false); + } catch (err: any) { + toast.error("Error", err?.message || "Failed to share"); + } + }; + + if (loading || !data) { + return ( + + + + + + + ); + } + + const statusKey = (data.status || "DRAFT").toUpperCase(); + const theme = STATUS_THEME[statusKey] || STATUS_THEME.DRAFT; + const categoryKey = (data.category || "MIXED").toUpperCase(); + const categoryTheme = CATEGORY_THEME[categoryKey] || CATEGORY_THEME.MIXED; + const items: any[] = Array.isArray(data.items) ? data.items : []; + const invites: any[] = Array.isArray(data.invites) ? data.invites : []; + const submissionCount = data.submissionCount ?? 0; + const totalQuantity = items.reduce( + (s: number, i: any) => s + (Number(i.quantity) || 0), + 0, + ); + + return ( + + + + + + {/* Hero Card */} + + + + + + + + + {data.title || "Untitled request"} + + + + + {categoryKey} + + + + + + + + {theme.label} + + + + + {data.description ? ( + + {data.description} + + ) : null} + + + + + Deadline + + + + + {fmtDate(data.submissionDeadline)} + + + + + + + Submissions + + + + + {submissionCount} + + + + + + + + {/* Items */} + {items.length > 0 && ( + + + + Requested Items ({items.length}) + + + {totalQuantity} units + + + + {items.map((item: any, idx: number) => ( + + + + {item.itemName || `Item ${idx + 1}`} + + + {item.quantity || 0} {item.unitOfMeasure || "unit"} + + + {item.itemDescription ? ( + + {item.itemDescription} + + ) : null} + {item.technicalSpecifications && + Object.keys(item.technicalSpecifications).length > 0 ? ( + + {Object.entries( + item.technicalSpecifications as Record, + ).map(([k, v]) => ( + + + {k}: {String(v)} + + + ))} + + ) : null} + + ))} + + + )} + + {/* Commercial Terms */} + + + Commercial Terms + + + {data.paymentTerms ? ( + + ) : null} + {data.incoterms ? ( + + ) : null} + {data.validityPeriod != null ? ( + + ) : null} + + + {data.discountStructure ? ( + + ) : null} + + + + {/* Invite link */} + {data.inviteUrl ? ( + + + Invite Link + + + + + + {data.inviteUrl} + + + setShowShareSheet(true)} + className="h-9 rounded-[6px] bg-primary flex-row items-center justify-center gap-1.5" + > + + + Share Invite Link + + + + + ) : null} + + {/* Invites */} + {invites.length > 0 && ( + + + Invites ({invites.length}) + + + {invites.map((inv: any, idx: number) => { + const key = (inv.status || "PENDING").toUpperCase(); + const invTheme = INVITE_STATUS_ICON[key] || INVITE_STATUS_ICON.PENDING; + const { Icon, color, label } = invTheme; + return ( + + + + + + + + {inv.customerName || "Customer"} + + {inv.sentTo ? ( + + {inv.sentTo} + + ) : null} + + + + + {label} + + + + {inv.sendError ? ( + + {inv.sendError} + + ) : null} + + ); + })} + + + )} + + {/* Notes fallback / no-data state */} + {items.length === 0 && !data.description && ( + + + + + This request has no items or description yet. + + + + )} + + + setShowShareSheet(false)} + > + setShowShareSheet(false)} + className="flex-1 bg-black/40 justify-end" + > + {}} + className="bg-background rounded-t-3xl p-5 pb-8" + style={{ + borderTopWidth: 1, + borderColor: isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.05)", + }} + > + + + Share Invite Link + + setShowShareSheet(false)} hitSlop={8}> + + + + handleShare("system")} + /> + handleShare("email")} + /> + + + + + ); +} + +function TermRow({ + icon: Icon, + label, + value, + divider, + multiline, +}: { + icon: React.ComponentType; + label: string; + value: string; + divider?: boolean; + multiline?: boolean; +}) { + return ( + + + + + + + {label} + + + {value} + + + + ); +} + +function ShareOption({ + icon: Icon, + label, + description, + onPress, +}: { + icon: React.ComponentType; + label: string; + description: string; + onPress: () => void; +}) { + return ( + + + + + + {label} + + {description} + + + + ); +} diff --git a/app/proforma-requests/create.tsx b/app/proforma-requests/create.tsx new file mode 100644 index 0000000..cacc752 --- /dev/null +++ b/app/proforma-requests/create.tsx @@ -0,0 +1,793 @@ +import React, { useState, useEffect } from "react"; +import { View, Pressable, TextInput, StyleSheet, Switch } from "react-native"; +import { useColorScheme } from "nativewind"; +import { Text } from "@/components/ui/text"; +import { ChevronDown, Plus, Trash2, X } 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 { PickerModal, SelectOption } from "@/components/PickerModal"; +import { CalendarGrid } from "@/components/CalendarGrid"; +import { FormFlow } from "@/components/FormFlow"; +import { CustomerPicker } from "@/components/CustomerPicker"; + +type Item = { + id: number; + itemName: string; + itemDescription: string; + quantity: string; + unitOfMeasure: 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, + textAlignVertical: "center", + textAlign: "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 ( + + + {label} + + + + ); +} + +function PickerField({ + label, + value, + onPress, + flex, +}: { + label: string; + value: string; + onPress: () => void; + flex?: number; +}) { + const c = useInputColors(); + return ( + + + {label} + + + + {value || "Select"} + + + + + ); +} + +const CATEGORIES = ["EQUIPMENT", "SERVICE", "MIXED"]; +const INCOTERMS = ["EXW", "FOB", "CIF", "DAP", "DDP"]; +const INVITE_CHANNELS = ["EMAIL", "PHONE"]; +const UNITS = ["unit", "kg", "m", "m²", "m³", "litre", "hour", "service"]; + +const STEPS = [ + { key: "details", label: "Details" }, + { key: "items", label: "Items" }, + { key: "terms", label: "Terms" }, + { key: "schedule", label: "Schedule" }, + { key: "summary", label: "Summary" }, +]; + +export default function CreateProformaRequestScreen() { + const nav = useSirouRouter(); + const [step, setStep] = useState(0); + const [submitting, setSubmitting] = useState(false); + + // Step 1 — Details + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [category, setCategory] = useState("EQUIPMENT"); + + // Step 2 — Items + const [items, setItems] = useState([ + { + id: 1, + itemName: "", + itemDescription: "", + quantity: "1", + unitOfMeasure: "unit", + }, + ]); + + // Step 3 — Terms + const [paymentTerms, setPaymentTerms] = useState("Net 30 days"); + const [incoterms, setIncoterms] = useState("EXW"); + const [taxIncluded, setTaxIncluded] = useState(false); + const [discountStructure, setDiscountStructure] = useState(""); + const [validityPeriod, setValidityPeriod] = useState("30"); + const [allowRevisions, setAllowRevisions] = useState(true); + + // Step 4 — Schedule & Invite + const [submissionDeadline, setSubmissionDeadline] = useState(""); + const [inviteChannel, setInviteChannel] = useState("EMAIL"); + const [customerIds, setCustomerIds] = useState([]); + const [selectedCustomers, setSelectedCustomers] = useState< + { id: string; name: string; email: string; phone: string }[] + >([]); + + // Modal visibility + const [showCategory, setShowCategory] = useState(false); + const [showIncoterms, setShowIncoterms] = useState(false); + const [showInviteChannel, setShowInviteChannel] = useState(false); + const [showDeadline, setShowDeadline] = useState(false); + + const c = useInputColors(); + + useEffect(() => { + const d = new Date(); + d.setDate(d.getDate() + 14); + setSubmissionDeadline(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(), + itemName: "", + itemDescription: "", + quantity: "1", + unitOfMeasure: "unit", + }, + ]); + + const removeItem = (id: number) => { + if (items.length > 1) setItems((prev) => prev.filter((it) => it.id !== id)); + }; + + const setItemUnit = (id: number, unit: string) => + updateItem(id, "unitOfMeasure", unit); + + const handleNext = () => { + if (step === 0 && !title.trim()) { + toast.error("Validation", "Title is required"); + return; + } + if (step === 1) { + if (items.length === 0) { + toast.error("Validation", "Add at least one item"); + return; + } + if (items.every((it) => !it.itemName.trim())) { + toast.error("Validation", "At least one item must have a name"); + return; + } + } + setStep((s) => s + 1); + }; + + const handleSubmit = async () => { + if (!title.trim()) { + toast.error("Validation", "Title is required"); + return; + } + if (!submissionDeadline) { + toast.error("Validation", "Submission deadline is required"); + return; + } + + const payload = { + title: title.trim(), + description: description.trim() || undefined, + category, + submissionDeadline: new Date(submissionDeadline).toISOString(), + allowRevisions, + paymentTerms: paymentTerms.trim() || undefined, + incoterms: incoterms || undefined, + taxIncluded, + discountStructure: discountStructure.trim() || undefined, + validityPeriod: parseInt(validityPeriod, 10) || 30, + items: items + .filter((it) => it.itemName.trim()) + .map((it) => ({ + itemName: it.itemName.trim(), + itemDescription: it.itemDescription.trim() || undefined, + quantity: parseInt(it.quantity, 10) || 1, + unitOfMeasure: it.unitOfMeasure || "unit", + })), + customerIds: customerIds.length > 0 ? customerIds : undefined, + inviteChannel, + }; + + try { + setSubmitting(true); + await api.proformaRequests.create({ + body: payload, + headers: { "Content-Type": "application/json" }, + }); + toast.success("Success", "Proforma request created successfully!"); + nav.back(); + } catch (err: any) { + console.error("[ProformaRequestCreate] Error:", err); + toast.error( + "Error", + err?.response?.data?.message || + err?.message || + "Failed to create request", + ); + } finally { + setSubmitting(false); + } + }; + + const totalQuantity = items.reduce( + (s, it) => s + (parseInt(it.quantity, 10) || 0), + 0, + ); + const namedItemCount = items.filter((it) => it.itemName.trim()).length; + + return ( + + setStep(step - 1)} + onComplete={handleSubmit} + loading={submitting} + completeLabel="Create Request" + > + {step === 0 && ( + + + Request Details + + + + + setShowCategory(true)} + /> + + + )} + + {step === 1 && ( + + + + Requested Items + + + + + Add + + + + + {items.map((item, index) => ( + 1} + onUpdate={updateItem} + onRemove={removeItem} + onPickUnit={(u) => setItemUnit(item.id, u)} + /> + ))} + + + )} + + {step === 2 && ( + + + Commercial Terms + + + + + setShowIncoterms(true)} + flex={2} + /> + + + + + + + Options + + + + + + + + )} + + {step === 3 && ( + + + Deadline & Invitation + + + setShowDeadline(true)} + /> + setShowInviteChannel(true)} + /> + + + Customers + + { + setCustomerIds(ids); + setSelectedCustomers(customers); + }} + placeholder="Select customers to invite" + /> + {selectedCustomers.length > 0 && ( + + {selectedCustomers.map((cust) => ( + + + {cust.name} + + { + setCustomerIds((ids) => + ids.filter((id) => id !== cust.id), + ); + setSelectedCustomers((prev) => + prev.filter((c) => c.id !== cust.id), + ); + }} + hitSlop={6} + > + + + + ))} + + )} + + + + )} + + {step === 4 && ( + + + Summary + + + + {description ? ( + + ) : null} + + + {namedItemCount > 0 ? ( + + + Items ({namedItemCount}) + + {items + .filter((it) => it.itemName.trim()) + .map((it, i) => ( + + + {it.itemName} + + + {it.quantity} {it.unitOfMeasure} + + + ))} + + ) : null} + + + + + {discountStructure ? ( + + ) : null} + + + Tax Included + + + {taxIncluded ? "Yes" : "No"} + + + + + Allow Revisions + + + {allowRevisions ? "Yes" : "No"} + + + + + + {selectedCustomers.length > 0 ? ( + + + Customers ({selectedCustomers.length}) + + {selectedCustomers.map((cust) => ( + + {cust.name} + + ))} + + ) : null} + + + )} + + + setShowCategory(false)} + title="Select Category" + > + {CATEGORIES.map((cat) => ( + { + setCategory(v); + setShowCategory(false); + }} + /> + ))} + + + setShowIncoterms(false)} + title="Select Incoterms" + > + {INCOTERMS.map((t) => ( + { + setIncoterms(v); + setShowIncoterms(false); + }} + /> + ))} + + + setShowInviteChannel(false)} + title="Invite Channel" + > + {INVITE_CHANNELS.map((ch) => ( + { + setInviteChannel(v); + setShowInviteChannel(false); + }} + /> + ))} + + + setShowDeadline(false)} + title="Submission Deadline" + > + { + setSubmissionDeadline(v); + setShowDeadline(false); + }} + /> + + + ); +} + +function ToggleRow({ + title, + subtitle, + value, + onValueChange, +}: { + title: string; + subtitle: string; + value: boolean; + onValueChange: (v: boolean) => void; +}) { + return ( + + + + {title} + + + {subtitle} + + + + + ); +} + +function ItemRow({ + item, + index, + canRemove, + onUpdate, + onRemove, + onPickUnit, +}: { + item: Item; + index: number; + canRemove: boolean; + onUpdate: (id: number, field: keyof Item, value: string) => void; + onRemove: (id: number) => void; + onPickUnit: (u: string) => void; +}) { + const [showUnits, setShowUnits] = useState(false); + const c = useInputColors(); + return ( + + + + Item {index + 1} + + {canRemove && ( + onRemove(item.id)} hitSlop={8}> + + + )} + + onUpdate(item.id, "itemName", v)} + /> + + onUpdate(item.id, "itemDescription", v)} + multiline + /> + + + onUpdate(item.id, "quantity", v)} + flex={1} + /> + + + Unit + + setShowUnits(true)} + className="h-10 px-3 border border-border rounded-[6px] flex-row items-center justify-between" + style={{ backgroundColor: c.bg, borderColor: c.border }} + > + + {item.unitOfMeasure || "unit"} + + + + + + setShowUnits(false)} + title="Unit of Measure" + > + {UNITS.map((u) => ( + { + onPickUnit(v); + setShowUnits(false); + }} + /> + ))} + + + ); +} + +function Row({ + label, + value, + multiline, +}: { + label: string; + value: string; + multiline?: boolean; +}) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/app/proforma.tsx b/app/proforma.tsx index 1f245f9..8724a7c 100644 --- a/app/proforma.tsx +++ b/app/proforma.tsx @@ -1,24 +1,36 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { CommandPalette } from "@/components/CommandPalette"; import { View, + ScrollView, Pressable, - ActivityIndicator, - FlatList, - ListRenderItem, TextInput, + ActivityIndicator, } from "react-native"; import { Text } from "@/components/ui/text"; import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { useSirouRouter } from "@sirou/react-native"; +import { useFocusEffect } from "expo-router"; import { AppRoutes } from "@/lib/routes"; -import { Plus, FileText, Search } from "@/lib/icons"; +import { + Plus, + FileText, + Search, + ChevronRight, + Inbox, +} from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; -import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/EmptyState"; import { api } from "@/lib/api"; import { useAuthStore } from "@/lib/auth-store"; -import { PERMISSION_MAP, hasPermission } from "@/lib/permissions"; +import { toast } from "@/lib/toast-store"; +import { useColorScheme } from "nativewind"; +import { getPlaceholderColor } from "@/lib/colors"; +import { hasPermission, PERMISSION_MAP } from "@/lib/permissions"; + +type Tab = "proforma" | "request"; interface ProformaItem { id: string; @@ -41,170 +53,141 @@ interface ProformaItem { updatedAt: string; } -const dummyData: ProformaItem = { - id: "dummy-1", - proformaNumber: "PF-001", - customerName: "John Doe", - customerEmail: "john@example.com", - customerPhone: "+1234567890", - amount: { value: 1000, currency: "USD" }, - currency: "USD", - issueDate: "2026-03-10T11:51:36.134Z", - dueDate: "2026-03-10T11:51:36.134Z", - description: "Dummy proforma", - notes: "Test notes", - taxAmount: { value: 100, currency: "USD" }, - discountAmount: { value: 50, currency: "USD" }, - pdfPath: "dummy.pdf", - userId: "user-1", - items: [ - { - id: "item-1", - description: "Test item", - quantity: 1, - unitPrice: { value: 1000, currency: "USD" }, - total: { value: 1000, currency: "USD" }, - }, - ], - createdAt: "2026-03-10T11:51:36.134Z", - updatedAt: "2026-03-10T11:51:36.134Z", +interface ProformaRequest { + id: string; + title: string; + description: string; + category: "EQUIPMENT" | "SERVICE" | "MIXED"; + status: + | "DRAFT" + | "OPEN" + | "UNDER_REVIEW" + | "REVISION_REQUESTED" + | "CLOSED" + | "CANCELLED"; + submissionDeadline: string; + items: { id: string; itemName: string; quantity: number; unitOfMeasure: string }[]; + createdAt: string; + updatedAt: string; +} + +const REQUEST_STATUS_COLORS: Record = { + DRAFT: "#6b7280", + OPEN: "#E46212", + UNDER_REVIEW: "#2563eb", + REVISION_REQUESTED: "#dc2626", + CLOSED: "#16a34a", + CANCELLED: "#6b7280", +}; + +const REQUEST_STATUS_BG: Record = { + DRAFT: "#6b728015", + OPEN: "#E4621215", + UNDER_REVIEW: "#2563eb15", + REVISION_REQUESTED: "#dc262615", + CLOSED: "#16a34a15", + CANCELLED: "#6b728015", +}; + +const CATEGORY_COLORS: Record = { + EQUIPMENT: "#2563eb", + SERVICE: "#16a34a", + MIXED: "#E46212", }; export default function ProformaScreen() { const nav = useSirouRouter(); const permissions = useAuthStore((s) => s.permissions); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + const [tab, setTab] = useState("proforma"); + const [searchOpen, setSearchOpen] = useState(false); + + // Proforma state const [proformas, setProformas] = useState([]); const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [search, setSearch] = useState(""); + // Request state + const [requests, setRequests] = useState([]); + const [requestsLoading, setRequestsLoading] = useState(false); + const [reqPage, setReqPage] = useState(1); + const [reqHasMore, setReqHasMore] = useState(true); + const [reqLoadingMore, setReqLoadingMore] = useState(false); + const canCreateProformas = hasPermission( permissions, PERMISSION_MAP["proforma:create"], ); - const fetchProformas = useCallback( - async (pageNum: number, isRefresh = false) => { - const { isAuthenticated } = useAuthStore.getState(); - if (!isAuthenticated) return; + const fetchProformas = useCallback(async (pageNum: number) => { + const { isAuthenticated } = useAuthStore.getState(); + if (!isAuthenticated) return; + try { + pageNum === 1 ? setLoading(true) : setLoadingMore(true); + const response = await api.proforma.getAll({ + query: { page: pageNum, limit: 10 }, + }); + const newProformas = response.data; + setProformas((prev) => + pageNum === 1 ? newProformas : [...prev, ...newProformas], + ); + setHasMore(response.meta.hasNextPage); + setPage(pageNum); + } catch (err: any) { + console.error("[Proforma] Fetch error:", err); + setHasMore(false); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, []); - try { - if (!isRefresh) { - pageNum === 1 ? setLoading(true) : setLoadingMore(true); - } + const fetchRequests = useCallback(async (pageNum: number) => { + const { isAuthenticated } = useAuthStore.getState(); + if (!isAuthenticated) return; + try { + pageNum === 1 ? setRequestsLoading(true) : setReqLoadingMore(true); + const response = await api.proformaRequests.getAll({ + query: { page: pageNum, limit: 10 }, + }); + const newRequests = response.data; + setRequests((prev) => + pageNum === 1 ? newRequests : [...prev, ...newRequests], + ); + setReqHasMore(response.meta.hasNextPage); + setReqPage(pageNum); + } catch (err: any) { + console.error("[ProformaRequests] Fetch error:", err); + toast.error("Error", "Failed to fetch proforma requests."); + } finally { + setRequestsLoading(false); + setReqLoadingMore(false); + } + }, []); - const response = await api.proforma.getAll({ - query: { page: pageNum, limit: 10 }, - }); - - let newProformas = response.data; - - const newData = newProformas; - if (isRefresh) { - setProformas(newData); - } else { - setProformas((prev) => - pageNum === 1 ? newData : [...prev, ...newData], - ); - } - setHasMore(response.meta.hasNextPage); - setPage(pageNum); - } catch (err: any) { - console.error("[Proforma] Fetch error:", err); - setHasMore(false); - } finally { - setLoading(false); - setRefreshing(false); - setLoadingMore(false); - } - }, - [], + useFocusEffect( + useCallback(() => { + if (tab === "proforma") fetchProformas(1); + else fetchRequests(1); + }, [tab, fetchProformas, fetchRequests]), ); - useEffect(() => { - fetchProformas(1); - }, [fetchProformas]); - - const onRefresh = () => { - setRefreshing(true); - fetchProformas(1, true); - }; - const loadMore = () => { - if (hasMore && !loadingMore && !loading) { + if (tab === "proforma" && hasMore && !loadingMore && !loading) { fetchProformas(page + 1); } - }; - - const renderProformaItem: ListRenderItem = ({ item }) => { - const amountVal = - typeof item.amount === "object" ? item.amount.value : item.amount; - const issuedStr = item.issueDate - ? new Date(item.issueDate).toLocaleDateString() - : ""; - const dueStr = item.dueDate - ? new Date(item.dueDate).toLocaleDateString() - : ""; - const itemsCount = Array.isArray(item.items) ? item.items.length : 0; - - return ( - - nav.go("proforma/[id]", { id: item.id })} - className="mb-3" - > - - - - - - - - - - - - {item.proformaNumber || "Proforma"} - - - {item.customerName || "Customer"} - - - - - - {item.currency || "$"} - {amountVal?.toLocaleString?.() ?? amountVal ?? "0"} - - - - - - - Issued: {issuedStr} | Due: {dueStr} | {itemsCount} item - {itemsCount !== 1 ? "s" : ""} - - - - - - - - - ); + if ( + tab === "request" && + reqHasMore && + !reqLoadingMore && + !requestsLoading + ) { + fetchRequests(reqPage + 1); + } }; const filteredProformas = useMemo(() => { @@ -226,63 +209,288 @@ export default function ProformaScreen() { }); }, [proformas, search]); + const filteredRequests = useMemo(() => { + if (!search.trim()) return requests; + const q = search.toLowerCase(); + return requests.filter((r) => { + if (r.title?.toLowerCase().includes(q)) return true; + if (r.description?.toLowerCase().includes(q)) return true; + if (r.category?.toLowerCase().includes(q)) return true; + if (r.status?.toLowerCase().includes(q)) return true; + return false; + }); + }, [requests, search]); + + const renderProformaCard = (item: ProformaItem) => { + const amountVal = + typeof item.amount === "object" ? item.amount.value : item.amount; + const issuedStr = item.issueDate + ? new Date(item.issueDate).toLocaleDateString() + : ""; + const dueStr = item.dueDate ? new Date(item.dueDate).toLocaleDateString() : ""; + const itemsCount = Array.isArray(item.items) ? item.items.length : 0; + + return ( + nav.go("proforma/[id]", { id: item.id })} + className="mb-2" + > + + + + + + + + + {item.proformaNumber || "Proforma"} + + + {item.currency || "ETB"}{" "} + {(amountVal || 0).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + + + {item.customerName || "Customer"} · Issued {issuedStr} + {dueStr ? ` · Due ${dueStr}` : ""} · {itemsCount} item + {itemsCount !== 1 ? "s" : ""} + + + + + + + ); + }; + + const renderRequestCard = (req: ProformaRequest) => { + const statusColor = REQUEST_STATUS_COLORS[req.status] || "#6b7280"; + const statusBg = REQUEST_STATUS_BG[req.status] || "#6b728015"; + const categoryColor = CATEGORY_COLORS[req.category] || "#6b7280"; + const deadlineStr = req.submissionDeadline + ? new Date(req.submissionDeadline).toLocaleDateString() + : ""; + const itemsCount = Array.isArray(req.items) ? req.items.length : 0; + + return ( + nav.go("proforma-requests/[id]", { id: req.id })} + className="mb-2" + > + + + + + + + + + {req.title || "Untitled request"} + + + + {req.status.replace(/_/g, " ")} + + + + + {itemsCount} item{itemsCount !== 1 ? "s" : ""} · Deadline{" "} + {deadlineStr || "—"} + + + + + {req.category} + + + + + + + + ); + }; + + const isLoading = + tab === "proforma" ? loading && page === 1 : requestsLoading && reqPage === 1; + if (isLoading) { + return ( + + setSearchOpen(true)} + /> + + + + + ); + } + + const items = tab === "proforma" ? filteredProformas : filteredRequests; + return ( - item.id} + - - - - - - - - - - } - ListFooterComponent={ - loadingMore ? ( - - ) : null - } - ListEmptyComponent={ - !loading ? ( - { + const isCloseToBottom = + nativeEvent.layoutMeasurement.height + + nativeEvent.contentOffset.y >= + nativeEvent.contentSize.height - 20; + if (isCloseToBottom) loadMore(); + }} + scrollEventThrottle={400} + > + setSearchOpen(true)} + /> + + + + - ) : ( - - + + + + + {/* Tabs */} + + { + if (tab !== "proforma") { + setTab("proforma"); + setSearch(""); + } + }} + className={`flex-1 py-2 rounded-[8px] items-center ${ + tab === "proforma" ? "bg-primary" : "" + }`} + > + + Proforma + + + { + if (tab !== "request") { + setTab("request"); + setSearch(""); + } + }} + className={`flex-1 py-2 rounded-[8px] items-center ${ + tab === "request" ? "bg-primary" : "" + }`} + > + + Requests + + + + + + {items.length > 0 ? ( + tab === "proforma" + ? (items as ProformaItem[]).map(renderProformaCard) + : (items as ProformaRequest[]).map(renderRequestCard) + ) : ( + + )} + + + {(loadingMore || reqLoadingMore) && ( + + - ) - } + )} + + + + setSearchOpen(false)} /> ); diff --git a/app/proforma/[id].tsx b/app/proforma/[id].tsx index 49d4611..34e5c09 100644 --- a/app/proforma/[id].tsx +++ b/app/proforma/[id].tsx @@ -3,22 +3,21 @@ import { View, ScrollView, ActivityIndicator, - Alert, Linking, Pressable, Modal, Dimensions, + StyleSheet, + Image, } from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; import { Stack, useLocalSearchParams, useFocusEffect } from "expo-router"; import { Text } from "@/components/ui/text"; +import { EmptyState } from "@/components/EmptyState"; import { FileText, Calendar, - Download, - Trash2, - Package, Clock, User, Hash, @@ -26,9 +25,15 @@ import { Edit, Mail, MessageSquare, - Globe, MoreVertical, X, + Package, + Share2, + Download, + TrendingUp, + TrendingDown, + Check, + Trash2, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; @@ -36,6 +41,9 @@ import { api, BASE_URL } from "@/lib/api"; import { toast } from "@/lib/toast-store"; import { useAuthStore } from "@/lib/auth-store"; import { useColorScheme } from "nativewind"; +import { ActionModal } from "@/components/ActionModal"; +import { SendHorizonal } from "lucide-react-native"; +import ticketImage from "@/assets/ticket.png"; const { height: SCREEN_HEIGHT } = Dimensions.get("window"); @@ -49,12 +57,40 @@ function fmt(v: number, currency = "ETB") { return `${currency} ${v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } -const STATUS_THEME: Record = { - PAID: { label: "Paid", bg: "bg-emerald-500/10", text: "text-emerald-600", dot: "bg-emerald-500" }, - PENDING: { label: "Pending", bg: "bg-amber-500/10", text: "text-amber-600", dot: "bg-amber-500" }, - DRAFT: { label: "Draft", bg: "bg-blue-500/10", text: "text-blue-600", dot: "bg-blue-500" }, - CANCELLED: { label: "Cancelled", bg: "bg-slate-500/10", text: "text-slate-600", dot: "bg-slate-500" }, - DEFAULT: { label: "Unknown", bg: "bg-slate-500/10", text: "text-slate-500", dot: "bg-slate-500" }, +const STATUS_THEME: Record< + string, + { label: string; bg: string; text: string; dot: string } +> = { + PAID: { + label: "Paid", + bg: "bg-emerald-500/10", + text: "text-emerald-600", + dot: "bg-emerald-500", + }, + PENDING: { + label: "Pending", + bg: "bg-amber-500/10", + text: "text-amber-600", + dot: "bg-amber-500", + }, + DRAFT: { + label: "Draft", + bg: "bg-blue-500/10", + text: "text-blue-600", + dot: "bg-blue-500", + }, + CANCELLED: { + label: "Cancelled", + bg: "bg-slate-500/10", + text: "text-slate-600", + dot: "bg-slate-500", + }, + DEFAULT: { + label: "Unknown", + bg: "bg-slate-500/10", + text: "text-slate-500", + dot: "bg-slate-500", + }, }; export default function ProformaDetailScreen() { @@ -65,9 +101,18 @@ export default function ProformaDetailScreen() { const [loading, setLoading] = useState(true); const [proforma, setProforma] = useState(null); - const [showActions, setShowActions] = useState(false); + const [activeTab, setActiveTab] = useState<"details" | "items">("details"); + const [showMoreSheet, setShowMoreSheet] = useState(false); + const [showSendSheet, setShowSendSheet] = useState(false); + const [sharing, setSharing] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleting, setDeleting] = useState(false); - useFocusEffect(useCallback(() => { fetchProforma(); }, [id])); + useFocusEffect( + useCallback(() => { + fetchProforma(); + }, [id]), + ); const fetchProforma = async () => { try { @@ -93,26 +138,38 @@ export default function ProformaDetailScreen() { } }; - const handleDelete = () => { - Alert.alert("Delete Proforma", "This cannot be undone.", [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - const pid = Array.isArray(id) ? id[0] : id; - await api.proforma.delete({ params: { id: pid } }); - toast.success("Success", "Proforma deleted"); - nav.back(); - } catch { - toast.error("Error", "Failed to delete proforma"); - setLoading(false); - } - }, - }, - ]); + const handleShare = async (channel: "email" | "sms") => { + try { + setSharing(true); + const pid = Array.isArray(id) ? id[0] : id; + await api.proforma.shareLink({ body: { proformaId: pid, channel } }); + toast.success( + "Sent", + `Proforma shared via ${channel === "email" ? "email" : "SMS"}`, + ); + setShowSendSheet(false); + } catch (err: any) { + toast.error("Error", err?.message || "Failed to share proforma"); + } finally { + setSharing(false); + } + }; + + const handleDelete = () => setShowDeleteModal(true); + + const confirmDelete = async () => { + try { + setDeleting(true); + const pid = Array.isArray(id) ? id[0] : id; + await api.proforma.delete({ params: { id: pid } }); + toast.success("Deleted", "Proforma has been removed."); + setShowDeleteModal(false); + nav.back(); + } catch (err: any) { + toast.error("Error", err?.message || "Failed to delete proforma"); + } finally { + setDeleting(false); + } }; if (loading) { @@ -152,249 +209,438 @@ export default function ProformaDetailScreen() { const statusKey = (proforma.status || "DRAFT").toUpperCase(); const theme = STATUS_THEME[statusKey] || STATUS_THEME.DEFAULT; + const issueDate = proforma.issueDate ? new Date(proforma.issueDate) : null; + const dueDate = proforma.dueDate ? new Date(proforma.dueDate) : null; + + const formatLongDate = (d: Date) => + d.toLocaleDateString("en-US", { + day: "numeric", + month: "short", + year: "numeric", + }); + + const customerName = ( + proforma.customerName?.replace("Customer Name: ", "") || "Walking Client" + ).trim(); + + const ActionOption = ({ + icon, + label, + description, + onPress, + destructive, + }: { + icon: React.ReactNode; + label: string; + description: string; + onPress?: () => void; + destructive?: boolean; + }) => ( + + + {icon} + + + + {label} + + + {description} + + + + ); + return ( nav.go("proforma/edit", { id: proforma.id })} + right={ + setShowMoreSheet(true)} + className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" + > + + + } /> - {/* Hero — Amount + Status */} - - - - - Total Amount + {/* Hero Card — illustration overflows the top */} + + + + + + + + {amount.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + + {currency} - - - {amount.toLocaleString("en-US", { minimumFractionDigits: 2 })} - - - {currency} - - - - + + + {proforma.proformaNumber + ? `Proforma ${proforma.proformaNumber}` + : `Proforma #${(proforma.id || "").slice(0, 8).toUpperCase()}`} + + + {/* Status badge */} + - + {theme.label} - - - {/* Period Dates */} - - - - - + + + Issued - - - - {proforma.issueDate ? new Date(proforma.issueDate).toLocaleDateString() : "—"} - - + + {issueDate ? formatLongDate(issueDate) : "—"} + - - - + + Due - - - - {proforma.dueDate ? new Date(proforma.dueDate).toLocaleDateString() : "—"} - - - - - - - - {/* Customer */} - {proforma.customerName && ( - - - Customer - - - - - - - - - {proforma.customerName} - - {(proforma.customerEmail || proforma.customerPhone) && ( - - {[proforma.customerEmail, proforma.customerPhone].filter(Boolean).join(" · ")} - - )} - - - - - - #{proforma.id?.slice(0, 8) || "—"} + + {dueDate ? formatLongDate(dueDate) : "—"} - )} + - {/* Items */} - {items.length > 0 && ( - - - Items ({items.length}) - - - {items.map((item: any, idx: number) => ( - - - - {item.description || `Item ${idx + 1}`} + {/* Tabs */} + + + setActiveTab("details")} + className="pb-2.5" + > + + Details + + {activeTab === "details" && ( + + )} + + setActiveTab("items")} className="pb-2.5"> + + Items + + {activeTab === "items" && ( + + )} + + + + + {/* Tab content */} + {activeTab === "details" ? ( + + {/* Customer */} + {proforma.customerName && ( + + + Customer + + + + + + + + {customerName} + + {(proforma.customerEmail || proforma.customerPhone) && ( + + {proforma.customerEmail || proforma.customerPhone} + + )} + + + + )} + + {/* Proforma Details */} + + + Proforma Details + + + + + + + Proforma Number - {fmt(safeVal(item.total || item.unitPrice) * safeVal(item.quantity || 1), currency)} + {proforma.proformaNumber || + `PRF${(proforma.id || "").slice(0, 8).toUpperCase()}`} - - {safeVal(item.quantity)} × {fmt(safeVal(item.unitPrice), currency)} + + + + + + + Issue Date + + + {issueDate ? issueDate.toLocaleString() : "—"} + + + + + + + + + Due Date + + + {dueDate ? dueDate.toLocaleString() : "—"} + + + + + {proforma.description && ( + + + + + Description + + + {proforma.description} + + + + )} + + + + {/* Note (border only, no bg) */} + {proforma.notes && ( + + + Note + + + + {proforma.notes} - ))} - - - )} - - {items.length === 0 && ( - - - - No items - - - )} - - {/* Summary */} - - - Summary - - - - - Subtotal - - - {fmt(subtotal, currency)} - - - {tax > 0 && ( - - Tax - +{fmt(tax, currency)} )} - {discount > 0 && ( - - Discount - -{fmt(discount, currency)} + + ) : ( + + {items.length > 0 ? ( + + + Items + + + {items.map((item: any, idx: number) => { + const qty = safeVal(item.quantity || 1); + const unitPrice = safeVal(item.unitPrice); + const lineTotal = safeVal(item.total || qty * unitPrice); + return ( + + + + + + + {item.description || "No item"} + + + {qty} ×{" "} + {unitPrice.toLocaleString("en-US", { + minimumFractionDigits: 2, + })}{" "} + {currency} + + + + {lineTotal.toLocaleString("en-US", { + minimumFractionDigits: 2, + })}{" "} + {currency} + + + ); + })} + + + {/* Summary */} + + + + Subtotal + + + {fmt(subtotal, currency)} + + + {tax > 0 && ( + + + Tax + + + +{fmt(tax, currency)} + + + )} + {discount > 0 && ( + + + Discount + + + -{fmt(discount, currency)} + + + )} + + + Total + + + {fmt(amount, currency)} + + + + ) : ( + )} - - Total - - {fmt(amount, currency)} - - - - - {/* Description */} - {proforma.description ? ( - - - Description - - - - {proforma.description} - - - - ) : null} - - {/* Notes */} - {proforma.notes ? ( - - - Notes - - - - {proforma.notes} - - - - ) : null} - - {/* Actions Trigger */} - - setShowActions(true)} - className="bg-primary h-10 rounded-[6px] flex-row items-center justify-center gap-2" - > - - - Actions - - - + )} - {/* Actions Bottom Sheet */} + {/* Sticky bottom bar — Send + Download PDF (like invoice detail) */} + + setShowSendSheet(true)} + className="flex-1 h-12 rounded-[8px] border border-border items-center justify-center flex-row gap-2 bg-card" + > + + + Send + + + + + + Download PDF + + + + + {/* More bottom sheet */} setShowActions(false)} + onRequestClose={() => setShowMoreSheet(false)} > - setShowActions(false)}> + setShowMoreSheet(false)} + > e.stopPropagation()} > - {/* Header */} - Actions + + Proforma + setShowActions(false)} + onPress={() => setShowMoreSheet(false)} className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10" > - {/* PDF */} } - label="Download PDF" - description="Save proforma as PDF document" - onPress={() => { setShowActions(false); handleGetPdf(); }} + icon={} + label="Edit Proforma" + description="Update details, items, or dates" + onPress={() => { + setShowMoreSheet(false); + nav.go("proforma/edit", { id: proforma.id }); + }} /> - - {/* Delete */} } label="Delete Proforma" - description="Permanently remove this proforma" - onPress={() => { setShowActions(false); handleDelete(); }} - danger - /> - - {/* Send as Email */} - - - Send - - - } - label="Send as Email" - description="Public accessible shortened link via yaltopia.com" - /> - } - label="Send as SMS" - description="Public accessible shortened link via yaltopia.com" + description="Permanently remove this record" + onPress={() => { + setShowMoreSheet(false); + handleDelete(); + }} + destructive /> + + {/* Send bottom sheet (Email / SMS) */} + setShowSendSheet(false)} + > + setShowSendSheet(false)} + > + + e.stopPropagation()} + > + + + + Send Proforma + + setShowSendSheet(false)} + className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10" + > + + + + + + + Send a public, shortened link via yaltopia.com to your + customer's email or phone. + + } + label="Send as Email" + description="Public accessible shortened link via yaltopia.com" + onPress={() => handleShare("email")} + /> + + } + label="Send as SMS" + description="Public accessible shortened link via yaltopia.com" + onPress={() => handleShare("sms")} + /> + + + + + + + setShowDeleteModal(false)} + onConfirm={confirmDelete} + title="Delete Proforma" + description="Are you sure you want to permanently delete this proforma? This action cannot be reversed." + confirmText="Delete" + confirmVariant="destructive" + icon={Trash2} + iconColor="#ef4444" + loading={deleting} + /> ); } - -function ActionOption({ - icon, - label, - description, - onPress, - danger, -}: { - icon: React.ReactNode; - label: string; - description: string; - onPress?: () => void; - danger?: boolean; -}) { - return ( - - - {icon} - - - - {label} - - - {description} - - - - ); -} diff --git a/app/proforma/create.tsx b/app/proforma/create.tsx index 0fa4fcf..9a1e1e3 100644 --- a/app/proforma/create.tsx +++ b/app/proforma/create.tsx @@ -142,9 +142,11 @@ export default function CreateProformaScreen() { // Fields const [proformaNumber, setProformaNumber] = useState(""); + const [customerId, setCustomerId] = useState(""); const [customerName, setCustomerName] = useState(""); const [customerEmail, setCustomerEmail] = useState(""); const [customerPhone, setCustomerPhone] = useState(""); + const [selectedCustomers, setSelectedCustomers] = useState([]); const [description, setDescription] = useState(""); const [currency, setCurrency] = useState("ETB"); const [taxAmount, setTaxAmount] = useState("0"); @@ -301,11 +303,20 @@ export default function CreateProformaScreen() { Customer Name { - setCustomerName(c.name); - setCustomerEmail(c.email); - setCustomerPhone(c.phone.replace("+251", "")); + selectedIds={customerId ? [customerId] : []} + selectedCustomers={selectedCustomers} + onSelect={(ids, customers) => { + setCustomerId(ids[0] || ""); + setSelectedCustomers(customers); + if (customers[0]) { + setCustomerName(customers[0].name); + setCustomerEmail(customers[0].email); + setCustomerPhone(customers[0].phone?.replace("+251", "") || ""); + } else { + setCustomerName(""); + setCustomerEmail(""); + setCustomerPhone(""); + } }} placeholder="Select or search for a customer" /> diff --git a/app/proforma/edit.tsx b/app/proforma/edit.tsx index 9d75024..4e90a31 100644 --- a/app/proforma/edit.tsx +++ b/app/proforma/edit.tsx @@ -160,9 +160,11 @@ export default function EditProformaScreen() { const [step, setStep] = useState(0); const [proformaNumber, setProformaNumber] = useState(""); + const [customerId, setCustomerId] = useState(""); const [customerName, setCustomerName] = useState(""); const [customerEmail, setCustomerEmail] = useState(""); const [customerPhone, setCustomerPhone] = useState(""); + const [selectedCustomers, setSelectedCustomers] = useState([]); const [currency, setCurrency] = useState("USD"); const [description, setDescription] = useState(""); const [notes, setNotes] = useState(""); @@ -358,11 +360,20 @@ export default function EditProformaScreen() { Customer Name { - setCustomerName(c.name); - setCustomerEmail(c.email); - setCustomerPhone(c.phone.replace("+251", "")); + selectedIds={customerId ? [customerId] : []} + selectedCustomers={selectedCustomers} + onSelect={(ids, customers) => { + setCustomerId(ids[0] || ""); + setSelectedCustomers(customers); + if (customers[0]) { + setCustomerName(customers[0].name); + setCustomerEmail(customers[0].email); + setCustomerPhone(customers[0].phone?.replace("+251", "") || ""); + } else { + setCustomerName(""); + setCustomerEmail(""); + setCustomerPhone(""); + } }} placeholder="Select or search for a customer" /> diff --git a/app/team/[id]/details.tsx b/app/team/[id]/details.tsx index 0fbb5a4..0e476f7 100644 --- a/app/team/[id]/details.tsx +++ b/app/team/[id]/details.tsx @@ -52,7 +52,7 @@ export default function TeamMemberDetailsScreen() { if (loading) { return ( - + @@ -63,9 +63,9 @@ export default function TeamMemberDetailsScreen() { if (!member) { return ( - + - Worker not found + Team member not found ); @@ -73,7 +73,7 @@ export default function TeamMemberDetailsScreen() { return ( - + void; + placeholder: string; + icon?: React.ReactNode; + flex?: number; + numeric?: boolean; + secureTextEntry?: boolean; +}) { + const c = useInputColors(); + return ( + + + {label} + + + {icon} + + + + ); +} + +const ROLES = ["VIEWER", "EMPLOYEE", "ACCOUNTANT", "CUSTOMER_SERVICE"]; + +const STEPS = [ + { key: "name", label: "Name" }, + { key: "contact", label: "Contact" }, + { key: "access", label: "Access" }, +]; + +export default function CreateTeamMemberScreen() { + const nav = useSirouRouter(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + const iconColor = isDark ? "#94a3b8" : "#64748b"; + + const [step, setStep] = useState(0); + const [submitting, setSubmitting] = useState(false); + const [showRolePicker, setShowRolePicker] = useState(false); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + const [role, setRole] = useState("VIEWER"); + const [password, setPassword] = useState(""); + + const handleNext = () => { + if (step === 0 && (!firstName.trim() || !lastName.trim())) { + toast.error("Validation", "First and last name are required"); + return; + } + if (step === 1 && (!email.trim() || !phone.trim())) { + toast.error("Validation", "Email and phone are required"); + return; + } + if (step < STEPS.length - 1) setStep(step + 1); + }; + + const handleBack = () => { + if (step > 0) setStep(step - 1); + }; + + const handleSubmit = async () => { + if (!password.trim()) { + toast.error("Validation", "Password is required"); + return; + } + + setSubmitting(true); + try { + const formattedPhone = `+251${phone.trim()}`; + await api.team.create({ + body: { + firstName: firstName.trim(), + lastName: lastName.trim(), + email: email.trim(), + phone: formattedPhone, + role, + password: password.trim(), + }, + }); + toast.success("Team Member Added", `${firstName} has been added to the team.`); + nav.back(); + } catch (err: any) { + toast.error("Creation Failed", err.message || "Failed to add team member"); + } finally { + setSubmitting(false); + } + }; + + return ( + + + {step === 0 && ( + + + + Personal Info + + + Enter the team member's full name + + + } + /> + } + /> + + )} + + {step === 1 && ( + + + + Contact Details + + + How to reach this team member + + + } + /> + + + Phone Number + + + + +251 + + + + + )} + + {step === 2 && ( + + + + System Access + + + Set role and initial password + + + + + + Role + + setShowRolePicker(true)} + style={[S.input, { backgroundColor: useInputColors().bg, borderColor: useInputColors().border, flexDirection: "row", alignItems: "center" }]} + > + + + {role.replace("_", " ")} + + + + + + } + secureTextEntry + /> + + )} + + + setShowRolePicker(false)} + title="Select Role" + > + {ROLES.map((r) => ( + { + setRole(v); + setShowRolePicker(false); + }} + /> + ))} + + + ); +} diff --git a/app/team/index.tsx b/app/team/index.tsx index dc85565..c6020a8 100644 --- a/app/team/index.tsx +++ b/app/team/index.tsx @@ -65,14 +65,14 @@ export default function TeamScreen() { return ( - + - Workers ({filteredWorkers?.length || 0}) + Members ({filteredWorkers?.length || 0}) @@ -159,7 +159,7 @@ export default function TeamScreen() { )) ) : ( )} diff --git a/assets/cbe.png b/assets/cbe1.png similarity index 100% rename from assets/cbe.png rename to assets/cbe1.png diff --git a/assets/ticket.png b/assets/ticket.png new file mode 100644 index 0000000..8fbab98 Binary files /dev/null and b/assets/ticket.png differ diff --git a/components/CommandPalette.tsx b/components/CommandPalette.tsx index f67f550..cf9a29d 100644 --- a/components/CommandPalette.tsx +++ b/components/CommandPalette.tsx @@ -3,7 +3,7 @@ import { View, Pressable, TextInput, useColorScheme, Modal, ScrollView } from "r import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; import { Text } from "@/components/ui/text"; -import { X, Search, FileText, ShieldCheck, Wallet, Receipt, Settings, User, HelpCircle, Briefcase, FolderOpen, BarChart3, DraftingCompass, Scan, Lock, Globe, History } from "@/lib/icons"; +import { X, Search, FileText, ShieldCheck, Wallet, Settings, User, HelpCircle, Briefcase, FolderOpen, BarChart3, DraftingCompass, Scan, Lock, Globe, History, Inbox } from "@/lib/icons"; const ICON_COLOR = "#E46212"; @@ -19,7 +19,6 @@ const FLOWS: Flow[] = [ { label: "Add Invoice", keywords: ["invoice", "create", "new", "bill"], route: "invoices/create", icon: }, { label: "Verify Payment", keywords: ["verify", "payment", "reference", "ft"], route: "verify-payment", icon: }, { label: "Create Payment", keywords: ["payment", "create", "new", "pay"], route: "payments/create", icon: }, - { label: "Add Receipt", keywords: ["receipt", "scan", "upload"], route: "add-receipt", icon: }, { label: "Settings", keywords: ["settings", "preferences", "theme"], route: "settings", icon: }, { label: "Profile", keywords: ["profile", "account", "user"], route: "profile", icon: }, { label: "Help & Support", keywords: ["help", "support", "ticket"], route: "help", icon: }, @@ -29,6 +28,7 @@ const FLOWS: Flow[] = [ { label: "Reports", keywords: ["reports", "analytics", "stats"], route: "reports/index", icon: }, { label: "Scan Receipt", keywords: ["scan", "camera", "receipt", "ocr"], route: "(tabs)/scan", icon: }, { label: "Proforma", keywords: ["proforma", "estimate", "quote"], route: "(tabs)/proforma", icon: }, + { label: "Proforma Requests", keywords: ["request", "rfq", "quote", "proforma", "inquiry"], route: "(tabs)/proforma", icon: }, { label: "News", keywords: ["news", "updates", "announcements"], route: "news/index", icon: }, { label: "Change PIN", keywords: ["pin", "password", "security", "change"], route: "set-pin", icon: }, { label: "Payment History", keywords: ["payments", "history", "transactions"], route: "history", icon: }, diff --git a/components/ConfirmSubmitModal.tsx b/components/ConfirmSubmitModal.tsx new file mode 100644 index 0000000..6a6e3ae --- /dev/null +++ b/components/ConfirmSubmitModal.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { Modal, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "./ui/text"; +import { Button } from "./ui/button"; +import { CheckCircle2, X } from "@/lib/icons"; +import { useColorScheme } from "nativewind"; + +interface ConfirmSubmitModalProps { + visible: boolean; + onClose: () => void; + onConfirm: () => void; + title?: string; + description?: string; + confirmText?: string; + cancelText?: string; + loading?: boolean; +} + +export function ConfirmSubmitModal({ + visible, + onClose, + onConfirm, + title = "Confirm submission", + description = "Are you sure all the information is correct? Please review before proceeding.", + confirmText = "Yes, submit", + cancelText = "Review again", + loading = false, +}: ConfirmSubmitModalProps) { + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + + return ( + + + e.stopPropagation()} + > + + + + + + + {title} + + + + + + + + {description} + + + + + + + + + + + ); +} diff --git a/components/CreateMethodSheet.tsx b/components/CreateMethodSheet.tsx new file mode 100644 index 0000000..8b5a0d8 --- /dev/null +++ b/components/CreateMethodSheet.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { Modal, Pressable, View, Dimensions, ScrollView } from "react-native"; +import { Text } from "./ui/text"; +import { ScanLine, X, Edit } from "@/lib/icons"; +import { useColorScheme } from "nativewind"; + +const { height: SCREEN_HEIGHT } = Dimensions.get("window"); + +interface CreateMethodSheetProps { + visible: boolean; + onClose: () => void; + onSelectScan: () => void; + onSelectManual: () => void; + title?: string; +} + +export function CreateMethodSheet({ + visible, + onClose, + onSelectScan, + onSelectManual, + title = "Create", +}: CreateMethodSheetProps) { + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + + return ( + + + + e.stopPropagation()} + > + + + + {title} + + + + + + + + + + + + + + Scan + + + Capture a photo and auto-fill the form + + + + + + + + + + + Enter manually + + + Enter all the details yourself + + + + + + + + + ); +} diff --git a/components/CustomerPicker.tsx b/components/CustomerPicker.tsx index bedb1d2..71cd324 100644 --- a/components/CustomerPicker.tsx +++ b/components/CustomerPicker.tsx @@ -10,25 +10,27 @@ import { } from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { Text } from "@/components/ui/text"; -import { Search, X, Plus, User, Building2, ChevronDown } from "@/lib/icons"; +import { Search, X, Plus, User, Building2, ChevronDown, Check } from "@/lib/icons"; import { api } from "@/lib/api"; import { useColorScheme } from "nativewind"; const { height: SCREEN_HEIGHT } = Dimensions.get("window"); interface CustomerData { + id: string; name: string; email: string; phone: string; } interface CustomerPickerProps { - value: string; - onSelect: (c: CustomerData) => void; + selectedIds: string[]; + selectedCustomers: CustomerData[]; + onSelect: (ids: string[], customers: CustomerData[]) => void; placeholder?: string; } -export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerProps) { +export function CustomerPicker({ selectedIds, selectedCustomers, onSelect, placeholder }: CustomerPickerProps) { const nav = useSirouRouter(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; @@ -38,8 +40,13 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP const [loading, setLoading] = useState(false); const [search, setSearch] = useState(""); + const [tempIds, setTempIds] = useState(selectedIds); + const [tempCustomers, setTempCustomers] = useState(selectedCustomers); + const openPicker = async () => { setShow(true); + setTempIds(selectedIds); + setTempCustomers(selectedCustomers); setSearch(""); setLoading(true); try { @@ -52,6 +59,29 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP } }; + const toggleCustomer = (c: any) => { + const id = String(c.id); + const name = c.displayName || `${c.firstName || ""} ${c.lastName || ""}`.trim() || c.companyName || ""; + + let newIds: string[]; + let newCustomers: CustomerData[]; + + if (tempIds.includes(id)) { + newIds = tempIds.filter((i) => i !== id); + newCustomers = tempCustomers.filter((p) => p.id !== id); + } else { + newIds = [...tempIds, id]; + newCustomers = [ + ...tempCustomers, + { id, name, email: c.email || "", phone: c.phone || "" }, + ]; + } + + setTempIds(newIds); + setTempCustomers(newCustomers); + onSelect(newIds, newCustomers); + }; + const filtered = useMemo(() => { if (!search.trim()) return customers; const q = search.toLowerCase(); @@ -65,14 +95,25 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP ); }, [customers, search]); + const triggerLabel = selectedIds.length === 0 + ? (placeholder || "Select customers") + : selectedIds.length === 1 + ? selectedCustomers[0]?.name || placeholder + : `${selectedIds.length} customers selected`; + return ( <> - - {value || placeholder || "Select a customer"} + 0 ? "text-foreground" : "text-muted-foreground" + }`} + numberOfLines={1} + > + {triggerLabel} @@ -119,7 +160,6 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP - {/* Add New Customer */} { setShow(false); nav.go("customers/create"); }} @@ -148,20 +188,14 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP ) : ( filtered.map((c: any) => { const isCompany = c.type === "COMPANY"; + const isSelected = tempIds.includes(String(c.id)); return ( { - setShow(false); - onSelect({ - name: c.displayName || `${c.firstName || ""} ${c.lastName || ""}`.trim() || c.companyName || "", - email: c.email || "", - phone: c.phone || "", - }); - }} - className="bg-card rounded-[6px] border border-border p-4 mb-3" + onPress={() => toggleCustomer(c)} + className="bg-card rounded-[6px] border border-border p-4 mb-3 flex-row items-center" > - + {isCompany ? ( @@ -185,6 +219,13 @@ export function CustomerPicker({ value, onSelect, placeholder }: CustomerPickerP + + {isSelected && } + ); }) diff --git a/components/ModalToast.tsx b/components/ModalToast.tsx index ad28673..60d5c0d 100644 --- a/components/ModalToast.tsx +++ b/components/ModalToast.tsx @@ -1,52 +1,54 @@ import React, { useEffect, useRef } from "react"; import { View, StyleSheet, Animated } from "react-native"; -import { CheckCircle2, AlertCircle, AlertTriangle, Lightbulb, X } from "@/lib/icons"; +import { + CheckCircle2, + AlertCircle, + AlertTriangle, + Lightbulb, +} from "@/lib/icons"; import { Text } from "@/components/ui/text"; import { useToast, ToastType } from "@/lib/toast-store"; import { useColorScheme } from "nativewind"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; const VARIANT_CONFIG: Record< ToastType, - { iconColor: string; borderColor: string; icon: React.ReactNode } + { iconColor: string; icon: typeof CheckCircle2 } > = { success: { - iconColor: "#16a34a", - borderColor: "#16a34a", - icon: , + iconColor: "#4ADE80", + icon: CheckCircle2, }, error: { - iconColor: "#dc2626", - borderColor: "#dc2626", - icon: , + iconColor: "#F87171", + icon: AlertCircle, }, warning: { - iconColor: "#d97706", - borderColor: "#d97706", - icon: , + iconColor: "#FBBF24", + icon: AlertTriangle, }, info: { - iconColor: "#E46212", - borderColor: "#E46212", - icon: , + iconColor: "#60A5FA", + icon: Lightbulb, }, }; export function ModalToast() { const { visible, type, title, message, hide, duration } = useToast(); const isDark = useColorScheme() === "dark"; - const insets = useSafeAreaInsets(); - const translateX = useRef(new Animated.Value(-40)).current; + const translateY = useRef(new Animated.Value(-20)).current; const opacity = useRef(new Animated.Value(0)).current; + const hideRef = useRef(hide); + hideRef.current = hide; + useEffect(() => { if (visible) { - translateX.setValue(-40); + translateY.setValue(-20); opacity.setValue(0); Animated.parallel([ - Animated.spring(translateX, { + Animated.spring(translateY, { toValue: 0, useNativeDriver: true, speed: 20, @@ -59,57 +61,48 @@ export function ModalToast() { }), ]).start(); - const timer = setTimeout(hide, duration); + const timer = setTimeout(() => { + Animated.timing(opacity, { + toValue: 0, + duration: 180, + useNativeDriver: true, + }).start(() => hideRef.current()); + }, duration); return () => clearTimeout(timer); } - }, [visible]); + }, [visible, duration]); if (!visible) return null; const config = VARIANT_CONFIG[type]; + const Icon = config.icon; return ( - - + - {config.icon} - + - - - {title} - - {message ? ( - - {message} + + + {title} - ) : null} - - - - - - + + + ); } @@ -119,27 +112,26 @@ const styles = StyleSheet.create({ zIndex: 9999, elevation: 50, }, + wrapper: { + position: "absolute", + top: 60, + left: 0, + right: 0, + alignItems: "center", + paddingHorizontal: 20, + }, toast: { - marginHorizontal: 16, - borderRadius: 12, - paddingHorizontal: 16, - paddingVertical: 12, + width: "100%", + maxWidth: 400, + borderRadius: 14, + paddingHorizontal: 18, + paddingVertical: 16, flexDirection: "row", alignItems: "center", - shadowColor: "#000", - shadowOpacity: 0.18, - shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, - }, - iconContainer: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: "center", - justifyContent: "center", + borderWidth: 1, + gap: 12, }, textContainer: { flex: 1, - marginLeft: 12, }, }); diff --git a/components/Toast.tsx b/components/Toast.tsx deleted file mode 100644 index 8b48c02..0000000 --- a/components/Toast.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React, { useEffect } from "react"; -import { View, Dimensions, Pressable } from "react-native"; -import { Text } from "@/components/ui/text"; -import { useToast, ToastType } from "@/lib/toast-store"; -import { - CheckCircle2, - AlertCircle, - AlertTriangle, - Lightbulb, - X, -} from "@/lib/icons"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Animated, { - useSharedValue, - useAnimatedStyle, - withSpring, - withTiming, - runOnJS, -} from "react-native-reanimated"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; - -const { width: SCREEN_WIDTH } = Dimensions.get("window"); -const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.35; - -const TOAST_VARIANTS: Record< - ToastType, - { - accent: string; - iconBg: string; - icon: React.ReactNode; - } -> = { - success: { - accent: "#16a34a", - iconBg: "#16a34a15", - icon: , - }, - info: { - accent: "#E46212", - iconBg: "#E4621215", - icon: , - }, - warning: { - accent: "#d97706", - iconBg: "#d9770615", - icon: , - }, - error: { - accent: "#dc2626", - iconBg: "#dc262615", - icon: , - }, -}; - -export function Toast() { - const { visible, type, title, message, hide, duration } = useToast(); - const insets = useSafeAreaInsets(); - - const opacity = useSharedValue(0); - const scale = useSharedValue(0.85); - const translateY = useSharedValue(-60); - const translateX = useSharedValue(0); - - useEffect(() => { - if (visible) { - opacity.value = withTiming(1, { duration: 200 }); - scale.value = withSpring(1, { damping: 14, stiffness: 160 }); - translateY.value = withSpring(0, { damping: 16, stiffness: 140 }); - translateX.value = 0; - - const timer = setTimeout(handleHide, duration); - return () => clearTimeout(timer); - } - }, [visible]); - - const handleHide = () => { - opacity.value = withTiming(0, { duration: 180 }); - scale.value = withTiming(0.92, { duration: 180 }); - translateY.value = withTiming(-40, { duration: 180 }, () => { - runOnJS(hide)(); - }); - }; - - const swipeGesture = Gesture.Pan() - .onUpdate((event) => { - translateX.value = event.translationX; - }) - .onEnd((event) => { - if (Math.abs(event.translationX) > SWIPE_THRESHOLD) { - translateX.value = withTiming( - event.translationX > 0 ? SCREEN_WIDTH : -SCREEN_WIDTH, - { duration: 200 }, - () => runOnJS(handleHide)(), - ); - } else { - translateX.value = withSpring(0); - } - }); - - const animatedStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - transform: [ - { translateY: translateY.value }, - { translateX: translateX.value }, - { scale: scale.value }, - ], - })); - - if (!visible) return null; - - const variant = TOAST_VARIANTS[type]; - - return ( - - - - - - - {variant.icon} - - - - - {title} - - {message ? ( - - {message} - - ) : null} - - - - - - - - - ); -} diff --git a/lib/api.ts b/lib/api.ts index 855229e..ec96f91 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -81,6 +81,7 @@ export const api = createApi({ endpoints: { get: { method: "GET", path: "company" }, update: { method: "PUT", path: "company" }, + paymentMethods: { method: "GET", path: "company/payment-methods" }, }, }, team: { @@ -115,6 +116,7 @@ export const api = createApi({ update: { method: "PUT", path: "payments/:id" }, associate: { method: "POST", path: "payments/:id/associate" }, verifySms: { method: "POST", path: "payments/:id/verify-sms" }, + flag: { method: "POST", path: "payments/:id/flag" }, delete: { method: "DELETE", path: "payments/:id" }, }, }, @@ -124,8 +126,9 @@ export const api = createApi({ getAll: { method: "GET", path: "payment-requests" }, getById: { method: "GET", path: "payment-requests/:id" }, create: { method: "POST", path: "payment-requests" }, + update: { method: "PUT", path: "payment-requests/:id" }, open: { method: "POST", path: "payment-requests/:id/open" }, - sendEmail: { method: "POST", path: "payment-requests/:id/send-email" }, + send: { method: "POST", path: "payment-requests/:id/send" }, }, }, proforma: { @@ -137,6 +140,15 @@ export const api = createApi({ update: { method: "PUT", path: "proforma/:id" }, delete: { method: "DELETE", path: "proforma/:id" }, getPdf: { method: "GET", path: "proforma/:id/pdf" }, + shareLink: { method: "POST", path: "proforma/share/link" }, + }, + }, + proformaRequests: { + middleware: [authMiddleware], + endpoints: { + getAll: { method: "GET", path: "proforma-requests" }, + getById: { method: "GET", path: "proforma-requests/:id" }, + create: { method: "POST", path: "proforma-requests" }, }, }, rbac: { @@ -165,6 +177,8 @@ export const api = createApi({ getAll: { method: "GET", path: "customers" }, getById: { method: "GET", path: "customers/:id" }, create: { method: "POST", path: "customers" }, + update: { method: "PUT", path: "customers/:id" }, + delete: { method: "DELETE", path: "customers/:id" }, }, }, declarations: { @@ -175,6 +189,7 @@ export const api = createApi({ create: { method: "POST", path: "declarations" }, update: { method: "PUT", path: "declarations/:id" }, delete: { method: "DELETE", path: "declarations/:id" }, + scan: { method: "POST", path: "declarations/scan" }, }, }, }, diff --git a/lib/icons.tsx b/lib/icons.tsx index 6a72588..38981c8 100644 --- a/lib/icons.tsx +++ b/lib/icons.tsx @@ -65,6 +65,7 @@ export { Triangle as TrianglePlanets, AlertTriangle, Lightbulb, + Flag, Check, MessageSquare, RefreshCw, @@ -82,4 +83,8 @@ export { MapPin, BookOpen, FileCheck, + Inbox, + Truck, + Hourglass, + XCircle, } from "lucide-react-native"; diff --git a/lib/payment-providers.ts b/lib/payment-providers.ts deleted file mode 100644 index ad6b6c6..0000000 --- a/lib/payment-providers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ImageSourcePropType } from "react-native"; - -const PROVIDER_LOGOS: Record = { - telebirr: require("@/assets/telebirr.png"), - cbe: require("@/assets/cbe.png"), - dashen: require("@/assets/dashen.png"), -}; - -export function getProviderLogo(paymentMethod: string): ImageSourcePropType | null { - return PROVIDER_LOGOS[paymentMethod.toLowerCase()] ?? null; -} - -export function isCash(paymentMethod: string): boolean { - return paymentMethod.toLowerCase() === "cash"; -} diff --git a/lib/routes.ts b/lib/routes.ts index ea9b52f..9989f76 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -87,6 +87,17 @@ export const routes = defineRoutes({ guards: ["auth"], meta: { requiresAuth: true }, }, + "proforma-requests/create": { + path: "/proforma-requests/create", + guards: ["auth"], + meta: { requiresAuth: true, title: "Create Proforma Request" }, + }, + "proforma-requests/[id]": { + path: "/proforma-requests/:id", + params: { id: "string" }, + guards: ["auth"], + meta: { requiresAuth: true, title: "Proforma Request" }, + }, "payments/[id]": { path: "/payments/:id", params: { id: "string" }, @@ -104,11 +115,23 @@ export const routes = defineRoutes({ guards: ["auth"], meta: { requiresAuth: true }, }, + "payment-requests/edit": { + path: "/payment-requests/edit", + params: { id: "string" }, + guards: ["auth"], + meta: { requiresAuth: true }, + }, "customers/create": { path: "/customers/create", guards: ["auth"], meta: { requiresAuth: true }, }, + "customers/edit": { + path: "/customers/edit", + params: { id: "string" }, + guards: ["auth"], + meta: { requiresAuth: true }, + }, "customers/[id]": { path: "/customers/:id", params: { id: "string" }, @@ -208,11 +231,6 @@ export const routes = defineRoutes({ guards: ["auth"], meta: { requiresAuth: true, title: "Verification Result" }, }, - "add-receipt": { - path: "/add-receipt", - guards: ["auth"], - meta: { requiresAuth: true, title: "Add Receipt" }, - }, company: { path: "/company", guards: ["auth"], @@ -231,18 +249,18 @@ export const routes = defineRoutes({ "team/index": { path: "/team", guards: ["auth"], - meta: { requiresAuth: true, title: "Workers" }, + meta: { requiresAuth: true, title: "Team" }, }, "team/[id]/details": { path: "/team/:id/details", params: { id: "string" }, guards: ["auth"], - meta: { requiresAuth: true, title: "Worker Details" }, + meta: { requiresAuth: true, title: "Team Member Details" }, }, - "user/create": { - path: "/user/create", + "team/create": { + path: "/team/create", guards: ["auth"], - meta: { requiresAuth: true, title: "Add User" }, + meta: { requiresAuth: true, title: "Add Team Member" }, }, "declarations/index": { path: "/declarations/index", @@ -254,6 +272,11 @@ export const routes = defineRoutes({ guards: ["auth"], meta: { requiresAuth: true, title: "Create Declaration" }, }, + "declarations/scan": { + path: "/declarations/scan", + guards: ["auth"], + meta: { requiresAuth: true, title: "Scan Declaration" }, + }, "declarations/edit": { path: "/declarations/edit", params: { id: "string" }, diff --git a/lib/scan-cache.ts b/lib/scan-cache.ts index af59d1a..216358e 100644 --- a/lib/scan-cache.ts +++ b/lib/scan-cache.ts @@ -1,10 +1,18 @@ -let _scanData: any = null; +export type ScanType = "invoice" | "payment" | "declaration"; -export function setScanData(data: any) { - _scanData = data; +export interface ScanPayload { + type: ScanType; + id?: string; + data: any; } -export function getScanData() { +let _scanData: ScanPayload | null = null; + +export function setScanData(payload: ScanPayload) { + _scanData = payload; +} + +export function getScanData(): ScanPayload | null { const data = _scanData; _scanData = null; return data; diff --git a/package-lock.json b/package-lock.json index 158fee7..2a6ca9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "clsx": "^2.1.1", "expo": "~52.0.35", "expo-camera": "~16.0.18", + "expo-clipboard": "^56.0.4", "expo-constants": "~17.0.7", "expo-document-picker": "~13.0.3", "expo-image-picker": "~16.0.3", @@ -39,6 +40,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.7", + "react-native-bcrypt": "^2.4.0", "react-native-gesture-handler": "~2.20.2", "react-native-get-sms-android": "^2.1.0", "react-native-reanimated": "~3.16.1", @@ -47,6 +49,7 @@ "react-native-svg": "15.8.0", "react-native-timer-picker": "^2.6.3", "react-native-web": "~0.19.13", + "react-native-webview": "^13.12.5", "tailwind-merge": "^3.0.1", "tailwindcss-animate": "^1.0.7", "zustand": "^5.0.11" @@ -97,9 +100,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -159,13 +162,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -175,25 +178,25 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -212,17 +215,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "engines": { @@ -242,12 +245,12 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, @@ -268,9 +271,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", @@ -296,49 +299,49 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -348,35 +351,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -386,14 +389,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -403,54 +406,54 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -556,12 +559,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -853,12 +856,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1024,12 +1027,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1039,14 +1042,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1056,14 +1059,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1073,12 +1076,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1088,13 +1091,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1104,17 +1107,17 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1124,13 +1127,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1140,13 +1143,13 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1156,12 +1159,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1187,13 +1190,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1203,14 +1206,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1220,12 +1223,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1235,12 +1238,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1250,13 +1253,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1266,13 +1269,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1282,12 +1285,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1297,12 +1300,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1312,16 +1315,16 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1331,12 +1334,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1346,13 +1349,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1362,12 +1365,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1377,13 +1380,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1393,14 +1396,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1505,12 +1508,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1549,12 +1552,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1564,13 +1567,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", + "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1580,12 +1583,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1595,12 +1598,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1629,13 +1632,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1729,26 +1732,26 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -1757,17 +1760,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1808,12 +1811,12 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -1822,13 +1825,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -7588,6 +7591,17 @@ } } }, + "node_modules/expo-clipboard": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-56.0.4.tgz", + "integrity": "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "17.0.8", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz", @@ -12410,6 +12424,12 @@ } } }, + "node_modules/react-native-bcrypt": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-native-bcrypt/-/react-native-bcrypt-2.4.0.tgz", + "integrity": "sha512-nC8SR/YCCLIluxd1YEkUX2/xjKELLkO0pgIZc1JGUw9o+wnsfpujm0J8NSPmPEqFUe1n2EkysHnAmuKInDusoQ==", + "license": "MIT" + }, "node_modules/react-native-css-interop": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.2.tgz", @@ -12603,6 +12623,20 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/react-native-webview": { + "version": "13.12.5", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.12.5.tgz", + "integrity": "sha512-INOKPom4dFyzkbxbkuQNfeRG9/iYnyRDzrDkJeyvSWgJAW2IDdJkWFJBS2v0RxIL4gqLgHkiIZDOfiLaNnw83Q==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/@react-native/normalize-colors": { "version": "0.76.7", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.76.7.tgz", diff --git a/package.json b/package.json index 7bfb694..6cdebd2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "clsx": "^2.1.1", "expo": "~52.0.35", "expo-camera": "~16.0.18", + "expo-clipboard": "^56.0.4", "expo-constants": "~17.0.7", "expo-document-picker": "~13.0.3", "expo-image-picker": "~16.0.3", @@ -40,6 +41,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.7", + "react-native-bcrypt": "^2.4.0", "react-native-gesture-handler": "~2.20.2", "react-native-get-sms-android": "^2.1.0", "react-native-reanimated": "~3.16.1", @@ -48,6 +50,7 @@ "react-native-svg": "15.8.0", "react-native-timer-picker": "^2.6.3", "react-native-web": "~0.19.13", + "react-native-webview": "^13.12.5", "tailwind-merge": "^3.0.1", "tailwindcss-animate": "^1.0.7", "zustand": "^5.0.11"