From db5ac60987f544a3d9bfd7cc88ceec7eabbd1827 Mon Sep 17 00:00:00 2001 From: elnatansamuel25 Date: Wed, 13 May 2026 15:21:17 +0300 Subject: [PATCH] expo eject before --- app/(tabs)/news.tsx | 128 ++++--- app/(tabs)/payments.tsx | 134 +++---- app/(tabs)/scan.tsx | 62 +++- app/_layout.tsx | 263 ++++++++------ app/company.tsx | 57 +-- app/invoices/[id].tsx | 535 ++++++++++++++++++--------- app/invoices/edit.tsx | 659 ++++++++++++++++++++++++++++++++++ app/login.tsx | 204 ++++++++--- app/otp.tsx | 185 ++++++++++ app/payments/[id].tsx | 537 ++++++++++++++++++++++----- app/proforma/[id].tsx | 558 ++++++++++++++++------------ app/proforma/edit.tsx | 127 ++++--- components/StandardHeader.tsx | 46 ++- lib/api-middlewares.ts | 244 +++++++++---- lib/api.ts | 11 + lib/auth-guards.ts | 4 +- lib/auth-store.ts | 33 +- lib/icons.tsx | 3 + lib/routes.ts | 13 + lib/toast-store.ts | 6 + locales/en.json | 418 --------------------- localey.config.json | 4 - src/i18n/config.ts | 19 - src/i18n/hooks.ts | 10 - src/i18n/store.ts | 15 - 25 files changed, 2871 insertions(+), 1404 deletions(-) create mode 100644 app/invoices/edit.tsx create mode 100644 app/otp.tsx delete mode 100644 locales/en.json delete mode 100644 localey.config.json delete mode 100644 src/i18n/config.ts delete mode 100644 src/i18n/hooks.ts delete mode 100644 src/i18n/store.ts diff --git a/app/(tabs)/news.tsx b/app/(tabs)/news.tsx index d48dac7..6b5dffe 100644 --- a/app/(tabs)/news.tsx +++ b/app/(tabs)/news.tsx @@ -22,7 +22,7 @@ import { ShadowWrapper } from "@/components/ShadowWrapper"; import { useAuthStore } from "@/lib/auth-store"; const { width } = Dimensions.get("window"); -const LATEST_CARD_WIDTH = width * 0.8; +const LATEST_CARD_WIDTH = width * 0.6; interface NewsItem { id: string; @@ -36,7 +36,9 @@ interface NewsItem { export default function NewsScreen() { const nav = useSirouRouter(); - const permissions = useAuthStore((s: { permissions: string[] }) => s.permissions); + const permissions = useAuthStore( + (s: { permissions: string[] }) => s.permissions, + ); // Safe accessor to handle initialization race conditions const getNewsApi = () => { @@ -136,88 +138,84 @@ export default function NewsScreen() { const LatestItem = ({ item }: { item: NewsItem }) => ( - - - - - - - - {item.category} - - - - {new Date(item.publishedAt).toLocaleDateString()} + + + + + + + {item.category} - - {item.title} + + {new Date(item.publishedAt).toLocaleDateString()} + + {item.title} + + - - - Tap to read more - - - - + + + Tap to read more + + + - - + + ); const NewsItem = ({ item }: { item: NewsItem }) => ( - - - - - - - {item.category} - - - - {item.title} - + + + + - {item.content} + {item.category} + + + {item.title} + + + {item.content} + - - - - - {new Date(item.publishedAt).toLocaleDateString()} - - + + + + + {new Date(item.publishedAt).toLocaleDateString()} + - - + + ); diff --git a/app/(tabs)/payments.tsx b/app/(tabs)/payments.tsx index 1526c3a..1fb26f7 100644 --- a/app/(tabs)/payments.tsx +++ b/app/(tabs)/payments.tsx @@ -64,7 +64,10 @@ export default function PaymentsScreen() { const [loadingMore, setLoadingMore] = useState(false); // Check permissions - const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]); + const canCreatePayments = hasPermission( + permissions, + PERMISSION_MAP["payments:create"], + ); const fetchPayments = useCallback( async (pageNum: number, isRefresh = false) => { @@ -124,6 +127,10 @@ export default function PaymentsScreen() { reconciled: payments.filter((p) => p.invoiceId && !p.isFlagged), }; + useEffect(() => { + console.log(payments); + }, [payments]); + const renderPaymentItem = ( pay: Payment, type: "reconciled" | "pending" | "flagged", @@ -208,7 +215,7 @@ export default function PaymentsScreen() { { const isCloseToBottom = @@ -231,70 +238,71 @@ export default function PaymentsScreen() { - - {/* Flagged Section */} - {categorized.flagged.length > 0 && ( - <> - - - Flagged Payments - - - - {categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))} - - - )} - - {/* Pending Section */} - - - Pending Match - - - - {categorized.pending.length > 0 ? ( - categorized.pending.map((p) => renderPaymentItem(p, "pending")) - ) : ( - - - + {/* Flagged Section */} + {categorized.flagged.length > 0 && ( + <> + + + Flagged Payments + + + + {categorized.flagged.map((p) => + renderPaymentItem(p, "flagged"), + )} + + )} - - {/* Reconciled Section */} - - - Reconciled - - - - {categorized.reconciled.length > 0 ? ( - categorized.reconciled.map((p) => - renderPaymentItem(p, "reconciled"), - ) - ) : ( - - - - )} - - - {loadingMore && ( - - + {/* Pending Section */} + + + Pending Match + - )} + + {categorized.pending.length > 0 ? ( + categorized.pending.map((p) => renderPaymentItem(p, "pending")) + ) : ( + + + + )} + + + {/* Reconciled Section */} + + + Reconciled + + + + {categorized.reconciled.length > 0 ? ( + categorized.reconciled.map((p) => + renderPaymentItem(p, "reconciled"), + ) + ) : ( + + + + )} + + + {loadingMore && ( + + + + )} diff --git a/app/(tabs)/scan.tsx b/app/(tabs)/scan.tsx index 23d576d..1c7d6cb 100644 --- a/app/(tabs)/scan.tsx +++ b/app/(tabs)/scan.tsx @@ -14,7 +14,7 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; import { useNavigation } from "expo-router"; -import { BASE_URL } from "@/lib/api"; +import { api, BASE_URL } from "@/lib/api"; import { useAuthStore } from "@/lib/auth-store"; import { toast } from "@/lib/toast-store"; @@ -94,13 +94,63 @@ export default function ScanScreen() { throw new Error(err.message || "Scan failed."); } - const data = await response.json(); - console.log("[Scan] Extracted invoice data:", data); + const scanResult = await response.json(); + console.log("[Scan] Extracted invoice data:", scanResult); - toast.success("Scan Complete!", "Invoice data extracted successfully."); + if (!scanResult.success) { + throw new Error(scanResult.message || "Extraction failed."); + } - // Navigate to create invoice screen - nav.go("proforma/create"); + toast.success("Scan Complete!", "Drafting your invoice now..."); + + // 4. Map OCR data to Invoice structure + const ocr = scanResult.data || {}; + const invoicePayload = { + invoiceNumber: ocr.invoiceNumber || `INV-${Date.now()}`, + customerName: ocr.customerName?.trim() || "Unknown Customer", + customerEmail: ocr.customerEmail || "", + customerPhone: ocr.customerPhone || "", + amount: ocr.totalAmount || ocr.subtotalAmount || 0, + currency: ocr.currency || "ETB", + type: "SALES", + status: "DRAFT", + issueDate: ocr.issueDate + ? new Date(ocr.issueDate).toISOString() + : new Date().toISOString(), + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + description: `Scanned Invoice #${ocr.invoiceNumber || ""}`, + notes: scanResult.message || "Automatically generated from scan.", + taxAmount: ocr.taxAmount || 0, + discountAmount: 0, + isScanned: true, + scannedData: { + sellerTIN: ocr.sellerTIN || "", + items: ocr.items || [], + }, + items: (ocr.items || []).map((item: any) => ({ + description: + typeof item === "string" ? item : item.description || "Item", + quantity: item.quantity || 1, + unitPrice: item.unitPrice || item.total || 0, + total: item.total || 0, + })), + }; + + // 5. Create the invoice in the backend + const createResponse = await api.invoices.create({ + body: invoicePayload, + }); + + console.log("[Scan] Invoice created successfully:", createResponse); + + toast.success("Success!", "Invoice created and ready for review."); + + // 6. Navigate to the new invoice detail page + if (createResponse?.id) { + nav.go(`invoices/${createResponse.id}`); + } else { + nav.go("(tabs)/payments"); + } } catch (err: any) { console.error("[Scan] Error:", err); toast.error( diff --git a/app/_layout.tsx b/app/_layout.tsx index 20d8d6d..4983e63 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -6,18 +6,28 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { Toast } from "@/components/Toast"; import "@/global.css"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import { View, ActivityIndicator, InteractionManager } from "react-native"; +import { + View, + ActivityIndicator, + InteractionManager, + AppState, +} from "react-native"; import { useRestoreTheme, NAV_THEME } from "@/lib/theme"; import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native"; -import { NavigationContainer, NavigationIndependentTree, ThemeProvider } from "@react-navigation/native"; +import { refreshTokens } from "@/lib/api-middlewares"; +import { + NavigationContainer, + NavigationIndependentTree, + ThemeProvider, +} from "@react-navigation/native"; import { routes } from "@/lib/routes"; import { authGuard, guestGuard } from "@/lib/auth-guards"; import { useAuthStore } from "@/lib/auth-store"; -import { useFonts } from 'expo-font'; +import { useFonts } from "expo-font"; import { api } from "@/lib/api"; -import { useColorScheme } from 'react-native'; +import { useColorScheme } from "nativewind"; -import { useSegments } from "expo-router"; +import { useSegments, useLocalSearchParams, useRouter } from "expo-router"; function BackupGuard() { const segments = useSegments(); @@ -30,18 +40,57 @@ function BackupGuard() { useEffect(() => { if (!isMounted) return; - - // Intentionally disabled: redirecting here can happen before the root layout - // navigator is ready and cause "Attempted to navigate before mounting". - // Sirou guards handle redirects. }, [segments, isAuthed, isMounted]); return null; } +/** + * SessionHeartbeat: Proactively refreshes tokens every 5 minutes and upon app foregrounding. + */ +function SessionHeartbeat() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + if (!isAuthenticated) return; + + // Refresh every 5 minutes + const INTERVAL_MS = 5 * 60 * 1000; + + const performRefresh = async (reason: string) => { + try { + console.log(`[SessionHeartbeat] Refresh triggered by: ${reason}`); + await refreshTokens(); + } catch (err) { + console.warn(`[SessionHeartbeat] Refresh failed (${reason}):`, err); + } + }; + + // 1. Initial/Interval Refresh + performRefresh("Mount"); // Refresh immediately on mount + const interval = setInterval(() => performRefresh("Interval"), INTERVAL_MS); + + // 2. Foreground Refresh (AppState listener) + const subscription = AppState.addEventListener("change", (nextAppState) => { + if (nextAppState === "active") { + performRefresh("Foreground"); + } + }); + + return () => { + clearInterval(interval); + subscription.remove(); + }; + }, [isAuthenticated]); + + return null; +} + function SirouBridge() { const sirou = useSirouRouter(); + const router = useRouter(); const segments = useSegments(); + const params = useLocalSearchParams(); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const [isMounted, setIsMounted] = useState(false); @@ -53,19 +102,20 @@ function SirouBridge() { if (!isMounted) return; const checkAuth = async () => { - // Create EXACT name from segments: (tabs), index => (tabs)/index - // Use "root" if segments are empty (initial layout) const routeName = segments.length > 0 ? segments.join("/") : "root"; - console.log(`[SirouBridge] checking route: "${routeName}"`); + console.log( + `[SirouBridge] checking route: "${routeName}" with params:`, + params, + ); try { - const result = await (sirou as any).checkGuards(routeName); + const result = await (sirou as any).checkGuards(routeName, params); if (!result.allowed && result.redirect) { console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`); - // Use Sirou navigation safely InteractionManager.runAfterInteractions(() => { - sirou.go(result.redirect); + // Use Expo Router directly — sirou.go fires NAVIGATE which Expo can't resolve + router.replace(result.redirect as any); }); } } catch (e: any) { @@ -77,26 +127,26 @@ function SirouBridge() { }; checkAuth(); - }, [segments, sirou, isMounted, isAuthenticated]); + }, [segments, params, sirou, router, isMounted, isAuthenticated]); return null; } export default function RootLayout() { - const colorScheme = useColorScheme(); + const { colorScheme } = useColorScheme(); useRestoreTheme(); const [isMounted, setIsMounted] = useState(false); const [hasHydrated, setHasHydrated] = useState(false); const [fontsLoaded] = useFonts({ - 'DMSans-Regular': require('../assets/fonts/DMSans-Regular.ttf'), - 'DMSans-Bold': require('../assets/fonts/DMSans-Bold.ttf'), - 'DMSans-Medium': require('../assets/fonts/DMSans-Medium.ttf'), - 'DMSans-SemiBold': require('../assets/fonts/DMSans-SemiBold.ttf'), - 'DMSans-Light': require('../assets/fonts/DMSans-Light.ttf'), - 'DMSans-ExtraLight': require('../assets/fonts/DMSans-ExtraLight.ttf'), - 'DMSans-Thin': require('../assets/fonts/DMSans-Thin.ttf'), - 'DMSans-Black': require('../assets/fonts/DMSans-Black.ttf'), - 'DMSans-ExtraBold': require('../assets/fonts/DMSans-ExtraBold.ttf'), + "DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"), + "DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"), + "DMSans-Medium": require("../assets/fonts/DMSans-Medium.ttf"), + "DMSans-SemiBold": require("../assets/fonts/DMSans-SemiBold.ttf"), + "DMSans-Light": require("../assets/fonts/DMSans-Light.ttf"), + "DMSans-ExtraLight": require("../assets/fonts/DMSans-ExtraLight.ttf"), + "DMSans-Thin": require("../assets/fonts/DMSans-Thin.ttf"), + "DMSans-Black": require("../assets/fonts/DMSans-Black.ttf"), + "DMSans-ExtraBold": require("../assets/fonts/DMSans-ExtraBold.ttf"), }); useEffect(() => { @@ -134,85 +184,90 @@ export default function RootLayout() { return ( - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); diff --git a/app/company.tsx b/app/company.tsx index 13fd82d..2a3b4ea 100644 --- a/app/company.tsx +++ b/app/company.tsx @@ -6,8 +6,9 @@ import { TextInput, ActivityIndicator, RefreshControl, - useColorScheme, + Image, } from "react-native"; +import { useColorScheme } from "nativewind"; import { Text } from "@/components/ui/text"; import { Card, CardContent } from "@/components/ui/card"; import { ScreenWrapper } from "@/components/ScreenWrapper"; @@ -19,6 +20,7 @@ import { Stack } from "expo-router"; import { useAuthStore } from "@/lib/auth-store"; import { api } from "@/lib/api"; import { getPlaceholderColor } from "@/lib/colors"; +import { EmptyState } from "@/components/EmptyState"; import { UserPlus, Search, @@ -31,7 +33,7 @@ import { export default function CompanyScreen() { const nav = useSirouRouter(); - const colorScheme = useColorScheme(); + const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const [workers, setWorkers] = useState([]); @@ -42,7 +44,9 @@ export default function CompanyScreen() { const fetchWorkers = async () => { try { const response = await api.users.getAll(); - setWorkers(response.data || []); + // Handle both direct array and { data: [] } response formats + const data = Array.isArray(response) ? response : response.data || []; + setWorkers(data); } catch (error) { console.error("[CompanyScreen] Error fetching workers:", error); } finally { @@ -90,7 +94,7 @@ export default function CompanyScreen() { {/* Worker List Header */} - Workers ({filteredWorkers.length}) + Workers ({filteredWorkers?.length || 0}) @@ -115,21 +119,33 @@ export default function CompanyScreen() { - - - {worker.firstName?.[0]} - {worker.lastName?.[0]} - - + {worker.avatar ? ( + + ) : ( + + + {worker.firstName?.[0]} + {worker.lastName?.[0]} + + + )} {worker.firstName} {worker.lastName} - - {worker.role || "WORKER"} + + {worker.role?.replace("_", " ") || "WORKER"} + {worker.email && ( + + {worker.email} + + )} @@ -142,16 +158,13 @@ export default function CompanyScreen() { )) ) : ( - - - - No workers found - - + )} )} diff --git a/app/invoices/[id].tsx b/app/invoices/[id].tsx index a7fee7f..e6a5ef6 100644 --- a/app/invoices/[id].tsx +++ b/app/invoices/[id].tsx @@ -1,5 +1,13 @@ import React, { useState, useEffect } from "react"; -import { View, ScrollView, Pressable, ActivityIndicator } from "react-native"; +import { + View, + ScrollView, + ActivityIndicator, + Alert, + Linking, + useColorScheme, + Pressable, +} from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; import { Stack, useLocalSearchParams } from "expo-router"; @@ -11,18 +19,28 @@ import { Calendar, Share2, Download, - ArrowLeft, + Trash2, + Package, + Clock, ExternalLink, + ChevronRight, + User, + CreditCard, + Hash, + AlertCircle, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper"; import { StandardHeader } from "@/components/StandardHeader"; -import { api } from "@/lib/api"; +import { api, BASE_URL } from "@/lib/api"; import { toast } from "@/lib/toast-store"; +import { useAuthStore } from "@/lib/auth-store"; export default function InvoiceDetailScreen() { const nav = useSirouRouter(); const { id } = useLocalSearchParams(); + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; const [loading, setLoading] = useState(true); const [invoice, setInvoice] = useState(null); @@ -34,7 +52,11 @@ export default function InvoiceDetailScreen() { const fetchInvoice = async () => { try { setLoading(true); - const data = await api.invoices.getById({ params: { id: id as string } }); + // Ensure id is a string if useLocalSearchParams returns an array + const invoiceId = Array.isArray(id) ? id[0] : id; + if (!invoiceId) throw new Error("No ID provided"); + + const data = await api.invoices.getById({ params: { id: invoiceId } }); setInvoice(data); } catch (error: any) { console.error("[InvoiceDetail] Error:", error); @@ -44,11 +66,51 @@ export default function InvoiceDetailScreen() { } }; + const handleGetPdf = async () => { + try { + const { token } = useAuthStore.getState(); + const pdfUrl = `${BASE_URL}invoices/${id}/pdf?token=${token}`; + await Linking.openURL(pdfUrl); + } catch (error) { + console.error("[InvoiceDetail] PDF Error:", error); + toast.error("Error", "Failed to open PDF"); + } + }; + + const handleDelete = async () => { + Alert.alert( + "Delete Invoice", + "Are you sure you want to delete this invoice? This action cannot be undone.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + setLoading(true); + const invoiceId = Array.isArray(id) ? id[0] : id; + await api.invoices.delete({ + params: { id: invoiceId as string }, + }); + toast.success("Success", "Invoice deleted successfully"); + nav.back(); + } catch (error) { + console.error("[InvoiceDetail] Delete Error:", error); + toast.error("Error", "Failed to delete invoice"); + setLoading(false); + } + }, + }, + ], + ); + }; + if (loading) { return ( - + @@ -60,220 +122,343 @@ export default function InvoiceDetailScreen() { return ( - + - Invoice not found + + + Invoice Not Found + + + The requested document could not be retrieved. + ); } + // Robust data extraction with fallback for scanned invoices + const originalData = invoice.scannedData?.originalData || {}; + const items = + (invoice.items?.length > 0 ? invoice.items : originalData.items) || []; + + const taxAmountValue = Number( + typeof invoice.taxAmount === "object" + ? invoice.taxAmount?.value + : invoice.taxAmount || originalData.taxAmount || 0, + ); + + const discountValue = Number( + typeof invoice.discountAmount === "object" + ? invoice.discountAmount?.value + : invoice.discountAmount || originalData.discountAmount || 0, + ); + + let amountValue = Number( + typeof invoice.amount === "object" ? invoice.amount.value : invoice.amount, + ); + // Intelligence: If amount looks like it's just the tax, and we have items, use items total + if (items.length > 0) { + const itemsTotal = items.reduce( + (acc: number, item: any) => + acc + (Number(item.total?.value || item.total) || 0), + 0, + ); + if ( + itemsTotal > 0 && + (amountValue === taxAmountValue || amountValue < itemsTotal) + ) { + amountValue = itemsTotal + taxAmountValue - discountValue; + } + } + + const subtotalValue = amountValue - taxAmountValue + discountValue; + const statusColors = { + PAID: { + bg: "bg-emerald-500/10", + text: "text-emerald-500", + dot: "bg-emerald-500", + }, + PENDING: { + bg: "bg-amber-500/10", + text: "text-amber-500", + dot: "bg-amber-500", + }, + DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" }, + DEFAULT: { + bg: "bg-slate-500/10", + text: "text-slate-500", + dot: "bg-slate-500", + }, + }; + const status = (invoice.status || "PENDING").toUpperCase(); + const colors = + statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT; + return ( - + nav.go("invoices/edit", { id: invoice.id })} + /> - {/* Status Hero Card */} - - - - - - - - - {invoice.status || "Pending"} - - - - - - Total Amount + {/* Modern Hero Area */} + + + + + {status} - - ${Number(invoice.amount).toLocaleString()} + + + + Total Payable Amount + + + + {Number(amountValue).toLocaleString(undefined, { + minimumFractionDigits: 2, + })} + + + {invoice.currency || "ETB"} - - - - - - Due {new Date(invoice.dueDate).toLocaleDateString()} - - - - - #{invoice.invoiceNumber || id} - - - - {/* Recipient & Category — inline info strip */} - - - - - - Recipient - - - {invoice.customerName || "—"} - - - - - - - - Category - - - General - - - - - - - {/* Items / Billing Summary */} - - - + {/* Quick Stats Grid */} + + + - Billing Summary + Issue Date + + + {new Date( + invoice.issueDate || invoice.createdAt, + ).toLocaleDateString()} + + + + Due Date + + + {new Date(invoice.dueDate).toLocaleDateString()} + + + + - - + {/* Client Box */} + + + + + + + - Subtotal + Billed To + + + {invoice.customerName?.replace("Customer Name: ", "") || + "Walking Client"} - - $ - {( - Number(invoice.amount) - (Number(invoice.taxAmount) || 0) - ).toLocaleString()} - - - {Number(invoice.taxAmount) > 0 && ( - - - - Tax + + {invoice.customerEmail && ( + + + + {invoice.customerEmail} - - + ${Number(invoice.taxAmount).toLocaleString()} + )} + + + + #{invoice.id.split("-")[0]} + + + + + + + {/* Detailed Items Table */} + + + Order Summary + + + {items.map((item: any, idx: number) => ( + + + + {item.description} + + + {Number( + item.total?.value || item.total || 0, + ).toLocaleString()} + + + + {item.quantity} units x{" "} + {Number( + item.unitPrice?.value || item.unitPrice || 0, + ).toLocaleString()}{" "} + {invoice.currency} + + + ))} + {items.length === 0 && ( + + + No line items specified + + )} + + + + {/* Billing Breakdown */} + + + + + Subtotal + + + {subtotalValue.toLocaleString()} {invoice.currency} + + + + {taxAmountValue > 0 && ( + + + Tax (extracted) + + + +{taxAmountValue.toLocaleString()} {invoice.currency} )} - - - Total Balance - - - ${Number(invoice.amount).toLocaleString()} - - - - + {discountValue > 0 && ( + + + Discount + + + -{discountValue.toLocaleString()} {invoice.currency} + + + )} - {/* Notes Section (New) */} - {invoice.notes && ( - - - - Additional Notes - - - {invoice.notes} + + + + Grand Total + + + Verified from data + + + + {amountValue.toLocaleString()} {invoice.currency} - )} - - {/* Timeline Section (New) */} - - - - Created - - - {new Date(invoice.createdAt).toLocaleString()} - - - - - Last Updated - - - {new Date(invoice.updatedAt).toLocaleString()} - - - {/* Actions */} - - - - + - + + + diff --git a/app/invoices/edit.tsx b/app/invoices/edit.tsx new file mode 100644 index 0000000..5e73e63 --- /dev/null +++ b/app/invoices/edit.tsx @@ -0,0 +1,659 @@ +import React, { useState, useEffect } from "react"; +import { + View, + ScrollView, + Pressable, + TextInput, + StyleSheet, + ActivityIndicator, +} from "react-native"; +import { useColorScheme } from "nativewind"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { + ArrowLeft, + ArrowRight, + Trash2, + Send, + Plus, + Calendar, + ChevronDown, + CalendarSearch, + FileText, +} from "@/lib/icons"; +import { ScreenWrapper } from "@/components/ScreenWrapper"; +import { useSirouRouter } from "@sirou/react-native"; +import { AppRoutes } from "@/lib/routes"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { useRouter } from "expo-router"; +import { ShadowWrapper } from "@/components/ShadowWrapper"; +import { api } from "@/lib/api"; +import { toast } from "@/lib/toast-store"; +import { PickerModal, SelectOption } from "@/components/PickerModal"; +import { CalendarGrid } from "@/components/CalendarGrid"; +import { StandardHeader } from "@/components/StandardHeader"; + +type Item = { id: number; description: string; qty: string; price: string }; + +const S = StyleSheet.create({ + input: { + height: 44, + paddingHorizontal: 12, + fontSize: 12, + fontWeight: "500", + borderRadius: 6, + borderWidth: 1, + }, + inputCenter: { + height: 44, + paddingHorizontal: 12, + fontSize: 14, + fontWeight: "500", + borderRadius: 6, + borderWidth: 1, + textAlign: "center", + }, +}); + +function useInputColors() { + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + return { + bg: isDark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)", + border: isDark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)", + text: isDark ? "#f1f5f9" : "#0f172a", + placeholder: "rgba(100,116,139,0.45)", + }; +} + +function Field({ + label, + value, + onChangeText, + placeholder, + numeric = false, + center = false, + flex, +}: { + label: string; + value: string; + onChangeText: (v: string) => void; + placeholder: string; + numeric?: boolean; + center?: boolean; + flex?: number; +}) { + const c = useInputColors(); + return ( + + + {label} + + + + ); +} + +export default function EditInvoiceScreen() { + const nav = useSirouRouter(); + const router = useRouter(); + const { id } = useLocalSearchParams(); + const isEdit = !!id; + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + + // Form fields + const [invoiceNumber, setInvoiceNumber] = useState(""); + const [customerName, setCustomerName] = useState(""); + const [customerEmail, setCustomerEmail] = useState(""); + const [customerPhone, setCustomerPhone] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [type, setType] = useState("SALES"); + const [notes, setNotes] = useState(""); + const [taxAmount, setTaxAmount] = useState(""); + const [discountAmount, setDiscountAmount] = useState(""); + + // Dates + const [issueDate, setIssueDate] = useState(new Date()); + const [dueDate, setDueDate] = useState(new Date()); + + // Items + const [items, setItems] = useState([ + { id: 1, description: "", qty: "", price: "" }, + ]); + + // Modals + const [currencyModal, setCurrencyModal] = useState(false); + const [typeModal, setTypeModal] = useState(false); + const [issueModal, setIssueModal] = useState(false); + const [dueModal, setDueModal] = useState(false); + + // Fetch existing data for edit + useEffect(() => { + if (isEdit) { + fetchInvoice(); + } + }, [id]); + + const fetchInvoice = async () => { + try { + setLoading(true); + const data = await api.invoices.getById({ params: { id: id as string } }); + + // Robust fallbacks for scanned invoices + const original = data.scannedData?.originalData || {}; + + setInvoiceNumber(data.invoiceNumber || original.invoiceNumber || ""); + + // Clean up common OCR artifacts + let name = data.customerName || original.customerName || ""; + name = name + .replace(/^Customer Name:\s*/i, "") + .replace(/^Bill To:\s*/i, ""); + setCustomerName(name); + + setCustomerEmail(data.customerEmail || original.customerEmail || ""); + setCustomerPhone(data.customerPhone || original.customerPhone || ""); + setCurrency(data.currency || original.currency || "ETB"); + setType(data.type || "SALES"); + setNotes(data.notes || ""); + + const taxVal = + typeof data.taxAmount === "object" + ? data.taxAmount?.value + : data.taxAmount || original.taxAmount || "0"; + setTaxAmount(String(taxVal)); + + const discVal = + typeof data.discountAmount === "object" + ? data.discountAmount?.value + : data.discountAmount || original.discountAmount || "0"; + setDiscountAmount(String(discVal)); + + setIssueDate( + new Date( + data.createdAt || data.issueDate || original.issueDate || Date.now(), + ), + ); + setDueDate(new Date(data.dueDate || original.dueDate || Date.now())); + + // Populate items with fallback to original scanned data + const apiItems = data.items || []; + const sourceItems = apiItems.length > 0 ? apiItems : original.items || []; + + if (sourceItems.length > 0) { + setItems( + sourceItems.map((item: any, idx: number) => ({ + id: idx + 1, + description: item.description || "", + qty: String(item.quantity || "1"), + price: String(item.unitPrice?.value || item.unitPrice || "0"), + })), + ); + } else { + setItems([{ id: 1, description: "", qty: "", price: "" }]); + } + } catch (error) { + console.error("[EditInvoice] Error:", error); + toast.error("Error", "Failed to load invoice details"); + } finally { + setLoading(false); + } + }; + + const addItem = () => { + const newId = + items.length > 0 ? Math.max(...items.map((i) => i.id)) + 1 : 1; + setItems([...items, { id: newId, description: "", qty: "", price: "" }]); + }; + + const removeItem = (id: number) => { + if (items.length > 1) { + setItems(items.filter((i) => i.id !== id)); + } + }; + + const updateItem = (id: number, field: keyof Item, value: string) => { + setItems(items.map((i) => (i.id === id ? { ...i, [field]: value } : i))); + }; + + const calculateSubtotal = () => { + return items.reduce((acc, item) => { + const qty = parseFloat(item.qty) || 0; + const price = parseFloat(item.price) || 0; + return acc + qty * price; + }, 0); + }; + + const calculateTotal = () => { + const subtotal = calculateSubtotal(); + const tax = parseFloat(taxAmount) || 0; + const discount = parseFloat(discountAmount) || 0; + return subtotal + tax - discount; + }; + + const handleSubmit = async () => { + // Validation + if (!invoiceNumber || !customerName) { + toast.error("Error", "Please fill required fields"); + return; + } + + setSubmitting(true); + try { + const payload = { + invoiceNumber, + customerName, + customerEmail, + customerPhone, + amount: calculateTotal(), + currency, + type, + issueDate: issueDate.toISOString(), + dueDate: dueDate.toISOString(), + notes, + taxAmount: parseFloat(taxAmount) || 0, + discountAmount: parseFloat(discountAmount) || 0, + items: items.map((item) => ({ + description: item.description, + quantity: parseFloat(item.qty) || 0, + unitPrice: parseFloat(item.price) || 0, + total: (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0), + })), + status: "PENDING", + }; + + if (isEdit) { + await api.invoices.update({ + params: { id: id as string }, + body: payload, + }); + toast.success("Success", "Invoice updated successfully"); + } else { + await api.invoices.create({ body: payload }); + toast.success("Success", "Invoice created successfully"); + } + + nav.back(); + } catch (error: any) { + toast.error("Error", error.message || "Failed to save invoice"); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return ( + + + + + + + + ); + } + + const currencies = ["ETB", "USD", "EUR", "GBP"]; + const invoiceTypes = ["SALES", "PURCHASE", "SERVICE"]; + + return ( + + + + + {/* Invoice Details */} + + + + + + + + Invoice Details + + + + + + + + + + + {/* Customer Details */} + + + + Customer Details + + + + + + + + + + + {/* Dates */} + + + + Dates + + + + setIssueModal(true)} + > + + + + Issue Date: {issueDate.toLocaleDateString()} + + + + + + setDueModal(true)} + > + + + + Due Date: {dueDate.toLocaleDateString()} + + + + + + + + + {/* Items */} + + + + + Items + + + + + {items.map((item) => ( + + updateItem(item.id, "description", v)} + placeholder="Item description" + /> + updateItem(item.id, "qty", v)} + placeholder="0" + numeric + center + /> + updateItem(item.id, "price", v)} + placeholder="0.00" + numeric + center + /> + removeItem(item.id)} + > + + + + ))} + + + + {/* Totals */} + + + + Totals + + + + + Subtotal + + {currency} {calculateSubtotal().toFixed(2)} + + + + + + + + + Total + + {currency} {calculateTotal().toFixed(2)} + + + + + + + {/* Configuration */} + + + + Configuration + + + + + + Currency + + setCurrencyModal(true)} + > + + {currency} + + + + + + + + Invoice Type + + setTypeModal(true)} + > + {type} + + + + + + + + + {/* Bottom Action */} + + + + + {/* Modals */} + setCurrencyModal(false)} + > + {currencies.map((curr) => ( + { + setCurrency(v); + setCurrencyModal(false); + }} + /> + ))} + + + setTypeModal(false)} + > + {invoiceTypes.map((t) => ( + { + setType(v); + setTypeModal(false); + }} + /> + ))} + + + setIssueModal(false)} + > + { + setIssueDate(new Date(dateStr)); + setIssueModal(false); + }} + selectedDate={issueDate.toISOString().substring(0, 10)} + /> + + + setDueModal(false)} + > + { + setDueDate(new Date(dateStr)); + setDueModal(false); + }} + selectedDate={dueDate.toISOString().substring(0, 10)} + /> + + + ); +} diff --git a/app/login.tsx b/app/login.tsx index 95b61ec..6a62e98 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -12,9 +12,20 @@ import { } from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; +import { useRouter } from "expo-router"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; -import { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User, Globe } from "@/lib/icons"; +import { + Mail, + Lock, + ArrowRight, + Eye, + EyeOff, + Chrome, + User, + Globe, + Phone, +} from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { useAuthStore } from "@/lib/auth-store"; import * as Linking from "expo-linking"; @@ -24,21 +35,31 @@ import { toast } from "@/lib/toast-store"; import { useLanguageStore, AppLanguage } from "@/lib/language-store"; import { getPlaceholderColor } from "@/lib/colors"; import { LanguageModal } from "@/components/LanguageModal"; -import { - GoogleSignin, - statusCodes, -} from "@react-native-google-signin/google-signin"; +// Lazy-load Google Sign-In to prevent crash when native module is missing (e.g. Expo Go) +let GoogleSignin: any = null; +let statusCodes: any = {}; +let googleAvailable = false; -GoogleSignin.configure({ - webClientId: - "1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", - iosClientId: - "1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", // Placeholder: replace with your actual iOS Client ID from Google Cloud Console - offlineAccess: true, -}); +try { + const gsi = require("@react-native-google-signin/google-signin"); + GoogleSignin = gsi.GoogleSignin; + statusCodes = gsi.statusCodes; + googleAvailable = true; + + GoogleSignin.configure({ + webClientId: + "1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", + iosClientId: + "1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", + offlineAccess: true, + }); +} catch (e) { + console.warn("[Login] Google Sign-In native module not available:", (e as any).message); +} export default function LoginScreen() { const nav = useSirouRouter(); + const router = useRouter(); const setAuth = useAuthStore((state) => state.setAuth); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; @@ -49,8 +70,32 @@ export default function LoginScreen() { const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); + const [loginMode, setLoginMode] = useState<"email" | "phone">("email"); const handleLogin = async () => { + if (loginMode === "phone") { + if (!identifier) { + toast.error("Required Field", "Please enter your phone number"); + return; + } + setLoading(true); + const fullPhone = `+251${identifier}`; + try { + const response = await api.auth.sendOtp({ body: { phone: fullPhone } }); + toast.success("Success", response.message || "OTP sent successfully"); + // Navigate to OTP screen + router.push({ + pathname: "/otp", + params: { phone: fullPhone, verificationId: response.verificationId }, + }); + } catch (err: any) { + toast.error("Error", err.message || "Failed to send OTP"); + } finally { + setLoading(false); + } + return; + } + if (!identifier || !password) { toast.error( "Required Fields", @@ -64,25 +109,19 @@ export default function LoginScreen() { const isEmail = identifier.includes("@"); const payload = isEmail ? { email: identifier, password } - : { phone: identifier, password }; + : { phone: `+251${identifier}`, password }; try { // Using the new api.auth.login which is powered by simple-api const response = await api.auth.login({ body: payload }); - - // Store user, access token, refresh token, and permissions - // // Fetch roles to get permissions - // const rolesResponse = await rbacApi.roles(); - // const userRole = response.user.role; - // const roleData = rolesResponse.find((r: any) => r.role === userRole); - // const permissions = roleData ? roleData.permissions : []; const permissions: string[] = []; - - // Store user, access token, refresh token, and permissions - setAuth(response.user, response.accessToken, response.refreshToken, permissions); + setAuth( + response.user, + response.accessToken, + response.refreshToken, + permissions, + ); toast.success("Welcome Back!", "You have successfully logged in."); - - // Explicitly navigate to home nav.go("(tabs)"); } catch (err: any) { toast.error("Login Failed", err.message || "Invalid credentials"); @@ -92,6 +131,10 @@ export default function LoginScreen() { }; const handleGoogleLogin = async () => { + if (!googleAvailable || !GoogleSignin) { + toast.error("Unavailable", "Google Sign-In requires a native build. Please use email/phone login."); + return; + } try { setLoading(true); await GoogleSignin.hasPlayServices(); @@ -115,7 +158,12 @@ export default function LoginScreen() { // const permissions = roleData ? roleData.permissions : []; const permissions: string[] = []; - setAuth(response.user, response.accessToken, response.refreshToken, permissions); + setAuth( + response.user, + response.accessToken, + response.refreshToken, + permissions, + ); toast.success("Welcome!", "Signed in with Google."); nav.go("(tabs)"); } catch (error: any) { @@ -158,7 +206,7 @@ export default function LoginScreen() { {/* Logo / Branding */} - + Login @@ -167,48 +215,96 @@ export default function LoginScreen() { + {/* Login Type Toggle */} + + { + setLoginMode("email"); + setIdentifier(""); + }} + className={`flex-1 py-2 rounded-lg items-center ${loginMode === "email" ? "bg-primary" : ""}`} + > + + Email Login + + + { + setLoginMode("phone"); + setIdentifier(""); + }} + className={`flex-1 py-2 rounded-lg items-center ${loginMode === "phone" ? "bg-primary" : ""}`} + > + + Phone Number + + + + {/* Form */} - Email or Phone Number + {loginMode === "email" ? "Email Address" : "Phone Number"} - + {loginMode === "email" ? ( + + ) : ( + + + +251 + + )} - - - Password - - - - - setShowPassword(!showPassword)}> - {showPassword ? ( - - ) : ( - - )} - + {loginMode === "email" && ( + + + Password + + + + + setShowPassword(!showPassword)}> + {showPassword ? ( + + ) : ( + + )} + + - + )} + + + {timer > 0 ? ( + + Resend code in{" "} + {timer}s + + ) : ( + + + Resend Verification Code + + + )} + + + + + ); +} diff --git a/app/payments/[id].tsx b/app/payments/[id].tsx index dc648d5..4709c59 100644 --- a/app/payments/[id].tsx +++ b/app/payments/[id].tsx @@ -1,130 +1,475 @@ -import { View, ScrollView, Pressable } from "react-native"; -import { useSirouRouter, useSirouParams } from "@sirou/react-native"; +import React, { useState, useEffect } from "react"; +import { + View, + ScrollView, + ActivityIndicator, + Alert, + Linking, +} from "react-native"; +import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; -import { Stack } from "expo-router"; +import { Stack, useLocalSearchParams } from "expo-router"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons"; +import { + Wallet, + Link2, + Clock, + AlertTriangle, + User, + ShieldCheck, + Building2, + Hash, + CheckCircle2, + Eye, + Trash2, + Network, + AlertCircle, + Info, +} from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; +import { StandardHeader } from "@/components/StandardHeader"; +import { api, BASE_URL } from "@/lib/api"; +import { useColorScheme } from "nativewind"; +import { toast } from "@/lib/toast-store"; export default function PaymentDetailScreen() { const nav = useSirouRouter(); - const { id } = useSirouParams(); + const { id } = useLocalSearchParams(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + + const [payment, setPayment] = useState(null); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(false); + const [matching, setMatching] = useState(false); + + const paymentId = Array.isArray(id) ? id[0] : id; + + useEffect(() => { + const fetchPayment = async () => { + try { + setLoading(true); + if (!paymentId) throw new Error("No ID provided"); + + console.log("[PaymentDetail] Fetching ID:", paymentId); + const response = await api.payments.getById({ + params: { id: paymentId }, + }); + setPayment(response); + console.log("[PaymentDetail] Response:", response); + } catch (error) { + console.error("[PaymentDetail] Error fetching payment:", error); + toast.error("Error", "Failed to fetch payment details."); + } finally { + setLoading(false); + } + }; + fetchPayment(); + }, [paymentId]); + + const handleDelete = async () => { + Alert.alert( + "Delete Payment", + "Are you sure you want to delete this payment record?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + setDeleting(true); + try { + if (!paymentId) return; + await api.payments.delete({ params: { id: paymentId } }); + toast.success("Deleted", "Payment record has been removed."); + nav.back(); + } catch (err: any) { + toast.error("Error", err.message || "Failed to delete payment."); + } finally { + setDeleting(false); + } + }, + }, + ], + ); + }; + + const handleMatch = async () => { + if (!payment || matching || !paymentId) return; + + setMatching(true); + toast.info("Matching...", "Searching for a corresponding invoice."); + + try { + // 1. Fetch all invoices + const invoices = await api.invoices.getAll(); + const invoiceList = Array.isArray(invoices) + ? invoices + : (invoices as any).data || []; + + // 2. Algorithm: Match Amount AND (Sender OR Receiver Name) + const pAmount = Number(payment.amount); + const pSender = (payment.senderName || "").toLowerCase().trim(); + const pReceiver = (payment.receiverName || "").toLowerCase().trim(); + + const match = invoiceList.find((inv: any) => { + const invAmount = Number(inv.amount); + const invCustomer = (inv.customerName || "").toLowerCase().trim(); + + // Exact amount match is primary + const amountMatches = Math.abs(invAmount - pAmount) < 0.01; + + // Name proximity match (either sender or receiver) + const nameMatches = + (invCustomer && pSender && pSender.includes(invCustomer)) || + (invCustomer && pSender && invCustomer.includes(pSender)) || + (invCustomer && pReceiver && pReceiver.includes(invCustomer)) || + (invCustomer && pReceiver && invCustomer.includes(pReceiver)); + + return amountMatches && nameMatches; + }); + + if (!match) { + toast.info( + "No Match Found", + "Could not find an invoice with the same amount and customer name.", + ); + return; + } + + // 3. Confirm match with user + Alert.alert( + "Match Found!", + `Associate this payment with Invoice #${match.invoiceNumber} for ${match.customerName}?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Associate", + style: "default", + onPress: async () => { + try { + await api.payments.associate({ + params: { id: paymentId }, + body: { invoiceId: match.id }, + }); + toast.success( + "Success", + "Payment successfully associated with invoice.", + ); + // Refresh data + const updated = await api.payments.getById({ + params: { id: paymentId }, + }); + setPayment(updated); + } catch (err: any) { + toast.error("Error", err.message || "Failed to associate."); + } + }, + }, + ], + ); + } catch (err: any) { + console.error("[Match] Error:", err); + toast.error("Error", "Failed to fetch invoices for matching."); + } finally { + setMatching(false); + } + }; + + if (loading) { + return ( + + + + + + + Retrieving Transaction... + + + + ); + } + + if (!payment) { + return ( + + + + + + + Transaction Not Found + + + The requested payment record could not be retrieved from the server. + + + + ); + } + + const amountValue = Number( + typeof payment.amount === "object" ? payment.amount.value : payment.amount, + ); + + const paymentDate = payment.paymentDate + ? new Date(payment.paymentDate) + : new Date(payment.createdAt); + + const isFlagged = payment.isFlagged === true; + const isScanned = payment.isScanned === true; + const scanned = payment.scannedData || {}; + const extracted = scanned.extractedFields || {}; + const verification = payment.verification || scanned.verification || {}; + const isFailed = + verification.verificationStatus === "failed" || + verification.isVerified === false; return ( - - - nav.back()} - className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" - > - - - - Payment Match - - - + - - - - - - - - - Pending Match - - + {/* Urgent Alerts */} + {isFlagged && ( + + + - - - Received Amount - - - $2,000.00 - - - - - - TXN-9982734 - - - - - Telebirr SMS + + + Security Flag ({payment.flagReason || "Audit Needed"}) + + + {payment.flagNotes || "System flagged this for manual review."} - + )} - {/* Transaction Details */} - - - Transaction Details - - - - - - - - - Received On - - - - Sep 11, 2022 · 14:30 + {/* Hero Section */} + + {/* Status Badges */} + + + + + {payment.invoiceId ? "Matched" : "Pending Match"} - - - - - - Status + + {isFailed && ( + + + + Verify Failed - - - Awaiting Link + )} + + {isScanned && ( + + + + Scanned - + )} - - {/* SMS Message */} - - - - Original SMS - - - "Payment received from Elnatan Jansen for order #2322 via - Telebirr. Amount: $2,000. Ref: B88-22X7." - - - - - {/* Action */} - - + + + {amountValue.toLocaleString()} + + + {payment.currency || "USD"} + + + + {/* Core Info Grid */} + + + + + Merchant + + + {extracted.merchantName || + payment.merchantName || + "Unknown Merchant"} + + + + + + Provider + + + {extracted.provider || + payment.paymentMethod || + "Direct Payment"} + + + + + + {/* Sender / Payer Box */} + + + + + + + + + Transaction Origin + + + {payment.senderName || + (payment.user + ? `${payment.user.firstName} ${payment.user.lastName}` + : "Business Account")} + + + + + + + + {payment.transactionId || "INTERNAL-TXN"} + + + + + + {paymentDate.toLocaleString()} + + + + + + + {/* Notes */} + {payment.notes && ( + + + + + Transaction Notes + + + + + " {payment.notes} " + + + + )} + + {/* Premium Actions */} + + + {scanned?.imageUrl && ( + + )} + {!payment.invoiceId && !isFailed && ( + + )} + + + + ); diff --git a/app/proforma/[id].tsx b/app/proforma/[id].tsx index 7ca6e15..81f0d81 100644 --- a/app/proforma/[id].tsx +++ b/app/proforma/[id].tsx @@ -1,59 +1,46 @@ import React, { useState, useEffect } from "react"; -import { View, ScrollView, Pressable, ActivityIndicator } from "react-native"; +import { + View, + ScrollView, + ActivityIndicator, + Alert, + Linking, + useColorScheme, + Pressable, +} from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; import { Stack, useLocalSearchParams } from "expo-router"; -import { useRouter } from "expo-router"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { - ArrowLeft, - DraftingCompass, + FileText, + Calendar, + Share2, + Download, + Trash2, + Package, Clock, - Send, ExternalLink, ChevronRight, - CheckCircle2, + User, + CreditCard, + Hash, + AlertCircle, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; +import { ShadowWrapper } from "@/components/ShadowWrapper"; import { StandardHeader } from "@/components/StandardHeader"; -import { api } from "@/lib/api"; +import { api, BASE_URL } from "@/lib/api"; import { toast } from "@/lib/toast-store"; - -const dummyData = { - 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" -}; +import { useAuthStore } from "@/lib/auth-store"; export default function ProformaDetailScreen() { const nav = useSirouRouter(); - const router = useRouter(); const { id } = useLocalSearchParams(); + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; const [loading, setLoading] = useState(true); const [proforma, setProforma] = useState(null); @@ -65,17 +52,60 @@ export default function ProformaDetailScreen() { const fetchProforma = async () => { try { setLoading(true); - const data = await api.proforma.getById({ params: { id: id as string } }); + // Ensure id is a string if useLocalSearchParams returns an array + const proformaId = Array.isArray(id) ? id[0] : id; + if (!proformaId) throw new Error("No ID provided"); + + const data = await api.proforma.getById({ params: { id: proformaId } }); setProforma(data); } catch (error: any) { console.error("[ProformaDetail] Error:", error); toast.error("Error", "Failed to load proforma details"); - setProforma(dummyData); // Use dummy data for testing } finally { setLoading(false); } }; + const handleGetPdf = async () => { + try { + const { token } = useAuthStore.getState(); + const pdfUrl = `${BASE_URL}proforma/${id}/pdf?token=${token}`; + await Linking.openURL(pdfUrl); + } catch (error) { + console.error("[ProformaDetail] PDF Error:", error); + toast.error("Error", "Failed to open PDF"); + } + }; + + const handleDelete = async () => { + Alert.alert( + "Delete Proforma", + "Are you sure you want to delete this proforma? This action cannot be undone.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + setLoading(true); + const proformaId = Array.isArray(id) ? id[0] : id; + await api.proforma.delete({ + params: { id: proformaId as string }, + }); + toast.success("Success", "Proforma deleted successfully"); + nav.back(); + } catch (error) { + console.error("[ProformaDetail] Delete Error:", error); + toast.error("Error", "Failed to delete proforma"); + setLoading(false); + } + }, + }, + ], + ); + }; + if (loading) { return ( @@ -94,234 +124,320 @@ export default function ProformaDetailScreen() { - Proforma not found + + + Proforma Not Found + + + The requested document could not be retrieved. + ); } - const subtotal = - proforma.items?.reduce( - (acc: number, item: any) => acc + (Number(item.total) || 0), - 0, - ) || 0; + const amountValue = Number( + typeof proforma.amount === "object" + ? proforma.amount.value + : proforma.amount, + ); + + const taxAmountValue = Number( + typeof proforma.taxAmount === "object" + ? proforma.taxAmount?.value + : proforma.taxAmount || 0, + ); + + const discountValue = Number( + typeof proforma.discountAmount === "object" + ? proforma.discountAmount?.value + : proforma.discountAmount || 0, + ); + + const subtotalValue = amountValue - taxAmountValue + discountValue; + const items = proforma.items || []; + + const statusColors = { + PAID: { + bg: "bg-emerald-500/10", + text: "text-emerald-500", + dot: "bg-emerald-500", + }, + PENDING: { + bg: "bg-amber-500/10", + text: "text-amber-500", + dot: "bg-amber-500", + }, + DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" }, + DEFAULT: { + bg: "bg-slate-500/10", + text: "text-slate-500", + dot: "bg-slate-500", + }, + }; + const status = (proforma.status || "PENDING").toUpperCase(); + const colors = + statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT; return ( - - {/* Header */} - + nav.go("proforma/edit", { id: proforma.id })} + /> - {/* Proforma Info Card */} - - - - - - - - Proforma Details + {/* Modern Hero Area */} + + + + + {status} + + + + + Proforma Estimated Total + + + + {amountValue.toLocaleString(undefined, { + minimumFractionDigits: 2, + })} + + + {proforma.currency || "ETB"} + + + + {/* Quick Stats Grid */} + + + + + Issue Date + + + {new Date( + proforma.issueDate || proforma.createdAt, + ).toLocaleDateString()} + + + + Due Date + + + {new Date(proforma.dueDate).toLocaleDateString()} + + + + - - - Proforma Number - {proforma.proformaNumber} + {/* Client Box */} + + + + + - - Issued Date - {new Date(proforma.issueDate).toLocaleDateString()} + + + Quotation For + + + {proforma.customerName || "Interested Client"} + - - Due Date - {new Date(proforma.dueDate).toLocaleDateString()} - - - Currency - {proforma.currency} - - {proforma.description && ( - - Description - {proforma.description} + + + {proforma.customerEmail && ( + + + + {proforma.customerEmail} + )} - - - - - {/* Customer Info Card */} - - - - - - - - Customer Information - - - - - - Name - {proforma.customerName} - - - Email - {proforma.customerEmail || "N/A"} - - - Phone - {proforma.customerPhone || "N/A"} + + + + #{proforma.id.split("-")[0]} + - + - - - {/* Line Items Card */} - - - - - Line Items - - - - {proforma.items?.map((item: any, i: number) => ( + {/* Detailed Items Table */} + + + Estimate Summary + + + {items.map((item: any, idx: number) => ( - - + + {item.description} - - {item.quantity} × {proforma.currency}{" "} - {Number(item.unitPrice).toLocaleString()} + + {Number( + item.total?.value || item.total || 0, + ).toLocaleString()} - - {proforma.currency} {Number(item.total).toLocaleString()} + + {item.quantity} units x{" "} + {Number( + item.unitPrice?.value || item.unitPrice || 0, + ).toLocaleString()}{" "} + {proforma.currency} ))} - - - - - Subtotal - - - {proforma.currency} {subtotal.toLocaleString()} - + {items.length === 0 && ( + + + Empty line items list - {Number(proforma.taxAmount) > 0 && ( - - - Tax - - - {proforma.currency}{" "} - {Number(proforma.taxAmount).toLocaleString()} - - - )} - {Number(proforma.discountAmount) > 0 && ( - - - Discount - - - -{proforma.currency}{" "} - {Number(proforma.discountAmount).toLocaleString()} - - - )} - - - Total Amount - - - {proforma.currency} {Number(proforma.amount).toLocaleString()} - - - - - + )} + + - {/* Notes Section (New) */} - {proforma.notes && ( - - - - Additional Notes + {/* Billing Breakdown */} + + + + + Net Price - - {proforma.notes} + + {subtotalValue.toLocaleString()} {proforma.currency} + + + + {taxAmountValue > 0 && ( + + + Estimated Tax + + + +{taxAmountValue.toLocaleString()} {proforma.currency} + + + )} + + {discountValue > 0 && ( + + + Applicable Discount + + + -{discountValue.toLocaleString()} {proforma.currency} + + + )} + + + + + Estimated Total + + + Valid as of today + + + + {amountValue.toLocaleString()} {proforma.currency} + + + {/* Notes */} + {proforma.notes && ( + + + Internal Notes + + + " {proforma.notes} " + + )} - {/* Actions */} - - + {/* Premium Actions */} + + + + + + - diff --git a/app/proforma/edit.tsx b/app/proforma/edit.tsx index 5ab3741..46dec47 100644 --- a/app/proforma/edit.tsx +++ b/app/proforma/edit.tsx @@ -7,6 +7,7 @@ import { StyleSheet, ActivityIndicator, } from "react-native"; +import { useColorScheme } from "nativewind"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; import { @@ -34,6 +35,26 @@ import { getPlaceholderColor } from "@/lib/colors"; type Item = { id: number; description: string; qty: string; price: string }; +const dummyData = { + proformaNumber: "PF-2024-001", + customerName: "Acme Corp", + customerEmail: "contact@acme.com", + customerPhone: "+1234567890", + currency: "USD", + description: "Web development services", + notes: "Payment due within 30 days", + taxAmount: 15.0, + taxAmountValue: 15.0, + discountAmount: 10.0, + discountAmountValue: 10.0, + issueDate: new Date().toISOString(), + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + items: [ + { description: "Homepage Design", quantity: 1, unitPrice: 500 }, + { description: "Mobile App Refactoring", quantity: 1, unitPrice: 1200 }, + ], +}; + const S = StyleSheet.create({ input: { height: 44, @@ -55,12 +76,12 @@ const S = StyleSheet.create({ }); function useInputColors() { - const { colorScheme } = useColorScheme(); // Fix usage - const dark = colorScheme === "dark"; + const { colorScheme } = useColorScheme(); + const isDark = 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", + bg: isDark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)", + border: isDark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)", + text: isDark ? "#f1f5f9" : "#0f172a", placeholder: "rgba(100,116,139,0.45)", }; } @@ -83,7 +104,6 @@ function Field({ flex?: number; }) { const c = useInputColors(); - const isDark = colorScheme.get() === "dark"; return ( { - setItems( - items.map((i) => (i.id === id ? { ...i, [field]: value } : i)) - ); + setItems(items.map((i) => (i.id === id ? { ...i, [field]: value } : i))); }; const calculateSubtotal = () => { @@ -284,7 +320,10 @@ export default function EditProformaScreen() { return ( - + @@ -297,9 +336,10 @@ export default function EditProformaScreen() { return ( - - - + - Add Item + + Add Item + @@ -517,7 +559,6 @@ export default function EditProformaScreen() { - {/* Bottom Action */}