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 */}
+
+ setShowCreateSheet(false)}
+ title="Create Invoice"
+ onSelectScan={() => {
+ setShowCreateSheet(false);
+ nav.go("scan");
+ }}
+ onSelectManual={() => {
+ setShowCreateSheet(false);
+ nav.go("invoices/create");
+ }}
+ />
);
}
diff --git a/app/(tabs)/payments.tsx b/app/(tabs)/payments.tsx
index 5992657..0f6f924 100644
--- a/app/(tabs)/payments.tsx
+++ b/app/(tabs)/payments.tsx
@@ -21,18 +21,17 @@ import {
AlertTriangle,
Plus,
Search,
- Banknote,
FileText,
Clock,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { EmptyState } from "@/components/EmptyState";
+import { CreateMethodSheet } from "@/components/CreateMethodSheet";
import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store";
import { useColorScheme } from "nativewind";
import { getPlaceholderColor } from "@/lib/colors";
-import { getProviderLogo, isCash } from "@/lib/payment-providers";
type Tab = "payment" | "request";
@@ -43,6 +42,8 @@ interface Payment {
currency: string;
paymentDate: string;
paymentMethod: string;
+ financialInstitution?: string;
+ financialInstitutionLogoUrl?: string;
isFlagged: boolean;
senderName?: string;
receiverName?: string;
@@ -102,6 +103,7 @@ export default function PaymentsScreen() {
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [search, setSearch] = useState("");
+ const [showCreateSheet, setShowCreateSheet] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
// Request state
@@ -232,8 +234,7 @@ export default function PaymentsScreen() {
const renderPaymentItem = (pay: Payment) => {
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 (
@@ -243,24 +244,22 @@ export default function PaymentsScreen() {
>
- {logo ? (
-
-
+ {logoUrl ? (
+
+
) : (
{hasFlag ? (
- ) : cash ? (
-
) : (
)}
@@ -389,11 +388,13 @@ export default function PaymentsScreen() {
{/* 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()}
+ 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 */}
- {sending ? (
-
- ) : (
- <>
-
-
- Send Email
-
- >
- )}
-
- {!data.customerEmail && (
-
- No customer email on file
+
+
+ Send
- )}
+
+ {
+ const reqId = Array.isArray(id) ? id[0] : id;
+ nav.go("payment-requests/edit", { id: reqId });
+ }}
+ >
+
+
+ Edit
+
+
+
+ {/* 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"}
+
+
+
+
+
+ {sending ? (
+
+ ) : (
+ <>
+
+
+ Send {sendChannel === "EMAIL" ? "Email" : "SMS"}
+
+ >
+ )}
+
+
+
+
+
);
}
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 && (
+
+ {matching ? (
+
+ ) : (
+ <>
+
+
+ Link Invoice
+
+ >
+ )}
+
+ )}
+
+ )}
)}
- {/* Actions */}
-
-
- {payment.receiptPath && (
-
- Linking.openURL(
- `${BASE_URL}${payment.receiptPath.replace(/^\//, "")}`,
- )
- }
- >
-
-
- View Receipt
-
-
- )}
-
+ {payment.receiptPath && (
+
+ Linking.openURL(
+ `${BASE_URL}${payment.receiptPath.replace(/^\//, "")}`,
+ )
+ }
+ className="h-12 rounded-[8px] border border-border items-center justify-center flex-row gap-2 bg-card"
>
- {scanningSms ? (
-
- ) : (
- <>
-
-
- Scan SMS
-
- >
- )}
-
-
-
- {!payment.invoiceId && (
-
- {matching ? (
-
- ) : (
- <>
-
-
- Link Invoice
-
- >
- )}
-
- )}
-
-
-
- {deleting ? (
-
- ) : (
- <>
-
-
- Delete Payment
-
- >
- )}
-
+
+
+ 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}
+
-
-
-
-
-
-
- nav.go("proforma/create")}
- >
-
-
- Create New Proforma
-
-
-
- >
- }
- ListFooterComponent={
- loadingMore ? (
-
- ) : null
- }
- ListEmptyComponent={
- !loading ? (
- {
+ const isCloseToBottom =
+ nativeEvent.layoutMeasurement.height +
+ nativeEvent.contentOffset.y >=
+ nativeEvent.contentSize.height - 20;
+ if (isCloseToBottom) loadMore();
+ }}
+ scrollEventThrottle={400}
+ >
+ setSearchOpen(true)}
+ />
+
+
+
+
- ) : (
-
-
+
+
+
+ tab === "proforma"
+ ? nav.go("proforma/create")
+ : nav.go("proforma-requests/create")
+ }
+ >
+
+
+ {tab === "proforma" ? "Create New Proforma" : "Create Request"}
+
+
+
+ {/* 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 (
-
+
nav.go("user/create")}
+ onPress={() => nav.go("team/create")}
>
- Add Worker
+ Add Team Member
- 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}
+
+
+
+
+
+
+ {cancelText}
+
+
+
+
+ {confirmText}
+
+
+
+
+
+
+ );
+}
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"