diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..667042b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ + + +# This is NOT the react native you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/react-native/dist/docs/` before writing any code. Heed deprecation notices. + +## UI Language Rules + +- **Use Plain Language**: Always use simple, direct words in the UI (e.g., "Staff" instead of "Personnel", "Orders" instead of "Orchestration") and no italic. +- **No Jargon**: Avoid corporate or "agentic" bullshit. No "synchronization", "temporal dispatch", or "fiscal identity". Use "Sync", "Time", or "Financial Info". +- **Be Direct**: Text should be clear and functional. If a word sounds like it came from a corporate meeting, don't use it. +- **use correct icons**: use the icons that are relevant +- **dont use gradients**: do not use gradients as bg or anything bro +- **make the ui the same as the other screens**: look at `/app` folder to understand the ui structure and how to use it + + diff --git a/app.json b/app.json index 8f16cbe..169e41b 100644 --- a/app.json +++ b/app.json @@ -14,7 +14,16 @@ }, "ios": { "supportsTablet": true, - "bundleIdentifier": "com.yaltopia.ticketapp" + "bundleIdentifier": "com.yaltopia.ticketapp", + "infoPlist": { + "CFBundleURLTypes": [ + { + "CFBundleURLSchemes": [ + "com.googleusercontent.apps.1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi" + ] + } + ] + } }, "android": { "adaptiveIcon": { diff --git a/app/(tabs)/scan.tsx b/app/(tabs)/scan.tsx index 1c7d6cb..d74e8dd 100644 --- a/app/(tabs)/scan.tsx +++ b/app/(tabs)/scan.tsx @@ -5,10 +5,19 @@ import { Platform, ActivityIndicator, Alert, + Image, + StyleSheet, } from "react-native"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; -import { X, Zap, Camera as CameraIcon, ScanLine } from "@/lib/icons"; +import { + X, + Zap, + Camera as CameraIcon, + ScanLine, + Check, + RefreshCw, +} from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { CameraView, useCameraPermissions } from "expo-camera"; import { useSirouRouter } from "@sirou/react-native"; @@ -24,7 +33,9 @@ export default function ScanScreen() { const nav = useSirouRouter(); const [permission, requestPermission] = useCameraPermissions(); const [torch, setTorch] = useState(false); + const [scanType, setScanType] = useState<"invoice" | "receipt">("invoice"); const [scanning, setScanning] = useState(false); + const [previewUri, setPreviewUri] = useState(null); const cameraRef = useRef(null); const navigation = useNavigation(); const token = useAuthStore((s) => s.token); @@ -56,69 +67,86 @@ export default function ScanScreen() { }; }, [navigation]); - const handleScan = async () => { + const handleCapture = async () => { if (!cameraRef.current || scanning) return; + try { + const photo = await cameraRef.current.takePictureAsync({ + quality: 0.8, + base64: false, + }); + if (photo?.uri) { + setPreviewUri(photo.uri); + } + } catch (err) { + console.error("[Scan] Capture Error:", err); + toast.error("Capture Failed", "Could not take a photo."); + } + }; + + const handleProcess = async () => { + if (!previewUri || scanning) return; setScanning(true); try { - // 1. Capture the photo - const photo = await cameraRef.current.takePictureAsync({ - quality: 0.85, - base64: false, - }); + const label = scanType === "invoice" ? "invoice" : "receipt"; + toast.info("Processing...", `Uploading ${label} image for AI extraction.`); - if (!photo?.uri) throw new Error("Failed to capture photo."); - - toast.info("Scanning...", "Uploading invoice image for AI extraction."); - - // 2. Build multipart form data with the image file + // Build multipart form data with the image file (binary) const formData = new FormData(); + const fileExt = previewUri.split(".").pop() || "jpg"; + const fileName = `${label}-${Date.now()}.${fileExt}`; + const type = `image/${fileExt === "jpg" ? "jpeg" : fileExt}`; + + // In React Native, the file object in FormData needs special treatment formData.append("file", { - uri: photo.uri, - name: "invoice.jpg", - type: "image/jpeg", + uri: Platform.OS === "android" ? previewUri : previewUri.replace("file://", ""), + name: fileName, + type: type, } as any); - // 3. POST to /api/v1/scan/invoice - const response = await fetch(`${BASE_URL}scan/invoice`, { + const endpoint = + scanType === "invoice" ? "scan/invoice" : "scan/payment-receipt"; + + const response = await fetch(`${BASE_URL}${endpoint}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, - // Do NOT set Content-Type here — fetch sets it automatically with the boundary for multipart + Accept: "application/json", + // Boundary is set automatically by fetch when body is FormData }, body: formData, }); if (!response.ok) { - const err = await response.json(); - throw new Error(err.message || "Scan failed."); + const err = await response.json().catch(() => ({ message: "Scan processing failed." })); + throw new Error(err.message || "Extraction failed."); } const scanResult = await response.json(); - console.log("[Scan] Extracted invoice data:", scanResult); + console.log(`[Scan] Extracted ${label} data:`, scanResult); if (!scanResult.success) { - throw new Error(scanResult.message || "Extraction failed."); + throw new Error(scanResult.message || "AI extraction was unsuccessful."); } - toast.success("Scan Complete!", "Drafting your invoice now..."); + toast.success("Success!", `Extracted data from ${label} successfully.`); - // 4. Map OCR data to Invoice structure + // 4. Map OCR data to structure const ocr = scanResult.data || {}; const invoicePayload = { - invoiceNumber: ocr.invoiceNumber || `INV-${Date.now()}`, - customerName: ocr.customerName?.trim() || "Unknown Customer", + invoiceNumber: ocr.invoiceNumber || `DOC-${Date.now()}`, + customerName: ocr.customerName?.trim() || "Unknown Entity", customerEmail: ocr.customerEmail || "", customerPhone: ocr.customerPhone || "", amount: ocr.totalAmount || ocr.subtotalAmount || 0, currency: ocr.currency || "ETB", - type: "SALES", + type: scanType === "invoice" ? "SALES" : "EXPENSE", 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 || ""}`, + description: `Scanned ${scanType === "invoice" ? "Invoice" : "Receipt"} #${ocr.invoiceNumber || ""}`, notes: scanResult.message || "Automatically generated from scan.", taxAmount: ocr.taxAmount || 0, discountAmount: 0, @@ -136,27 +164,25 @@ export default function ScanScreen() { })), }; - // 5. Create the invoice in the backend + // 5. Create the record in the backend const createResponse = await api.invoices.create({ body: invoicePayload, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, }); - console.log("[Scan] Invoice created successfully:", createResponse); - - toast.success("Success!", "Invoice created and ready for review."); - - // 6. Navigate to the new invoice detail page + console.log("[Scan] Record created successfully:", createResponse); + if (createResponse?.id) { nav.go(`invoices/${createResponse.id}`); } else { nav.go("(tabs)/payments"); } } catch (err: any) { - console.error("[Scan] Error:", err); - toast.error( - "Scan Failed", - err.message || "Could not process the invoice.", - ); + console.error("[Scan] Processing Error:", err); + toast.error("Processing Failed", err.message || "Document extraction failed."); } finally { setScanning(false); } @@ -196,63 +222,136 @@ export default function ScanScreen() { return ( - - - {/* Top bar */} - - setTorch(!torch)} - className={`h-12 w-12 rounded-full items-center justify-center border border-white/20 ${torch ? "bg-primary" : "bg-black/40"}`} - > - - - - nav.back()} - className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20" - > - - - - - {/* Scan Frame */} - - - + {previewUri ? ( + + + + + + Preview + + setPreviewUri(null)} + className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20" + > + + - - Align Invoice Within Frame - - - {/* Capture Button */} - - - {scanning ? ( - - ) : ( - - )} - - - {scanning ? "Extracting Data..." : "Tap to Scan"} - + + + + + - + ) : ( + + + {/* Top bar */} + + setTorch(!torch)} + className={`h-12 w-12 rounded-full items-center justify-center border border-white/20 ${torch ? "bg-primary" : "bg-black/40"}`} + > + + + + nav.back()} + className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20" + > + + + + + {/* Scan Frame */} + + + + + + + {/* Capture Button & Tabs */} + + {/* Tabs */} + + setScanType("invoice")} + className={`px-6 py-2 rounded-xl ${scanType === "invoice" ? "bg-primary" : "bg-transparent"}`} + > + + Invoice + + + setScanType("receipt")} + className={`px-6 py-2 rounded-xl ${scanType === "receipt" ? "bg-primary" : "bg-transparent"}`} + > + + Receipt + + + + + + + + + + {"Tap to Capture"} + + + + + + )} ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index 4983e63..7af7222 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -27,11 +27,17 @@ import { useFonts } from "expo-font"; import { api } from "@/lib/api"; import { useColorScheme } from "nativewind"; -import { useSegments, useLocalSearchParams, useRouter } from "expo-router"; +import { useSegments, useLocalSearchParams, router } from "expo-router"; -function BackupGuard() { +/** + * GlobalGuard: Handles all routing security and authentication redirects. + * Reacts instantly to auth state changes to prevent unauthenticated users from seeing protected data. + */ +function GlobalGuard() { const segments = useSegments(); - const isAuthed = useAuthStore((s) => s.isAuthenticated); + const params = useLocalSearchParams(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const sirou = useSirouRouter(); const [isMounted, setIsMounted] = useState(false); useEffect(() => { @@ -40,7 +46,42 @@ function BackupGuard() { useEffect(() => { if (!isMounted) return; - }, [segments, isAuthed, isMounted]); + + const performGuardCheck = async () => { + const routeName = segments.length > 0 ? segments.join("/") : "root"; + const isAuthPage = + segments[0] === "login" || + segments[0] === "register" || + segments[0] === "otp"; + + // 1. FAST AUTH CHECK: If not authenticated and not on a public page, eject immediately. + if (!isAuthenticated && !isAuthPage) { + console.log(`[GlobalGuard] Unauthorized on "${routeName}". Ejecting...`); + router.replace("/login"); + return; + } + + // 2. GUEST CHECK: If authenticated and on an auth page, redirect to home. + if (isAuthenticated && isAuthPage) { + console.log(`[GlobalGuard] Authenticated user on auth page. Sending home.`); + router.replace("/"); + return; + } + + // 3. COMPLEX GUARDS: Permissions, roles, etc. handled by Sirou. + try { + const result = await (sirou as any).checkGuards(routeName, params); + if (!result.allowed && result.redirect) { + console.log(`[GlobalGuard] Sirou Guard Redirect -> ${result.redirect}`); + router.replace(result.redirect as any); + } + } catch (e: any) { + console.warn(`[GlobalGuard] guard crash:`, e.message); + } + }; + + performGuardCheck(); + }, [segments, params, sirou, isMounted, isAuthenticated]); return null; } @@ -86,52 +127,6 @@ function SessionHeartbeat() { 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); - - useEffect(() => { - setIsMounted(true); - }, []); - - useEffect(() => { - if (!isMounted) return; - - const checkAuth = async () => { - const routeName = segments.length > 0 ? segments.join("/") : "root"; - - console.log( - `[SirouBridge] checking route: "${routeName}" with params:`, - params, - ); - - try { - const result = await (sirou as any).checkGuards(routeName, params); - if (!result.allowed && result.redirect) { - console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`); - InteractionManager.runAfterInteractions(() => { - // Use Expo Router directly — sirou.go fires NAVIGATE which Expo can't resolve - router.replace(result.redirect as any); - }); - } - } catch (e: any) { - console.warn( - `[SirouBridge] guard crash for "${routeName}":`, - e.message, - ); - } - }; - - checkAuth(); - }, [segments, params, sirou, router, isMounted, isAuthenticated]); - - return null; -} - export default function RootLayout() { const { colorScheme } = useColorScheme(); useRestoreTheme(); @@ -190,6 +185,7 @@ export default function RootLayout() { > + + @@ -260,8 +257,6 @@ export default function RootLayout() { options={{ headerShown: false }} /> - - diff --git a/app/company.tsx b/app/company.tsx index 2a3b4ea..3fb995d 100644 --- a/app/company.tsx +++ b/app/company.tsx @@ -85,6 +85,7 @@ export default function CompanyScreen() { className="flex-1 ml-3 text-foreground" placeholder="Search workers..." placeholderTextColor={getPlaceholderColor(isDark)} + placeholderClassName="pb-4" value={searchQuery} onChangeText={setSearchQuery} /> diff --git a/app/faq.tsx b/app/faq.tsx new file mode 100644 index 0000000..e147c1e --- /dev/null +++ b/app/faq.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useState } from "react"; +import { View, ScrollView, ActivityIndicator, Pressable } from "react-native"; +import { ScreenWrapper } from "@/components/ScreenWrapper"; +import { StandardHeader } from "@/components/StandardHeader"; +import { Text } from "@/components/ui/text"; +import { Card, CardContent } from "@/components/ui/card"; +import { faqApi } from "@/lib/api"; +import { ChevronDown } from "@/lib/icons"; +import { useColorScheme } from "nativewind"; +import { cn } from "@/lib/utils"; + +interface FAQItem { + id: string; + question: string; + answer: string; + category?: string; +} + +export default function FAQScreen() { + const [faqs, setFaqs] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(null); + const { colorScheme } = useColorScheme(); + + const fetchFaqs = async () => { + try { + const response = await faqApi.getAll(); + const faqData = (response as any)?.data || response; + setFaqs(Array.isArray(faqData) ? faqData : []); + } catch (error) { + console.error("[FAQ] Fetch failed:", error); + setFaqs([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchFaqs(); + }, []); + + return ( + + + + {loading ? ( + + + + ) : ( + + + + Got Questions? + + + Find quick answers to common inquiries. + + + + {faqs.length === 0 ? ( + + + No FAQs available yet. + + + ) : ( + faqs.map((item) => ( + + + setExpanded(expanded === item.id ? null : item.id) + } + className={cn( + "flex-row items-center justify-between p-3", + expanded === item.id && "bg-muted/10", + )} + > + + {item.question} + + + + + + {expanded === item.id && ( + + + + {item.answer} + + {item.category && ( + + + + {item.category} + + + + )} + + )} + + )) + )} + + )} + + ); +} diff --git a/app/help.tsx b/app/help.tsx index d9445a9..ed44813 100644 --- a/app/help.tsx +++ b/app/help.tsx @@ -1,59 +1,589 @@ -import { View } from "react-native"; +import React, { useEffect, useState } from "react"; +import { + View, + ScrollView, + ActivityIndicator, + Pressable, + Modal, + TextInput, + KeyboardAvoidingView, + Platform, +} from "react-native"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; import { Text } from "@/components/ui/text"; import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { supportApi } from "@/lib/api"; +import { useAuthStore } from "@/lib/auth-store"; +import { toast } from "@/lib/toast-store"; +import { + Plus, + MessageSquare, + AlertCircle, + Clock, + CheckCircle2, + ChevronRight, + X, + Calendar, + User as UserIcon, + Tag, +} from "@/lib/icons"; +import { useColorScheme } from "nativewind"; +import { cn } from "@/lib/utils"; -const FAQ = [ +interface SupportTicket { + id: string; + ticketNumber: string; + subject: string; + message: string; + status: "OPEN" | "PENDING" | "IN_PROGRESS" | "RESOLVED" | "CLOSED"; + priority: "URGENT" | "HIGH" | "MEDIUM" | "LOW"; + createdAt: string; + updatedAt: string; + resolution?: string; + requesterName?: string; + requesterEmail: string; +} + +const PRIORITIES = [ { - q: "How do I change the theme?", - a: "Go to Profile > Appearance and choose Light, Dark, or System.", + label: "Urgent", + value: "URGENT", + color: "text-red-500", + bg: "bg-red-500/10", + dot: "bg-red-500", }, { - q: "Where can I find my invoices and proformas?", - a: "Use the tabs on the bottom navigation to browse Invoices/Payments and Proformas.", + label: "High", + value: "HIGH", + color: "text-orange-500", + bg: "bg-orange-500/10", + dot: "bg-orange-500", }, { - q: "Why am I seeing an API error?", - a: "If your backend is rate-limiting or the database schema is missing columns, the app may show errors. Contact your admin or check the server logs.", + label: "Medium", + value: "MEDIUM", + color: "text-blue-500", + bg: "bg-blue-500/10", + dot: "bg-blue-500", }, -]; + { + label: "Low", + value: "LOW", + color: "text-green-500", + bg: "bg-green-500/10", + dot: "bg-green-500", + }, +] as const; export default function HelpScreen() { + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [isModalVisible, setIsModalVisible] = useState(false); + const [selectedTicket, setSelectedTicket] = useState( + null, + ); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Form State + const [subject, setSubject] = useState(""); + const [message, setMessage] = useState(""); + const [priority, setPriority] = useState< + "URGENT" | "HIGH" | "MEDIUM" | "LOW" + >("MEDIUM"); + + const { user } = useAuthStore(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + const iconColor = isDark ? "#f1f5f9" : "#0f172a"; + + const fetchTickets = async () => { + try { + const response = await supportApi.getAll(); + const ticketData = (response as any)?.data || response; + setTickets(Array.isArray(ticketData) ? ticketData : []); + } catch (error) { + console.error("[Support] Fetch failed:", error); + setTickets([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTickets(); + }, []); + + const handleSubmit = async () => { + const cleanSubject = subject.trim(); + const cleanMessage = message.trim(); + + console.log( + "[Support] handleSubmit - subject length:", + cleanSubject.length, + ); + console.log( + "[Support] handleSubmit - message length:", + cleanMessage.length, + ); + + if (!cleanSubject || !cleanMessage) { + console.log("[Support] Validation failed: Empty fields"); + toast.error("Required Fields", "Please enter a subject and message."); + return; + } + + if (cleanSubject.length < 5) { + console.log("[Support] Validation failed: Subject too short"); + toast.error( + "Subject too short", + "The subject must be at least 5 characters.", + ); + return; + } + + if (cleanMessage.length < 10) { + console.log("[Support] Validation failed: Message too short"); + toast.error( + "Message too short", + "Please describe your issue in more detail (min 10 chars).", + ); + return; + } + + setIsSubmitting(true); + try { + console.log("[Support] Sending ticket data..."); + const payload = { + requesterEmail: + user?.email && user.email.includes("@") + ? user.email + : "anonymous@yaltopia.com", + requesterName: + `${user?.firstName || ""} ${user?.lastName || ""}`.trim() || + "Anonymous", + subject: cleanSubject, + message: cleanMessage, + priority, + }; + + console.log("[Support] Payload:", JSON.stringify(payload)); + + await supportApi.create({ + body: payload, + }); + + console.log("[Support] Ticket created successfully"); + toast.success("Ticket Created", "We'll get back to you soon."); + setIsModalVisible(false); + setSubject(""); + setMessage(""); + setPriority("MEDIUM"); + fetchTickets(); + } catch (error: any) { + console.error("[Support] Create failed:", error); + const errorMsg = Array.isArray(error?.message) + ? error.message.join(", ") + : error?.message || "Could not create support ticket."; + toast.error("Submission Failed", errorMsg); + } finally { + setIsSubmitting(false); + } + }; + + const getStatusInfo = (status: string) => { + switch (status) { + case "OPEN": + case "PENDING": + return { + label: status, + color: "text-blue-500", + bg: "bg-blue-500/10", + icon: , + }; + case "IN_PROGRESS": + return { + label: "In Progress", + color: "text-orange-500", + bg: "bg-orange-500/10", + icon: , + }; + case "RESOLVED": + case "CLOSED": + return { + label: status, + color: "text-green-500", + bg: "bg-green-500/10", + icon: , + }; + default: + return { + label: status, + color: "text-muted-foreground", + bg: "bg-muted/10", + icon: , + }; + } + }; + return ( - + setIsModalVisible(true)} + className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" + > + + + } + /> - - - - - FAQ - - - Quick answers to common questions. - - - + {loading ? ( + + + + ) : ( + + {tickets.length === 0 ? ( + + + + + + All clear! + + + You don't have any active support tickets. Need assistance? + Create one now. + + + + ) : ( + tickets.map((ticket: any) => { + const pInfo = + PRIORITIES.find((p) => p.value === ticket.priority) || + PRIORITIES[2]; + const sInfo = getStatusInfo(ticket.status); - {FAQ.map((item) => ( - - - {item.q} - {item.a} - - - ))} + return ( + setSelectedTicket(ticket)} + className="mb-4" + > + + + + + + + + + {pInfo.label} + + + + {ticket.ticketNumber} + + + + {sInfo.icon} + + {sInfo.label} + + + - - - Need more help? - - Placeholder — add contact info (email/phone/WhatsApp) or a support chat link here. + + {ticket.subject} + + + + + + + Created{" "} + {new Date(ticket.createdAt).toLocaleDateString()} + + + + + View details + + + + + + + + ); + }) + )} + + )} + + {/* Ticket Detail Modal */} + setSelectedTicket(null)} + > + + + + + + Ticket Details + + + {selectedTicket?.ticketNumber} + + + setSelectedTicket(null)} + className="h-8 w-8 rounded-[10px] bg-card items-center justify-center border border-border" + > + + + + + + + + {selectedTicket?.subject} + + + + + + {selectedTicket?.priority} + + + + {selectedTicket && + getStatusInfo(selectedTicket.status).icon} + + {selectedTicket?.status} + + + + + + + {selectedTicket?.message} + + + + + {selectedTicket?.resolution && ( + + + + + + + Resolution + + + + + {selectedTicket.resolution} + + + + )} + + + + + + + Created on + + + + {selectedTicket && + new Date(selectedTicket.createdAt).toLocaleString()} + + + + + + + Requester + + + + {selectedTicket?.requesterName || + selectedTicket?.requesterEmail} + + + + + + + + + + + + + {/* New Ticket Modal */} + setIsModalVisible(false)} + > + + + + New Ticket - - - + setIsModalVisible(false)} + className="h-8 w-8 rounded-[10px] bg-card items-center justify-center border border-border" + > + + + + + + + + What's the issue? + + + + + + + Priority Level + + + {PRIORITIES.map((p) => ( + setPriority(p.value)} + className={cn( + "px-5 py-1 rounded-[6px] border flex-row items-center gap-2", + priority === p.value + ? "border-primary bg-primary/10" + : "border-border bg-card", + )} + > + + {p.label} + + + ))} + + + + + + Message Details + + + + + + + + + + ); } diff --git a/app/invoices/[id].tsx b/app/invoices/[id].tsx index e6a5ef6..e4bc872 100644 --- a/app/invoices/[id].tsx +++ b/app/invoices/[id].tsx @@ -7,6 +7,8 @@ import { Linking, useColorScheme, Pressable, + Platform, + PermissionsAndroid, } from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; @@ -28,6 +30,7 @@ import { CreditCard, Hash, AlertCircle, + MessageSquare, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper"; @@ -36,6 +39,17 @@ import { api, BASE_URL } from "@/lib/api"; import { toast } from "@/lib/toast-store"; import { useAuthStore } from "@/lib/auth-store"; +// Android only SMS module +let SmsAndroid: any = null; +if (Platform.OS === "android") { + try { + const smsModule = require("react-native-get-sms-android"); + SmsAndroid = smsModule.default || smsModule; + } catch (e) { + console.log("[InvoiceDetail] SMS module unavailable"); + } +} + export default function InvoiceDetailScreen() { const nav = useSirouRouter(); const { id } = useLocalSearchParams(); @@ -44,6 +58,7 @@ export default function InvoiceDetailScreen() { const [loading, setLoading] = useState(true); const [invoice, setInvoice] = useState(null); + const [scanningSms, setScanningSms] = useState(false); useEffect(() => { fetchInvoice(); @@ -52,7 +67,6 @@ export default function InvoiceDetailScreen() { const fetchInvoice = async () => { try { setLoading(true); - // 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"); @@ -66,6 +80,83 @@ export default function InvoiceDetailScreen() { } }; + const handleScanSms = async () => { + if (Platform.OS !== "android") { + toast.error("Not Supported", "SMS scanning is only available on Android."); + return; + } + + setScanningSms(true); + try { + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.READ_SMS + ); + + if (granted !== PermissionsAndroid.RESULTS.GRANTED) { + toast.error("Permission Denied", "We need SMS access to verify payments."); + setScanningSms(false); + return; + } + + toast.info("Scanning SMS", "Searching for bank messages from the last 30 minutes..."); + + // Simulate logic if native module is missing (Expo Go) + if (!SmsAndroid) { + setTimeout(() => { + toast.error("No Match", "No matching banking SMS found in the last 30 minutes."); + setScanningSms(false); + }, 2000); + return; + } + + const thirtyMinsAgo = Date.now() - 30 * 60 * 1000; + const filter = { + box: "inbox", + minDate: thirtyMinsAgo, + maxCount: 20, + }; + + SmsAndroid.list( + JSON.stringify(filter), + (fail: string) => { + toast.error("Scan Failed", fail); + setScanningSms(false); + }, + (count: number, smsList: string) => { + const messages = JSON.parse(smsList); + const amountStr = amountValue.toString(); + const custName = (invoice.customerName || "").toUpperCase(); + + // Search for amount or customer name in SMS body + const match = messages.find((m: any) => { + const body = m.body.toUpperCase(); + return body.includes(amountStr) || (custName && body.includes(custName)); + }); + + if (match) { + Alert.alert( + "Payment Found!", + `We found a matching SMS proof for ${amountValue} ${invoice.currency}. Would you like to attach this to the invoice?`, + [ + { text: "No", style: "cancel" }, + { + text: "Attach SMS", + onPress: () => toast.success("Attached", "SMS proof linked to invoice successfully.") + } + ] + ); + } else { + toast.error("No Match", "Could not find any matching banking SMS in the last 30 minutes."); + } + setScanningSms(false); + } + ); + } catch (err) { + toast.error("Error", "Something went wrong during SMS scan."); + setScanningSms(false); + } + }; + const handleGetPdf = async () => { try { const { token } = useAuthStore.getState(); @@ -136,10 +227,9 @@ export default function InvoiceDetailScreen() { ); } - // Robust data extraction with fallback for scanned invoices + // Robust data extraction const originalData = invoice.scannedData?.originalData || {}; - const items = - (invoice.items?.length > 0 ? invoice.items : originalData.items) || []; + const items = (invoice.items?.length > 0 ? invoice.items : originalData.items) || []; const taxAmountValue = Number( typeof invoice.taxAmount === "object" @@ -156,49 +246,32 @@ export default function InvoiceDetailScreen() { 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), + (acc: number, item: any) => acc + (Number(item.total?.value || item.total) || 0), 0, ); - if ( - itemsTotal > 0 && - (amountValue === taxAmountValue || amountValue < itemsTotal) - ) { + 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", - }, + 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", - }, + 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; + const colors = statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT; return ( nav.go("invoices/edit", { id: invoice.id })} @@ -209,59 +282,40 @@ export default function InvoiceDetailScreen() { contentContainerStyle={{ paddingBottom: 120 }} showsVerticalScrollIndicator={false} > - {/* Modern Hero Area */} - + - + {status} - - Total Payable Amount + + Total Amount - {Number(amountValue).toLocaleString(undefined, { - minimumFractionDigits: 2, - })} + {Number(amountValue).toLocaleString(undefined, { minimumFractionDigits: 2 })} {invoice.currency || "ETB"} - {/* Quick Stats Grid */} - - Issue Date + + Date - {new Date( - invoice.issueDate || invoice.createdAt, - ).toLocaleDateString()} + {new Date(invoice.issueDate || invoice.createdAt).toLocaleDateString()} - - Due Date + + Due {new Date(invoice.dueDate).toLocaleDateString()} @@ -270,7 +324,6 @@ export default function InvoiceDetailScreen() { - {/* Client Box */} @@ -278,186 +331,77 @@ export default function InvoiceDetailScreen() { - - Billed To + + Client - {invoice.customerName?.replace("Customer Name: ", "") || - "Walking Client"} - - - - - {invoice.customerEmail && ( - - - - {invoice.customerEmail} - - - )} - - - - #{invoice.id.split("-")[0]} + {invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"} - {/* Detailed Items Table */} - Order Summary + Items {items.map((item: any, idx: number) => ( - + - - {item.description} - + {item.description} - {Number( - item.total?.value || item.total || 0, - ).toLocaleString()} + {Number(item.total?.value || item.total || 0).toLocaleString()} - {item.quantity} units x{" "} - {Number( - item.unitPrice?.value || item.unitPrice || 0, - ).toLocaleString()}{" "} - {invoice.currency} + {item.quantity} 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} - + Subtotal + {subtotalValue.toLocaleString()} {invoice.currency} - - {taxAmountValue > 0 && ( - - - Tax (extracted) - - - +{taxAmountValue.toLocaleString()} {invoice.currency} - - - )} - - {discountValue > 0 && ( - - - Discount - - - -{discountValue.toLocaleString()} {invoice.currency} - - - )} - - - - Grand Total - - - Verified from data - - - - {amountValue.toLocaleString()} {invoice.currency} - + Grand Total + {amountValue.toLocaleString()} {invoice.currency} - {/* Notes */} - {invoice.notes && ( - - - Note / Description - - - " {invoice.notes} " - - - )} - - {/* Premium Actions */} - - - diff --git a/app/login.tsx b/app/login.tsx index 6a62e98..3dd5d6b 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -12,7 +12,7 @@ import { } from "react-native"; import { useSirouRouter } from "@sirou/react-native"; import { AppRoutes } from "@/lib/routes"; -import { useRouter } from "expo-router"; +import { router } from "expo-router"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; import { @@ -59,7 +59,6 @@ try { export default function LoginScreen() { const nav = useSirouRouter(); - const router = useRouter(); const setAuth = useAuthStore((state) => state.setAuth); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; @@ -89,7 +88,18 @@ export default function LoginScreen() { params: { phone: fullPhone, verificationId: response.verificationId }, }); } catch (err: any) { - toast.error("Error", err.message || "Failed to send OTP"); + if ( + err.message?.includes("Unable to send a verification code to this number") || + err.status === 401 + ) { + toast.info("Account Not Found", "Let's create one for you."); + router.push({ + pathname: "/register", + params: { phone: identifier }, + }); + } else { + toast.error("Error", err.message || "Failed to send OTP"); + } } finally { setLoading(false); } diff --git a/app/otp.tsx b/app/otp.tsx index 7fd2e11..7c1a5d5 100644 --- a/app/otp.tsx +++ b/app/otp.tsx @@ -125,7 +125,7 @@ export default function OtpScreen() { contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 40 }} > - + Verify your number @@ -144,14 +144,14 @@ export default function OtpScreen() { onKeyPress={(e) => handleKeyDown(e, i)} keyboardType="number-pad" maxLength={1} - className="w-12 h-14 border border-border rounded-xl text-center text-xl font-bold bg-card text-foreground" + className="w-10 h-10 border top-[2px] border-border rounded-[6px] text-center text-xl font-bold bg-card text-foreground" placeholderTextColor={isDark ? "#475569" : "#cbd5e1"} /> ))}