Compare commits

..

No commits in common. "db5ac60987f544a3d9bfd7cc88ceec7eabbd1827" and "be2bde41a28bb3478e1571652fef31688da2c5b5" have entirely different histories.

20 changed files with 946 additions and 2879 deletions

View File

@ -22,7 +22,7 @@ import { ShadowWrapper } from "@/components/ShadowWrapper";
import { useAuthStore } from "@/lib/auth-store"; import { useAuthStore } from "@/lib/auth-store";
const { width } = Dimensions.get("window"); const { width } = Dimensions.get("window");
const LATEST_CARD_WIDTH = width * 0.6; const LATEST_CARD_WIDTH = width * 0.8;
interface NewsItem { interface NewsItem {
id: string; id: string;
@ -36,9 +36,7 @@ interface NewsItem {
export default function NewsScreen() { export default function NewsScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const permissions = useAuthStore( const permissions = useAuthStore((s: { permissions: string[] }) => s.permissions);
(s: { permissions: string[] }) => s.permissions,
);
// Safe accessor to handle initialization race conditions // Safe accessor to handle initialization race conditions
const getNewsApi = () => { const getNewsApi = () => {
@ -138,6 +136,7 @@ export default function NewsScreen() {
const LatestItem = ({ item }: { item: NewsItem }) => ( const LatestItem = ({ item }: { item: NewsItem }) => (
<Pressable className="mr-4" key={item.id}> <Pressable className="mr-4" key={item.id}>
<ShadowWrapper level="md">
<Card <Card
className="overflow-hidden rounded-[20px] bg-card border-border/50" className="overflow-hidden rounded-[20px] bg-card border-border/50"
style={{ width: LATEST_CARD_WIDTH, height: 160 }} style={{ width: LATEST_CARD_WIDTH, height: 160 }}
@ -174,11 +173,13 @@ export default function NewsScreen() {
</View> </View>
</View> </View>
</Card> </Card>
</ShadowWrapper>
</Pressable> </Pressable>
); );
const NewsItem = ({ item }: { item: NewsItem }) => ( const NewsItem = ({ item }: { item: NewsItem }) => (
<Pressable className="mb-4" key={item.id}> <Pressable className="mb-4" key={item.id}>
<ShadowWrapper level="xs">
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40"> <Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
<View className="p-4"> <View className="p-4">
<View className="flex-row items-center gap-2 mb-1.5"> <View className="flex-row items-center gap-2 mb-1.5">
@ -216,6 +217,7 @@ export default function NewsScreen() {
</View> </View>
</View> </View>
</Card> </Card>
</ShadowWrapper>
</Pressable> </Pressable>
); );

View File

@ -64,10 +64,7 @@ export default function PaymentsScreen() {
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
// Check permissions // Check permissions
const canCreatePayments = hasPermission( const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]);
permissions,
PERMISSION_MAP["payments:create"],
);
const fetchPayments = useCallback( const fetchPayments = useCallback(
async (pageNum: number, isRefresh = false) => { async (pageNum: number, isRefresh = false) => {
@ -127,10 +124,6 @@ export default function PaymentsScreen() {
reconciled: payments.filter((p) => p.invoiceId && !p.isFlagged), reconciled: payments.filter((p) => p.invoiceId && !p.isFlagged),
}; };
useEffect(() => {
console.log(payments);
}, [payments]);
const renderPaymentItem = ( const renderPaymentItem = (
pay: Payment, pay: Payment,
type: "reconciled" | "pending" | "flagged", type: "reconciled" | "pending" | "flagged",
@ -238,6 +231,7 @@ export default function PaymentsScreen() {
</Text> </Text>
</Button> </Button>
{/* Flagged Section */} {/* Flagged Section */}
{categorized.flagged.length > 0 && ( {categorized.flagged.length > 0 && (
<> <>
@ -247,9 +241,7 @@ export default function PaymentsScreen() {
</Text> </Text>
</View> </View>
<View className="gap-2 mb-6"> <View className="gap-2 mb-6">
{categorized.flagged.map((p) => {categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))}
renderPaymentItem(p, "flagged"),
)}
</View> </View>
</> </>
)} )}

View File

@ -14,7 +14,7 @@ import { CameraView, useCameraPermissions } from "expo-camera";
import { useSirouRouter } from "@sirou/react-native"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes"; import { AppRoutes } from "@/lib/routes";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { api, BASE_URL } from "@/lib/api"; import { BASE_URL } from "@/lib/api";
import { useAuthStore } from "@/lib/auth-store"; import { useAuthStore } from "@/lib/auth-store";
import { toast } from "@/lib/toast-store"; import { toast } from "@/lib/toast-store";
@ -94,63 +94,13 @@ export default function ScanScreen() {
throw new Error(err.message || "Scan failed."); throw new Error(err.message || "Scan failed.");
} }
const scanResult = await response.json(); const data = await response.json();
console.log("[Scan] Extracted invoice data:", scanResult); console.log("[Scan] Extracted invoice data:", data);
if (!scanResult.success) { toast.success("Scan Complete!", "Invoice data extracted successfully.");
throw new Error(scanResult.message || "Extraction failed.");
}
toast.success("Scan Complete!", "Drafting your invoice now..."); // Navigate to create invoice screen
nav.go("proforma/create");
// 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) { } catch (err: any) {
console.error("[Scan] Error:", err); console.error("[Scan] Error:", err);
toast.error( toast.error(

View File

@ -6,28 +6,18 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Toast } from "@/components/Toast"; import { Toast } from "@/components/Toast";
import "@/global.css"; import "@/global.css";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { import { View, ActivityIndicator, InteractionManager } from "react-native";
View,
ActivityIndicator,
InteractionManager,
AppState,
} from "react-native";
import { useRestoreTheme, NAV_THEME } from "@/lib/theme"; import { useRestoreTheme, NAV_THEME } from "@/lib/theme";
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native"; import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
import { refreshTokens } from "@/lib/api-middlewares"; import { NavigationContainer, NavigationIndependentTree, ThemeProvider } from "@react-navigation/native";
import {
NavigationContainer,
NavigationIndependentTree,
ThemeProvider,
} from "@react-navigation/native";
import { routes } from "@/lib/routes"; import { routes } from "@/lib/routes";
import { authGuard, guestGuard } from "@/lib/auth-guards"; import { authGuard, guestGuard } from "@/lib/auth-guards";
import { useAuthStore } from "@/lib/auth-store"; import { useAuthStore } from "@/lib/auth-store";
import { useFonts } from "expo-font"; import { useFonts } from 'expo-font';
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useColorScheme } from "nativewind"; import { useColorScheme } from 'react-native';
import { useSegments, useLocalSearchParams, useRouter } from "expo-router"; import { useSegments } from "expo-router";
function BackupGuard() { function BackupGuard() {
const segments = useSegments(); const segments = useSegments();
@ -40,57 +30,18 @@ function BackupGuard() {
useEffect(() => { useEffect(() => {
if (!isMounted) return; 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]); }, [segments, isAuthed, isMounted]);
return null; 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() { function SirouBridge() {
const sirou = useSirouRouter(); const sirou = useSirouRouter();
const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const params = useLocalSearchParams();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
@ -102,20 +53,19 @@ function SirouBridge() {
if (!isMounted) return; if (!isMounted) return;
const checkAuth = async () => { 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"; const routeName = segments.length > 0 ? segments.join("/") : "root";
console.log( console.log(`[SirouBridge] checking route: "${routeName}"`);
`[SirouBridge] checking route: "${routeName}" with params:`,
params,
);
try { try {
const result = await (sirou as any).checkGuards(routeName, params); const result = await (sirou as any).checkGuards(routeName);
if (!result.allowed && result.redirect) { if (!result.allowed && result.redirect) {
console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`); console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`);
// Use Sirou navigation safely
InteractionManager.runAfterInteractions(() => { InteractionManager.runAfterInteractions(() => {
// Use Expo Router directly — sirou.go fires NAVIGATE which Expo can't resolve sirou.go(result.redirect);
router.replace(result.redirect as any);
}); });
} }
} catch (e: any) { } catch (e: any) {
@ -127,26 +77,26 @@ function SirouBridge() {
}; };
checkAuth(); checkAuth();
}, [segments, params, sirou, router, isMounted, isAuthenticated]); }, [segments, sirou, isMounted, isAuthenticated]);
return null; return null;
} }
export default function RootLayout() { export default function RootLayout() {
const { colorScheme } = useColorScheme(); const colorScheme = useColorScheme();
useRestoreTheme(); useRestoreTheme();
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const [hasHydrated, setHasHydrated] = useState(false); const [hasHydrated, setHasHydrated] = useState(false);
const [fontsLoaded] = useFonts({ const [fontsLoaded] = useFonts({
"DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"), 'DMSans-Regular': require('../assets/fonts/DMSans-Regular.ttf'),
"DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"), 'DMSans-Bold': require('../assets/fonts/DMSans-Bold.ttf'),
"DMSans-Medium": require("../assets/fonts/DMSans-Medium.ttf"), 'DMSans-Medium': require('../assets/fonts/DMSans-Medium.ttf'),
"DMSans-SemiBold": require("../assets/fonts/DMSans-SemiBold.ttf"), 'DMSans-SemiBold': require('../assets/fonts/DMSans-SemiBold.ttf'),
"DMSans-Light": require("../assets/fonts/DMSans-Light.ttf"), 'DMSans-Light': require('../assets/fonts/DMSans-Light.ttf'),
"DMSans-ExtraLight": require("../assets/fonts/DMSans-ExtraLight.ttf"), 'DMSans-ExtraLight': require('../assets/fonts/DMSans-ExtraLight.ttf'),
"DMSans-Thin": require("../assets/fonts/DMSans-Thin.ttf"), 'DMSans-Thin': require('../assets/fonts/DMSans-Thin.ttf'),
"DMSans-Black": require("../assets/fonts/DMSans-Black.ttf"), 'DMSans-Black': require('../assets/fonts/DMSans-Black.ttf'),
"DMSans-ExtraBold": require("../assets/fonts/DMSans-ExtraBold.ttf"), 'DMSans-ExtraBold': require('../assets/fonts/DMSans-ExtraBold.ttf'),
}); });
useEffect(() => { useEffect(() => {
@ -184,6 +134,8 @@ export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider> <SafeAreaProvider>
<NavigationIndependentTree>
<NavigationContainer>
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}> <SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
<ThemeProvider <ThemeProvider
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light} value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
@ -204,18 +156,6 @@ export default function RootLayout() {
name="proforma/[id]" name="proforma/[id]"
options={{ title: "Proforma request" }} options={{ title: "Proforma request" }}
/> />
<Stack.Screen
name="proforma/edit"
options={{ title: "Edit Proforma" }}
/>
<Stack.Screen
name="invoices/[id]"
options={{ title: "Invoice" }}
/>
<Stack.Screen
name="invoices/edit"
options={{ title: "Edit Invoice" }}
/>
<Stack.Screen <Stack.Screen
name="payments/[id]" name="payments/[id]"
options={{ title: "Payment" }} options={{ title: "Payment" }}
@ -245,6 +185,10 @@ export default function RootLayout() {
name="register" name="register"
options={{ title: "Create account", headerShown: false }} options={{ title: "Create account", headerShown: false }}
/> />
<Stack.Screen
name="invoices/[id]"
options={{ title: "Invoice" }}
/>
<Stack.Screen <Stack.Screen
name="reports/index" name="reports/index"
options={{ title: "Reports" }} options={{ title: "Reports" }}
@ -262,12 +206,13 @@ export default function RootLayout() {
</Stack> </Stack>
<SirouBridge /> <SirouBridge />
<BackupGuard /> <BackupGuard />
<SessionHeartbeat />
<PortalHost /> <PortalHost />
<Toast /> <Toast />
</View> </View>
</ThemeProvider> </ThemeProvider>
</SirouRouterProvider> </SirouRouterProvider>
</NavigationContainer>
</NavigationIndependentTree>
</SafeAreaProvider> </SafeAreaProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );

View File

@ -6,9 +6,8 @@ import {
TextInput, TextInput,
ActivityIndicator, ActivityIndicator,
RefreshControl, RefreshControl,
Image, useColorScheme,
} from "react-native"; } from "react-native";
import { useColorScheme } from "nativewind";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
@ -20,7 +19,6 @@ import { Stack } from "expo-router";
import { useAuthStore } from "@/lib/auth-store"; import { useAuthStore } from "@/lib/auth-store";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { getPlaceholderColor } from "@/lib/colors"; import { getPlaceholderColor } from "@/lib/colors";
import { EmptyState } from "@/components/EmptyState";
import { import {
UserPlus, UserPlus,
Search, Search,
@ -33,7 +31,7 @@ import {
export default function CompanyScreen() { export default function CompanyScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const { colorScheme } = useColorScheme(); const colorScheme = useColorScheme();
const isDark = colorScheme === "dark"; const isDark = colorScheme === "dark";
const [workers, setWorkers] = useState<any[]>([]); const [workers, setWorkers] = useState<any[]>([]);
@ -44,9 +42,7 @@ export default function CompanyScreen() {
const fetchWorkers = async () => { const fetchWorkers = async () => {
try { try {
const response = await api.users.getAll(); const response = await api.users.getAll();
// Handle both direct array and { data: [] } response formats setWorkers(response.data || []);
const data = Array.isArray(response) ? response : response.data || [];
setWorkers(data);
} catch (error) { } catch (error) {
console.error("[CompanyScreen] Error fetching workers:", error); console.error("[CompanyScreen] Error fetching workers:", error);
} finally { } finally {
@ -94,7 +90,7 @@ export default function CompanyScreen() {
{/* Worker List Header */} {/* Worker List Header */}
<View className="flex-row justify-between items-center mb-4"> <View className="flex-row justify-between items-center mb-4">
<Text variant="h4" className="text-foreground tracking-tight"> <Text variant="h4" className="text-foreground tracking-tight">
Workers ({filteredWorkers?.length || 0}) Workers ({filteredWorkers.length})
</Text> </Text>
</View> </View>
@ -119,33 +115,21 @@ export default function CompanyScreen() {
<ShadowWrapper key={worker.id} level="xs"> <ShadowWrapper key={worker.id} level="xs">
<Card className="mb-3 overflow-hidden rounded-[12px] bg-card border-0"> <Card className="mb-3 overflow-hidden rounded-[12px] bg-card border-0">
<CardContent className="flex-row items-center p-4"> <CardContent className="flex-row items-center p-4">
{worker.avatar ? ( <View className="h-12 w-12 rounded-full bg-secondary/50 items-center justify-center mr-4">
<Image
source={{ uri: worker.avatar }}
className="h-12 w-12 rounded-full mr-4"
/>
) : (
<View className="h-12 w-12 rounded-full bg-secondary items-center justify-center mr-4">
<Text className="text-primary font-bold text-lg"> <Text className="text-primary font-bold text-lg">
{worker.firstName?.[0]} {worker.firstName?.[0]}
{worker.lastName?.[0]} {worker.lastName?.[0]}
</Text> </Text>
</View> </View>
)}
<View className="flex-1"> <View className="flex-1">
<Text className="text-foreground font-bold text-base"> <Text className="text-foreground font-bold text-base">
{worker.firstName} {worker.lastName} {worker.firstName} {worker.lastName}
</Text> </Text>
<View className="flex-row items-center mt-1"> <View className="flex-row items-center mt-1">
<Text className="text-muted-foreground text-xs bg-muted px-2 py-0.5 rounded-md uppercase font-bold tracking-widest text-[9px]"> <Text className="text-muted-foreground text-xs bg-secondary px-2 py-0.5 rounded-md uppercase font-bold tracking-widest text-[10px]">
{worker.role?.replace("_", " ") || "WORKER"} {worker.role || "WORKER"}
</Text> </Text>
{worker.email && (
<Text variant="muted" className="text-[10px] ml-2">
{worker.email}
</Text>
)}
</View> </View>
</View> </View>
@ -158,13 +142,16 @@ export default function CompanyScreen() {
</ShadowWrapper> </ShadowWrapper>
)) ))
) : ( ) : (
<EmptyState <View className="py-20 items-center">
title="No workers found" <Briefcase
description="Start by adding your first employee to manage your company." size={48}
hint="Use the + button below to add a new worker" color={isDark ? "#1e293b" : "#f1f5f9"}
actionLabel="Refresh List" strokeWidth={1}
onActionPress={onRefresh}
/> />
<Text variant="muted" className="mt-4">
No workers found
</Text>
</View>
)} )}
</ScrollView> </ScrollView>
)} )}

View File

@ -1,13 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
View,
ScrollView,
ActivityIndicator,
Alert,
Linking,
useColorScheme,
Pressable,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes"; import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router"; import { Stack, useLocalSearchParams } from "expo-router";
@ -19,28 +11,18 @@ import {
Calendar, Calendar,
Share2, Share2,
Download, Download,
Trash2, ArrowLeft,
Package,
Clock,
ExternalLink, ExternalLink,
ChevronRight,
User,
CreditCard,
Hash,
AlertCircle,
} from "@/lib/icons"; } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader"; import { StandardHeader } from "@/components/StandardHeader";
import { api, BASE_URL } from "@/lib/api"; import { api } from "@/lib/api";
import { toast } from "@/lib/toast-store"; import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store";
export default function InvoiceDetailScreen() { export default function InvoiceDetailScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [invoice, setInvoice] = useState<any>(null); const [invoice, setInvoice] = useState<any>(null);
@ -52,11 +34,7 @@ export default function InvoiceDetailScreen() {
const fetchInvoice = async () => { const fetchInvoice = async () => {
try { try {
setLoading(true); setLoading(true);
// Ensure id is a string if useLocalSearchParams returns an array const data = await api.invoices.getById({ params: { id: id as string } });
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); setInvoice(data);
} catch (error: any) { } catch (error: any) {
console.error("[InvoiceDetail] Error:", error); console.error("[InvoiceDetail] Error:", error);
@ -66,51 +44,11 @@ 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) { if (loading) {
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice" showBack /> <StandardHeader title="Invoice Details" showBack />
<View className="flex-1 justify-center items-center"> <View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" /> <ActivityIndicator color="#ea580c" size="large" />
</View> </View>
@ -122,343 +60,220 @@ export default function InvoiceDetailScreen() {
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice" showBack /> <StandardHeader title="Invoice Details" showBack />
<View className="flex-1 justify-center items-center"> <View className="flex-1 justify-center items-center">
<AlertCircle size={48} color="#ef4444" className="mb-4" /> <Text variant="muted">Invoice not found</Text>
<Text variant="h4" className="mb-1">
Invoice Not Found
</Text>
<Text variant="muted">
The requested document could not be retrieved.
</Text>
</View> </View>
</ScreenWrapper> </ScreenWrapper>
); );
} }
// 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 ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader <StandardHeader title="Invoice Details" showBack />
title={"Invoice Detail"}
showBack
rightAction="edit"
onRightActionPress={() => nav.go("invoices/edit", { id: invoice.id })}
/>
<ScrollView <ScrollView
className="flex-1" className="flex-1"
contentContainerStyle={{ paddingBottom: 120 }} contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Modern Hero Area */} {/* Status Hero Card */}
<View className="px-5 pt-4"> <Card className="mb-4 overflow-hidden rounded-[6px] border-0 bg-primary">
<View className="p-5">
<View className="flex-row items-center justify-between mb-3">
<View className="bg-white/20 p-1.5 rounded-[6px]">
<FileText color="white" size={16} strokeWidth={2.5} />
</View>
<View <View
className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`} className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`}
> >
<View className={`w-2 h-2 rounded-full ${colors.dot}`} />
<Text <Text
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`} className={`text-[10px] font-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
> >
{status} {invoice.status || "Pending"}
</Text> </Text>
</View> </View>
<Text
variant="muted"
className="text-xs font-bold uppercase tracking-wider mb-1"
>
Total Payable Amount
</Text>
<View className="flex-row items-end gap-2 mb-6">
<Text variant="h1" className="text-4xl font-black text-foreground">
{Number(amountValue).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</Text>
<Text className="text-xl font-bold text-primary mb-2">
{invoice.currency || "ETB"}
</Text>
</View>
{/* Quick Stats Grid */}
<View className="flex-row gap-3 mb-6">
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Calendar size={16} color="#ea580c" className="mb-2" />
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Issue Date
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(
invoice.issueDate || invoice.createdAt,
).toLocaleDateString()}
</Text>
</View>
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Clock size={16} color="#ef4444" className="mb-2" />
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Due Date
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(invoice.dueDate).toLocaleDateString()}
</Text>
</View>
</View>
</View> </View>
{/* Client Box */} <Text variant="small" className="text-white/70 mb-0.5">
<View className="px-5 mb-6"> Total Amount
<View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10"> </Text>
<View className="flex-row items-center gap-3 mb-4"> <Text variant="h3" className="text-white font-bold mb-3">
<View className="h-10 w-10 rounded-full bg-primary/20 items-center justify-center"> ${Number(invoice.amount).toLocaleString()}
<User color="#ea580c" size={20} /> </Text>
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
<View className="flex-row items-center gap-1.5">
<Calendar color="rgba(255,255,255,0.9)" size={12} />
<Text className="text-white/90 text-xs font-semibold">
Due {new Date(invoice.dueDate).toLocaleDateString()}
</Text>
</View> </View>
<View> <View className="h-3 w-[1px] bg-white/60" />
<Text className="text-white/90 text-xs font-semibold">
#{invoice.invoiceNumber || id}
</Text>
</View>
</View>
</Card>
{/* Recipient & Category — inline info strip */}
<Card className="bg-card rounded-[6px] mb-4">
<View className="flex-row px-4 py-2">
<View className="flex-1 flex-row items-center">
<View className="flex-col">
<Text className="text-foreground text-xs opacity-60">
Recipient
</Text>
<Text <Text
variant="muted" variant="p"
className="text-[10px] uppercase font-bold" className="text-foreground font-semibold"
numberOfLines={1}
> >
Billed To {invoice.customerName || "—"}
</Text>
<Text variant="p" className="text-foreground font-bold text-lg">
{invoice.customerName?.replace("Customer Name: ", "") ||
"Walking Client"}
</Text> </Text>
</View> </View>
</View> </View>
<View className="flex-row flex-wrap gap-4 pt-4 border-t border-primary/10"> <View className="w-[1px] bg-border/70 mx-3" />
{invoice.customerEmail && ( <View className="flex-1 flex-row items-center">
<View className="flex-row items-center gap-2"> <View className="flex-col">
<CreditCard size={12} color="#64748b" /> <Text className="text-foreground text-xs opacity-60">
<Text className="text-muted-foreground text-xs"> Category
{invoice.customerEmail}
</Text> </Text>
</View> <Text
)} variant="p"
<View className="flex-row items-center gap-2"> className="text-foreground font-semibold"
<Hash size={12} color="#64748b" /> numberOfLines={1}
<Text className="text-muted-foreground text-xs"> >
#{invoice.id.split("-")[0]} General
</Text> </Text>
</View> </View>
</View> </View>
</View> </View>
</Card>
{/* Items / Billing Summary */}
<Card className="mb-4 bg-card rounded-[6px]">
<View className="p-4">
<View className="flex-row items-center gap-2 mb-2">
<Text
variant="small"
className="font-bold opacity-60 uppercase text-[10px] tracking-widest"
>
Billing Summary
</Text>
</View> </View>
{/* Detailed Items Table */} <View className="flex-row justify-between py-3 border-b border-border/70">
<View className="px-5 mb-6"> <View className="flex-1 pr-4">
<Text variant="h4" className="font-bold mb-4 px-1"> <Text
Order Summary variant="p"
</Text> className="text-foreground font-semibold text-sm"
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
{items.map((item: any, idx: number) => (
<View
key={idx}
className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}
> >
<View className="flex-row justify-between items-start mb-1"> Subtotal
<Text className="text-foreground font-bold flex-1 mr-4">
{item.description}
</Text> </Text>
<Text className="text-foreground font-black"> </View>
{Number( <Text variant="p" className="text-foreground font-bold text-sm">
item.total?.value || item.total || 0, $
{(
Number(invoice.amount) - (Number(invoice.taxAmount) || 0)
).toLocaleString()} ).toLocaleString()}
</Text> </Text>
</View> </View>
<Text className="text-muted-foreground text-xs">
{item.quantity} units x{" "}
{Number(
item.unitPrice?.value || item.unitPrice || 0,
).toLocaleString()}{" "}
{invoice.currency}
</Text>
</View>
))}
{items.length === 0 && (
<View className="p-8 items-center bg-muted/20">
<Package size={32} color="#cbd5e1" className="mb-2" />
<Text variant="muted">No line items specified</Text>
</View>
)}
</Card>
</View>
{/* Billing Breakdown */} {Number(invoice.taxAmount) > 0 && (
<View className="px-5 mb-6"> <View className="flex-row justify-between py-3 border-b border-border/70">
<Card className="bg-card rounded-[6px] p-5 shadow-sm shadow-black/5 border-border/60"> <View className="flex-1 pr-4">
<View className="flex-row justify-between mb-4"> <Text
<Text className="text-muted-foreground font-medium"> variant="p"
Subtotal className="text-foreground font-semibold text-sm"
</Text> >
<Text className="text-foreground font-bold"> Tax
{subtotalValue.toLocaleString()} {invoice.currency}
</Text> </Text>
</View> </View>
<Text variant="p" className="text-foreground font-bold text-sm">
{taxAmountValue > 0 && ( + ${Number(invoice.taxAmount).toLocaleString()}
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Tax (extracted)
</Text>
<Text className="text-emerald-500 font-bold">
+{taxAmountValue.toLocaleString()} {invoice.currency}
</Text> </Text>
</View> </View>
)} )}
{discountValue > 0 && ( <View className="mt-3 pt-3 flex-row justify-between items-center border-t border-border/70">
<View className="flex-row justify-between mb-4"> <Text variant="muted" className="font-semibold text-sm">
<Text className="text-muted-foreground font-medium"> Total Balance
Discount
</Text>
<Text className="text-rose-500 font-bold">
-{discountValue.toLocaleString()} {invoice.currency}
</Text>
</View>
)}
<View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center">
<View>
<Text className="text-foreground font-black text-xl">
Grand Total
</Text> </Text>
<Text <Text
variant="muted" variant="h3"
className="text-[10px] uppercase font-bold tracking-tighter" className="text-foreground font-semibold text-xl tracking-tight"
> >
Verified from data ${Number(invoice.amount).toLocaleString()}
</Text> </Text>
</View> </View>
<Text className="text-primary font-black text-2xl">
{amountValue.toLocaleString()} {invoice.currency}
</Text>
</View> </View>
</Card> </Card>
</View>
{/* Notes */} {/* Notes Section (New) */}
{invoice.notes && ( {invoice.notes && (
<View className="px-5 mb-10"> <Card className="mb-4 bg-card rounded-[6px]">
<View className="p-4">
<Text <Text
variant="muted" variant="small"
className="text-[10px] uppercase font-bold mb-2" className="font-bold opacity-60 uppercase text-[10px] tracking-widest mb-2"
> >
Note / Description Additional Notes
</Text> </Text>
<Text className="text-foreground font-medium italic opacity-80 leading-5"> <Text
" {invoice.notes} " variant="p"
className="text-foreground font-medium text-xs leading-5"
>
{invoice.notes}
</Text> </Text>
</View> </View>
</Card>
)} )}
{/* Premium Actions */} {/* Timeline Section (New) */}
<View className="px-5 gap-3"> <View className="mt-2 mb-6 px-4 py-3 bg-secondary/20 rounded-[8px] border border-border/30">
<View className="flex-row justify-between mb-1.5">
<Text className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">
Created
</Text>
<Text className="text-[10px] text-foreground font-bold">
{new Date(invoice.createdAt).toLocaleString()}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">
Last Updated
</Text>
<Text className="text-[10px] text-foreground font-bold">
{new Date(invoice.updatedAt).toLocaleString()}
</Text>
</View>
</View>
{/* Actions */}
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<Button <Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20" className=" flex-1 mb-4 h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
onPress={() => onPress={() => {}}
toast.info(
"Coming Soon",
"SMS sharing enabled for matched accounts.",
)
}
> >
<Share2 color="#ffffff" size={18} strokeWidth={2.5} /> <Share2 color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs"> <Text className="ml-2 text-white text-[12px] font-black uppercase tracking-widest">
Scan SMS Share SMS
</Text> </Text>
</Button> </Button>
<ShadowWrapper>
<Button <Button
variant="outline" className=" flex-1 mb-4 h-12 rounded-[10px] bg-card border border-border"
className="flex-1 h-14 rounded-[6px] bg-card border border-border" onPress={() => {}}
onPress={handleGetPdf}
> >
<Download <Download color="#0f172a" size={16} strokeWidth={2.5} />
color={isDark ? "#f1f5f9" : "#0f172a"} <Text className="ml-2 text-foreground text-[12px] font-black uppercase tracking-widest">
size={18}
strokeWidth={2.5}
/>
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
Get PDF Get PDF
</Text> </Text>
</Button> </Button>
</View> </ShadowWrapper>
<Button
variant="ghost"
className="h-14 rounded-[6px] border border-rose-500/10"
onPress={handleDelete}
>
<Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">
Delete Invoice
</Text>
</Button>
</View> </View>
</ScrollView> </ScrollView>
</ScreenWrapper> </ScreenWrapper>

View File

@ -1,659 +0,0 @@
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 (
<View style={flex != null ? { flex } : undefined}>
<Text
variant="small"
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5"
>
{label}
</Text>
<TextInput
style={[
center ? S.inputCenter : S.input,
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
]}
placeholder={placeholder}
placeholderTextColor={c.placeholder}
value={value}
onChangeText={onChangeText}
keyboardType={numeric ? "numeric" : "default"}
/>
</View>
);
}
export default function EditInvoiceScreen() {
const nav = useSirouRouter<AppRoutes>();
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<Item[]>([
{ 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 (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title={isEdit ? "Edit Invoice" : "Create Invoice"}
showBack
/>
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" />
</View>
</ScreenWrapper>
);
}
const currencies = ["ETB", "USD", "EUR", "GBP"];
const invoiceTypes = ["SALES", "PURCHASE", "SERVICE"];
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title={isEdit ? "Edit Invoice" : "Create Invoice"}
showBack
/>
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
>
{/* Invoice Details */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<View className="flex-row items-center gap-3 mb-4">
<View className="bg-primary/10 p-2 rounded-[8px]">
<FileText color="#ea580c" size={16} strokeWidth={2.5} />
</View>
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
Invoice Details
</Text>
</View>
<View className="gap-4">
<Field
label="Invoice Number"
value={invoiceNumber}
onChangeText={setInvoiceNumber}
placeholder="Enter invoice number"
/>
<Field
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Additional notes"
/>
</View>
</View>
</ShadowWrapper>
{/* Customer Details */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
Customer Details
</Text>
<View className="gap-4">
<Field
label="Customer Name"
value={customerName}
onChangeText={setCustomerName}
placeholder="Enter customer name"
/>
<Field
label="Customer Email"
value={customerEmail}
onChangeText={setCustomerEmail}
placeholder="Enter customer email"
/>
<Field
label="Customer Phone"
value={customerPhone}
onChangeText={setCustomerPhone}
placeholder="Enter customer phone"
/>
</View>
</View>
</ShadowWrapper>
{/* Dates */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
Dates
</Text>
<View className="gap-4">
<Pressable
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
onPress={() => setIssueModal(true)}
>
<View className="flex-row items-center gap-2">
<Calendar color="#64748b" size={16} />
<Text className="text-foreground font-medium">
Issue Date: {issueDate.toLocaleDateString()}
</Text>
</View>
<ChevronDown color="#64748b" size={16} />
</Pressable>
<Pressable
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
onPress={() => setDueModal(true)}
>
<View className="flex-row items-center gap-2">
<CalendarSearch color="#64748b" size={16} />
<Text className="text-foreground font-medium">
Due Date: {dueDate.toLocaleDateString()}
</Text>
</View>
<ChevronDown color="#64748b" size={16} />
</Pressable>
</View>
</View>
</ShadowWrapper>
{/* Items */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<View className="flex-row items-center justify-between mb-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
Items
</Text>
<Button
className="h-8 px-3 rounded-[6px] bg-primary"
onPress={addItem}
>
<Plus color="#ffffff" size={14} />
<Text className="ml-1 text-white text-xs font-bold">
Add Item
</Text>
</Button>
</View>
{items.map((item) => (
<View
key={item.id}
className="flex-row items-center gap-3 mb-3 p-3 bg-muted rounded-[6px]"
>
<Field
flex={3}
label="Description"
value={item.description}
onChangeText={(v) => updateItem(item.id, "description", v)}
placeholder="Item description"
/>
<Field
flex={1}
label="Qty"
value={item.qty}
onChangeText={(v) => updateItem(item.id, "qty", v)}
placeholder="0"
numeric
center
/>
<Field
flex={1.5}
label="Price"
value={item.price}
onChangeText={(v) => updateItem(item.id, "price", v)}
placeholder="0.00"
numeric
center
/>
<Pressable
className="mt-4 p-2"
onPress={() => removeItem(item.id)}
>
<Trash2 color="#dc2626" size={16} />
</Pressable>
</View>
))}
</View>
</ShadowWrapper>
{/* Totals */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
Totals
</Text>
<View className="gap-3">
<View className="flex-row justify-between">
<Text className="text-foreground font-medium">Subtotal</Text>
<Text className="text-foreground font-bold">
{currency} {calculateSubtotal().toFixed(2)}
</Text>
</View>
<Field
label="Tax Amount"
value={taxAmount}
onChangeText={setTaxAmount}
placeholder="0.00"
numeric
/>
<Field
label="Discount Amount"
value={discountAmount}
onChangeText={setDiscountAmount}
placeholder="0.00"
numeric
/>
<View className="flex-row justify-between pt-2 border-t border-border">
<Text className="text-foreground font-bold text-lg">Total</Text>
<Text className="text-foreground font-bold text-lg">
{currency} {calculateTotal().toFixed(2)}
</Text>
</View>
</View>
</View>
</ShadowWrapper>
{/* Configuration */}
<ShadowWrapper className="mb-4">
<View className="bg-card rounded-[12px] p-4">
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
Configuration
</Text>
<View className="gap-4">
<View>
<Text
variant="small"
className="font-semibold text-[10px] uppercase mb-1.5 ml-1"
>
Currency
</Text>
<Pressable
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
onPress={() => setCurrencyModal(true)}
>
<Text className="text-foreground font-medium">
{currency}
</Text>
<ChevronDown color="#64748b" size={16} />
</Pressable>
</View>
<View>
<Text
variant="small"
className="font-semibold text-[10px] uppercase mb-1.5 ml-1"
>
Invoice Type
</Text>
<Pressable
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
onPress={() => setTypeModal(true)}
>
<Text className="text-foreground font-medium">{type}</Text>
<ChevronDown color="#64748b" size={16} />
</Pressable>
</View>
</View>
</View>
</ShadowWrapper>
</ScrollView>
{/* Bottom Action */}
<View className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t border-border">
<Button
className="h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
onPress={handleSubmit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<>
<Send color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
{isEdit ? "Update Invoice" : "Create Invoice"}
</Text>
</>
)}
</Button>
</View>
{/* Modals */}
<PickerModal
visible={currencyModal}
title="Select Currency"
onClose={() => setCurrencyModal(false)}
>
{currencies.map((curr) => (
<SelectOption
key={curr}
label={curr}
value={curr}
selected={curr === currency}
onSelect={(v) => {
setCurrency(v);
setCurrencyModal(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={typeModal}
title="Select Invoice Type"
onClose={() => setTypeModal(false)}
>
{invoiceTypes.map((t) => (
<SelectOption
key={t}
label={t}
value={t}
selected={t === type}
onSelect={(v) => {
setType(v);
setTypeModal(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={issueModal}
title="Select Issue Date"
onClose={() => setIssueModal(false)}
>
<CalendarGrid
onSelect={(dateStr: string) => {
setIssueDate(new Date(dateStr));
setIssueModal(false);
}}
selectedDate={issueDate.toISOString().substring(0, 10)}
/>
</PickerModal>
<PickerModal
visible={dueModal}
title="Select Due Date"
onClose={() => setDueModal(false)}
>
<CalendarGrid
onSelect={(dateStr: string) => {
setDueDate(new Date(dateStr));
setDueModal(false);
}}
selectedDate={dueDate.toISOString().substring(0, 10)}
/>
</PickerModal>
</ScreenWrapper>
);
}

View File

@ -12,20 +12,9 @@ import {
} from "react-native"; } from "react-native";
import { useSirouRouter } from "@sirou/react-native"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes"; import { AppRoutes } from "@/lib/routes";
import { useRouter } from "expo-router";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User, Globe } from "@/lib/icons";
Mail,
Lock,
ArrowRight,
Eye,
EyeOff,
Chrome,
User,
Globe,
Phone,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useAuthStore } from "@/lib/auth-store"; import { useAuthStore } from "@/lib/auth-store";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
@ -35,31 +24,21 @@ import { toast } from "@/lib/toast-store";
import { useLanguageStore, AppLanguage } from "@/lib/language-store"; import { useLanguageStore, AppLanguage } from "@/lib/language-store";
import { getPlaceholderColor } from "@/lib/colors"; import { getPlaceholderColor } from "@/lib/colors";
import { LanguageModal } from "@/components/LanguageModal"; import { LanguageModal } from "@/components/LanguageModal";
// Lazy-load Google Sign-In to prevent crash when native module is missing (e.g. Expo Go) import {
let GoogleSignin: any = null; GoogleSignin,
let statusCodes: any = {}; statusCodes,
let googleAvailable = false; } from "@react-native-google-signin/google-signin";
try {
const gsi = require("@react-native-google-signin/google-signin");
GoogleSignin = gsi.GoogleSignin;
statusCodes = gsi.statusCodes;
googleAvailable = true;
GoogleSignin.configure({ GoogleSignin.configure({
webClientId: webClientId:
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", "1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
iosClientId: iosClientId:
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", "1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", // Placeholder: replace with your actual iOS Client ID from Google Cloud Console
offlineAccess: true, offlineAccess: true,
}); });
} catch (e) {
console.warn("[Login] Google Sign-In native module not available:", (e as any).message);
}
export default function LoginScreen() { export default function LoginScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth); const setAuth = useAuthStore((state) => state.setAuth);
const { colorScheme } = useColorScheme(); const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark"; const isDark = colorScheme === "dark";
@ -70,32 +49,8 @@ export default function LoginScreen() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loginMode, setLoginMode] = useState<"email" | "phone">("email");
const handleLogin = async () => { 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) { if (!identifier || !password) {
toast.error( toast.error(
"Required Fields", "Required Fields",
@ -109,19 +64,25 @@ export default function LoginScreen() {
const isEmail = identifier.includes("@"); const isEmail = identifier.includes("@");
const payload = isEmail const payload = isEmail
? { email: identifier, password } ? { email: identifier, password }
: { phone: `+251${identifier}`, password }; : { phone: identifier, password };
try { try {
// Using the new api.auth.login which is powered by simple-api // Using the new api.auth.login which is powered by simple-api
const response = await api.auth.login({ body: payload }); 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[] = []; const permissions: string[] = [];
setAuth(
response.user, // Store user, access token, refresh token, and permissions
response.accessToken, setAuth(response.user, response.accessToken, response.refreshToken, permissions);
response.refreshToken,
permissions,
);
toast.success("Welcome Back!", "You have successfully logged in."); toast.success("Welcome Back!", "You have successfully logged in.");
// Explicitly navigate to home
nav.go("(tabs)"); nav.go("(tabs)");
} catch (err: any) { } catch (err: any) {
toast.error("Login Failed", err.message || "Invalid credentials"); toast.error("Login Failed", err.message || "Invalid credentials");
@ -131,10 +92,6 @@ export default function LoginScreen() {
}; };
const handleGoogleLogin = async () => { const handleGoogleLogin = async () => {
if (!googleAvailable || !GoogleSignin) {
toast.error("Unavailable", "Google Sign-In requires a native build. Please use email/phone login.");
return;
}
try { try {
setLoading(true); setLoading(true);
await GoogleSignin.hasPlayServices(); await GoogleSignin.hasPlayServices();
@ -158,12 +115,7 @@ export default function LoginScreen() {
// const permissions = roleData ? roleData.permissions : []; // const permissions = roleData ? roleData.permissions : [];
const permissions: string[] = []; const permissions: string[] = [];
setAuth( setAuth(response.user, response.accessToken, response.refreshToken, permissions);
response.user,
response.accessToken,
response.refreshToken,
permissions,
);
toast.success("Welcome!", "Signed in with Google."); toast.success("Welcome!", "Signed in with Google.");
nav.go("(tabs)"); nav.go("(tabs)");
} catch (error: any) { } catch (error: any) {
@ -206,7 +158,7 @@ export default function LoginScreen() {
</View> </View>
{/* Logo / Branding */} {/* Logo / Branding */}
<View className="items-center mb-8"> <View className="items-center mb-10">
<Text variant="h2" className="mt-6 font-bold text-foreground"> <Text variant="h2" className="mt-6 font-bold text-foreground">
Login Login
</Text> </Text>
@ -215,69 +167,25 @@ export default function LoginScreen() {
</Text> </Text>
</View> </View>
{/* Login Type Toggle */}
<View className="flex-row bg-card border border-border rounded-xl p-1 mb-6">
<Pressable
onPress={() => {
setLoginMode("email");
setIdentifier("");
}}
className={`flex-1 py-2 rounded-lg items-center ${loginMode === "email" ? "bg-primary" : ""}`}
>
<Text
className={`font-bold text-sm ${loginMode === "email" ? "text-white" : "text-muted-foreground"}`}
>
Email Login
</Text>
</Pressable>
<Pressable
onPress={() => {
setLoginMode("phone");
setIdentifier("");
}}
className={`flex-1 py-2 rounded-lg items-center ${loginMode === "phone" ? "bg-primary" : ""}`}
>
<Text
className={`font-bold text-sm ${loginMode === "phone" ? "text-white" : "text-muted-foreground"}`}
>
Phone Number
</Text>
</Pressable>
</View>
{/* Form */} {/* Form */}
<View className="gap-5"> <View className="gap-5">
<View> <View>
<Text variant="small" className="font-semibold mb-2 ml-1"> <Text variant="small" className="font-semibold mb-2 ml-1">
{loginMode === "email" ? "Email Address" : "Phone Number"} Email or Phone Number
</Text> </Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12"> <View className="flex-row items-center rounded-xl px-4 border border-border h-12">
{loginMode === "email" ? ( <User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
) : (
<View className="flex-row items-center">
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<Text className="ml-2 text-foreground font-bold">+251</Text>
</View>
)}
<TextInput <TextInput
className="flex-1 ml-3 text-foreground" className="flex-1 ml-3 text-foreground"
placeholder={ placeholder="john@example.com or +251..."
loginMode === "email" ? "john@example.com" : "912345678"
}
placeholderTextColor={getPlaceholderColor(isDark)} placeholderTextColor={getPlaceholderColor(isDark)}
value={identifier} value={identifier}
onChangeText={setIdentifier} onChangeText={setIdentifier}
autoCapitalize="none" autoCapitalize="none"
keyboardType={
loginMode === "email" ? "email-address" : "phone-pad"
}
maxLength={loginMode === "phone" ? 9 : undefined}
/> />
</View> </View>
</View> </View>
{loginMode === "email" && (
<View> <View>
<Text variant="small" className="font-semibold mb-2 ml-1"> <Text variant="small" className="font-semibold mb-2 ml-1">
Password Password
@ -294,17 +202,13 @@ export default function LoginScreen() {
/> />
<Pressable onPress={() => setShowPassword(!showPassword)}> <Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? ( {showPassword ? (
<EyeOff <EyeOff size={18} color={isDark ? "#94a3b8" : "#64748b"} />
size={18}
color={isDark ? "#94a3b8" : "#64748b"}
/>
) : ( ) : (
<Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} /> <Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} />
)} )}
</Pressable> </Pressable>
</View> </View>
</View> </View>
)}
<Button <Button
className="h-10 bg-primary rounded-[6px] shadow-lg shadow-primary/30 mt-2" className="h-10 bg-primary rounded-[6px] shadow-lg shadow-primary/30 mt-2"
@ -316,9 +220,7 @@ export default function LoginScreen() {
) : ( ) : (
<> <>
<Text className="text-white font-bold text-base mr-2"> <Text className="text-white font-bold text-base mr-2">
{loginMode === "email" Sign In
? "Sign In"
: "Send Verification Code"}
</Text> </Text>
<ArrowRight color="white" size={18} strokeWidth={2.5} /> <ArrowRight color="white" size={18} strokeWidth={2.5} />
</> </>

View File

@ -1,185 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import {
View,
ScrollView,
TextInput,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { useLocalSearchParams, Stack } from "expo-router";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api } from "@/lib/api";
import { useAuthStore } from "@/lib/auth-store";
import { toast } from "@/lib/toast-store";
import { useColorScheme } from "nativewind";
export default function OtpScreen() {
const nav = useSirouRouter<AppRoutes>();
const { phone, verificationId } = useLocalSearchParams<{
phone: string;
verificationId: string;
}>();
const setAuth = useAuthStore((state) => state.setAuth);
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [code, setCode] = useState(["", "", "", "", "", ""]);
const [loading, setLoading] = useState(false);
const [timer, setTimer] = useState(30);
const inputs = useRef<any[]>([]);
useEffect(() => {
let interval: any;
if (timer > 0) {
interval = setInterval(() => {
setTimer((t) => t - 1);
}, 1000);
}
return () => clearInterval(interval);
}, [timer]);
const handleInputChange = (text: string, index: number) => {
const newCode = [...code];
newCode[index] = text;
setCode(newCode);
// Auto-focus next input
if (text && index < 5) {
inputs.current[index + 1]?.focus();
}
};
const handleKeyDown = (e: any, index: number) => {
if (e.nativeEvent.key === "Backspace" && !code[index] && index > 0) {
inputs.current[index - 1]?.focus();
}
};
const handleVerify = async () => {
const fullCode = code.join("");
if (fullCode.length < 6) {
toast.error("Invalid Code", "Please enter the full 6-digit code");
return;
}
setLoading(true);
try {
const response = await api.auth.verifyOtp({
body: {
phone: phone as string,
code: fullCode,
verificationId: verificationId as string,
},
});
const permissions: string[] = [];
setAuth(
response.user,
response.accessToken,
response.refreshToken,
permissions,
);
toast.success("Welcome!", "Login successful.");
nav.go("(tabs)");
} catch (err: any) {
toast.error(
"Verification Failed",
err.message || "Invalid or expired code",
);
} finally {
setLoading(false);
}
};
const handleResend = async () => {
setLoading(true);
try {
await api.auth.sendOtp({ body: { phone: phone as string } });
toast.success("OTP Sent", "A new verification code has been sent.");
setTimer(30);
} catch (err: any) {
toast.error("Error", "Failed to resend code");
} finally {
setLoading(false);
}
};
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Verification" showBack />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1"
>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 40 }}
>
<View className="items-center mb-8">
<Text variant="h3" className="font-bold text-foreground">
Verify your number
</Text>
<Text variant="muted" className="mt-2 text-center text-sm">
Enter the 6-digit code we sent to{"\n"}
<Text className="text-foreground font-bold">{phone}</Text>
</Text>
</View>
<View className="flex-row justify-between mb-8">
{code.map((digit, i) => (
<TextInput
key={i}
ref={(el) => (inputs.current[i] = el)}
value={digit}
onChangeText={(text) => handleInputChange(text, i)}
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"
placeholderTextColor={isDark ? "#475569" : "#cbd5e1"}
/>
))}
</View>
<Button
className="h-12 bg-primary rounded-xl shadow-lg shadow-primary/30"
onPress={handleVerify}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-white font-bold text-base">
Verify & Continue
</Text>
)}
</Button>
<View className="mt-8 items-center">
{timer > 0 ? (
<Text variant="muted" className="text-sm">
Resend code in{" "}
<Text className="text-primary font-bold">{timer}s</Text>
</Text>
) : (
<Pressable onPress={handleResend}>
<Text className="text-primary font-bold">
Resend Verification Code
</Text>
</Pressable>
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
</ScreenWrapper>
);
}

View File

@ -1,475 +1,130 @@
import React, { useState, useEffect } from "react"; import { View, ScrollView, Pressable } from "react-native";
import { import { useSirouRouter, useSirouParams } from "@sirou/react-native";
View,
ScrollView,
ActivityIndicator,
Alert,
Linking,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes"; import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router"; import { Stack } from "expo-router";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons";
Wallet,
Link2,
Clock,
AlertTriangle,
User,
ShieldCheck,
Building2,
Hash,
CheckCircle2,
Eye,
Trash2,
Network,
AlertCircle,
Info,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; 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() { export default function PaymentDetailScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams(); const { id } = useSirouParams<AppRoutes, "payments/[id]">();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [payment, setPayment] = useState<any>(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 ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Payment Details" showBack />
<View className="flex-1 justify-center items-center"> <View className="px-6 pt-4 flex-row justify-between items-center">
<ActivityIndicator color="#ea580c" size="large" /> <Pressable
<Text onPress={() => nav.back()}
variant="muted" className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
className="mt-4 font-bold uppercase tracking-widest text-[10px]"
> >
Retrieving Transaction... <ArrowLeft color="#0f172a" size={20} />
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
Payment Match
</Text> </Text>
<View className="w-9" />
</View> </View>
</ScreenWrapper>
);
}
if (!payment) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Payment Details" showBack />
<View className="flex-1 justify-center items-center p-6">
<AlertTriangle color="#ef4444" size={48} strokeWidth={1.5} />
<Text variant="h4" className="mt-4 text-foreground font-black">
Transaction Not Found
</Text>
<Text variant="muted" className="mt-2 text-center">
The requested payment record could not be retrieved from the server.
</Text>
</View>
</ScreenWrapper>
);
}
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 (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title={"Payment Details"} showBack />
<ScrollView <ScrollView
className="flex-1" className="flex-1"
contentContainerStyle={{ paddingBottom: 10 }} contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Urgent Alerts */} <Card className=" overflow-hidden rounded-[6px] border-0 bg-primary">
{isFlagged && ( <View className="p-5">
<View className="mx-5 my-4 bg-red-500/10 border border-red-500/20 rounded-[24px] p-5 flex-row items-start"> <View className="flex-row items-center justify-between mb-3">
<View className="bg-red-500/20 p-2 rounded-full mr-4"> <View className="bg-white/20 p-1.5 rounded-[6px]">
<AlertTriangle color="#ef4444" size={20} /> <Wallet color="white" size={18} strokeWidth={2.5} />
</View> </View>
<View className="flex-1"> <View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
<Text className="text-red-600 font-black text-[10px] uppercase tracking-[2px] mb-1"> <Text className={`text-[10px] font-bold text-white`}>
Security Flag ({payment.flagReason || "Audit Needed"}) Pending Match
</Text>
<Text className="text-foreground/80 font-medium text-xs leading-5">
{payment.flagNotes || "System flagged this for manual review."}
</Text> </Text>
</View> </View>
</View> </View>
)}
{/* Hero Section */}
<View className="px-5 pt-2">
{/* Status Badges */}
<View className="flex-row flex-wrap gap-2 mb-6">
<View
className={`px-3 py-1 rounded-full flex-row items-center gap-2 ${payment.invoiceId ? "bg-emerald-500/10" : "bg-amber-500/10"}`}
>
<View
className={`w-2 h-2 rounded-full ${payment.invoiceId ? "bg-emerald-500" : "bg-amber-500"}`}
/>
<Text
className={`text-[10px] font-black uppercase tracking-widest ${payment.invoiceId ? "text-emerald-600" : "text-amber-600"}`}
>
{payment.invoiceId ? "Matched" : "Pending Match"}
</Text>
</View>
{isFailed && ( <Text variant="small" className="text-white/70 mb-0.5">
<View className="bg-red-500/10 px-3 py-1 rounded-full flex-row items-center gap-2"> Received Amount
<AlertCircle size={12} color="#ef4444" /> </Text>
<Text className="text-red-600 text-[10px] font-black uppercase tracking-widest"> <Text variant="h3" className="text-white font-bold mb-3">
Verify Failed $2,000.00
</Text> </Text>
</View>
)}
{isScanned && ( <View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
<View className="bg-primary/10 px-3 py-1 rounded-full flex-row items-center gap-2 border border-primary/20"> <View className="flex-row items-center gap-1.5">
<CheckCircle2 size={12} color="#ea580c" /> <Text className="text-white/90 text-xs font-semibold">
<Text className="text-primary text-[10px] font-black uppercase tracking-widest"> TXN-9982734
Scanned
</Text> </Text>
</View> </View>
)} <View className="h-3 w-[1px] bg-white/60" />
</View> <Text className="text-white/90 text-xs font-semibold">
Telebirr SMS
<Text
variant="muted"
className="text-[10px] font-black uppercase tracking-[3px] mb-2 opacity-60"
>
Total Transaction Amount
</Text>
<View className="flex-row items-end gap-3 mb-8">
<Text
variant="h1"
className="text-4xl font-black text-foreground tracking-tighter"
>
{amountValue.toLocaleString()}
</Text>
<Text className="text-2xl font-black text-primary mb-1">
{payment.currency || "USD"}
</Text> </Text>
</View> </View>
</View>
{/* Core Info Grid */}
<View className="flex-row gap-3 mb-6">
<Card className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
<Building2 size={18} color="#ea580c" className="mb-3" />
<Text
variant="muted"
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
>
Merchant
</Text>
<Text
className="text-foreground font-black text-sm"
numberOfLines={1}
>
{extracted.merchantName ||
payment.merchantName ||
"Unknown Merchant"}
</Text>
</Card> </Card>
<Card className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
<Network size={18} color="#ea580c" className="mb-3" /> {/* Transaction Details */}
<Text
variant="muted" <Text variant="h4" className="text-foreground mt-4 mb-2">
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50" Transaction Details
>
Provider
</Text> </Text>
<Text
className="text-foreground font-black text-sm" <Card className="bg-card rounded-[6px] mb-3">
numberOfLines={1} <View className="p-4">
> <View className="flex-row items-center justify-between">
{extracted.provider || <View className="flex-row items-center gap-2">
payment.paymentMethod || <Clock color="#000" size={13} />
"Direct Payment"} <Text variant="muted" className="text-sm">
Received On
</Text> </Text>
</View>
<Text variant="p" className="text-foreground text-sm">
Sep 11, 2022 · 14:30
</Text>
</View>
<View className="h-[1px] bg-border/70 my-3" />
<View className="flex-row items-center justify-between py-1">
<View className="flex-row items-center gap-2">
<Link2 color="#000" size={13} />
<Text variant="muted" className="text-sm">
Status
</Text>
</View>
<View className="bg-amber-500/10 px-2.5 py-1 rounded-[4px]">
<Text className="text-amber-600 text-xs font-semibold">
Awaiting Link
</Text>
</View>
</View>
</View>
</Card> </Card>
</View>
</View>
{/* Sender / Payer Box */} {/* SMS Message */}
<View className="px-5 mb-8"> <Card className="bg-card rounded-[6px] mb-6">
<View className="bg-card/50 rounded-[6px] p-6 border border-border/40 shadow-sm shadow-black/5"> <View className="p-4">
<View className="flex-row items-center gap-4 mb-5"> <Text variant="muted" className="mb-3 font-semibold">
<View className="h-12 w-12 rounded-full bg-secondary/10 items-center justify-center border border-secondary/20"> Original SMS
<User color={isDark ? "#f1f5f9" : "#0f172a"} size={22} />
</View>
<View>
<Text
variant="muted"
className="text-[9px] uppercase font-black tracking-[2px] mb-0.5"
>
Transaction Origin
</Text> </Text>
<Text className="text-foreground font-black text-lg"> <Text className="text-foreground/70 font-medium leading-6 text-sm">
{payment.senderName || "Payment received from Elnatan Jansen for order #2322 via
(payment.user Telebirr. Amount: $2,000. Ref: B88-22X7."
? `${payment.user.firstName} ${payment.user.lastName}`
: "Business Account")}
</Text> </Text>
</View> </View>
</View> </Card>
<View className="flex-row flex-wrap gap-5 pt-5 border-t border-border/40">
<View className="flex-row items-center gap-2">
<Hash size={14} color="#64748b" />
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
{payment.transactionId || "INTERNAL-TXN"}
</Text>
</View>
<View className="flex-row items-center gap-2">
<Clock size={14} color="#64748b" />
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
{paymentDate.toLocaleString()}
</Text>
</View>
</View>
</View>
</View>
{/* Notes */} {/* Action */}
{payment.notes && (
<View className="px-5 mb-10">
<View className="flex-row items-center gap-2 mb-3">
<Info size={14} color="#64748b" />
<Text
variant="muted"
className="text-[10px] uppercase font-black tracking-widest"
>
Transaction Notes
</Text>
</View>
<View className="bg-muted/5 border border-border/40 rounded-[6px] p-5">
<Text className="text-foreground font-medium italic opacity-70 leading-6">
" {payment.notes} "
</Text>
</View>
</View>
)}
{/* Premium Actions */} <Button className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30">
<View className="px-5 gap-3"> <Link2 color="white" size={18} strokeWidth={2.5} />
<View className="flex-row gap-3"> <Text className=" text-white text-xs font-semibold uppercase tracking-widest">
{scanned?.imageUrl && ( Associate to Invoice
<Button
className="flex-1 h-14 rounded-[6px] bg-secondary shadow-lg shadow-black/10"
onPress={() =>
Linking.openURL(
`${BASE_URL}${scanned.imageUrl.startsWith("/") ? scanned.imageUrl.substring(1) : scanned.imageUrl}`,
)
}
>
<Eye
color={isDark ? "#f1f5f9" : "#0f172a"}
size={18}
strokeWidth={2.5}
/>
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
View Receipt
</Text> </Text>
</Button> </Button>
)}
{!payment.invoiceId && !isFailed && (
<Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
onPress={handleMatch}
disabled={matching}
>
{matching ? (
<ActivityIndicator color="white" />
) : (
<>
<Link2 size={18} color="white" strokeWidth={2.5} />
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
Match Invoice
</Text>
</>
)}
</Button>
)}
</View>
<Button
variant="ghost"
className="h-14 rounded-[6px] border border-red-500/5 mb-10"
onPress={handleDelete}
disabled={deleting}
>
{deleting ? (
<ActivityIndicator color="#ef4444" />
) : (
<>
<Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-red-500 font-bold uppercase tracking-widest text-xs">
Terminate Record
</Text>
</>
)}
</Button>
</View>
</ScrollView> </ScrollView>
</ScreenWrapper> </ScreenWrapper>
); );

View File

@ -1,46 +1,59 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
View,
ScrollView,
ActivityIndicator,
Alert,
Linking,
useColorScheme,
Pressable,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes"; import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router"; import { Stack, useLocalSearchParams } from "expo-router";
import { useRouter } from "expo-router";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { import {
FileText, ArrowLeft,
Calendar, DraftingCompass,
Share2,
Download,
Trash2,
Package,
Clock, Clock,
Send,
ExternalLink, ExternalLink,
ChevronRight, ChevronRight,
User, CheckCircle2,
CreditCard,
Hash,
AlertCircle,
} from "@/lib/icons"; } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader"; import { StandardHeader } from "@/components/StandardHeader";
import { api, BASE_URL } from "@/lib/api"; import { api } from "@/lib/api";
import { toast } from "@/lib/toast-store"; import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-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"
};
export default function ProformaDetailScreen() { export default function ProformaDetailScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const router = useRouter();
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [proforma, setProforma] = useState<any>(null); const [proforma, setProforma] = useState<any>(null);
@ -52,60 +65,17 @@ export default function ProformaDetailScreen() {
const fetchProforma = async () => { const fetchProforma = async () => {
try { try {
setLoading(true); setLoading(true);
// Ensure id is a string if useLocalSearchParams returns an array const data = await api.proforma.getById({ params: { id: id as string } });
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); setProforma(data);
} catch (error: any) { } catch (error: any) {
console.error("[ProformaDetail] Error:", error); console.error("[ProformaDetail] Error:", error);
toast.error("Error", "Failed to load proforma details"); toast.error("Error", "Failed to load proforma details");
setProforma(dummyData); // Use dummy data for testing
} finally { } finally {
setLoading(false); 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) { if (loading) {
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
@ -124,320 +94,234 @@ export default function ProformaDetailScreen() {
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Proforma" showBack /> <StandardHeader title="Proforma" showBack />
<View className="flex-1 justify-center items-center"> <View className="flex-1 justify-center items-center">
<AlertCircle size={48} color="#ef4444" className="mb-4" /> <Text variant="muted">Proforma not found</Text>
<Text variant="h4" className="mb-1">
Proforma Not Found
</Text>
<Text variant="muted">
The requested document could not be retrieved.
</Text>
</View> </View>
</ScreenWrapper> </ScreenWrapper>
); );
} }
const amountValue = Number( const subtotal =
typeof proforma.amount === "object" proforma.items?.reduce(
? proforma.amount.value (acc: number, item: any) => acc + (Number(item.total) || 0),
: proforma.amount, 0,
); ) || 0;
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 ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title={"Proforma Detail"} {/* Header */}
showBack <StandardHeader title="Proforma" showBack />
// rightAction="edit"
// onRightActionPress={() => nav.go("proforma/edit", { id: proforma.id })}
/>
<ScrollView <ScrollView
className="flex-1" className="flex-1"
contentContainerStyle={{ paddingBottom: 40 }} contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Modern Hero Area */} {/* Proforma Info Card */}
<View className="px-5 pt-4"> <Card className="bg-card rounded-[12px] mb-4 border border-border">
<View <View className="p-4">
className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`} <View className="flex-row items-center gap-3 mb-3">
> <View className="bg-primary/10 p-2 rounded-[8px]">
<View className={`w-2 h-2 rounded-full ${colors.dot}`} /> <DraftingCompass color="#ea580c" size={16} strokeWidth={2.5} />
<Text </View>
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`} <Text className="text-foreground font-bold text-sm uppercase tracking-widest">
> Proforma Details
{status}
</Text> </Text>
</View> </View>
<Text <View className="gap-2">
variant="muted" <View className="flex-row justify-between">
className="text-xs font-bold uppercase tracking-wider mb-1" <Text variant="muted" className="text-xs font-medium">Proforma Number</Text>
> <Text className="text-foreground font-semibold text-sm">{proforma.proformaNumber}</Text>
Proforma Estimated Total
</Text>
<View className="flex-row items-end gap-2 mb-6">
<Text variant="h1" className="text-4xl font-black text-foreground">
{amountValue.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</Text>
<Text className="text-xl font-bold text-primary mb-2">
{proforma.currency || "ETB"}
</Text>
</View> </View>
<View className="flex-row justify-between">
{/* Quick Stats Grid */} <Text variant="muted" className="text-xs font-medium">Issued Date</Text>
<View className="flex-row gap-3 mb-6"> <Text className="text-foreground font-semibold text-sm">{new Date(proforma.issueDate).toLocaleDateString()}</Text>
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Calendar size={16} color="#ea580c" className="mb-2" />
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Issue Date
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(
proforma.issueDate || proforma.createdAt,
).toLocaleDateString()}
</Text>
</View> </View>
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40"> <View className="flex-row justify-between">
<Clock size={16} color="#ef4444" className="mb-2" /> <Text variant="muted" className="text-xs font-medium">Due Date</Text>
<Text <Text className="text-foreground font-semibold text-sm">{new Date(proforma.dueDate).toLocaleDateString()}</Text>
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Due Date
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(proforma.dueDate).toLocaleDateString()}
</Text>
</View> </View>
<View className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Currency</Text>
<Text className="text-foreground font-semibold text-sm">{proforma.currency}</Text>
</View> </View>
</View> {proforma.description && (
<View className="mt-2">
{/* Client Box */} <Text variant="muted" className="text-xs font-medium mb-1">Description</Text>
<View className="px-5 mb-6"> <Text className="text-foreground text-sm">{proforma.description}</Text>
<View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10">
<View className="flex-row items-center gap-3 mb-4">
<View className="h-10 w-10 rounded-full bg-primary/20 items-center justify-center">
<User color="#ea580c" size={20} />
</View>
<View>
<Text
variant="muted"
className="text-[10px] uppercase font-bold"
>
Quotation For
</Text>
<Text variant="p" className="text-foreground font-bold text-lg">
{proforma.customerName || "Interested Client"}
</Text>
</View>
</View>
<View className="flex-row flex-wrap gap-4 pt-4 border-t border-primary/10">
{proforma.customerEmail && (
<View className="flex-row items-center gap-2">
<CreditCard size={12} color="#64748b" />
<Text className="text-muted-foreground text-xs">
{proforma.customerEmail}
</Text>
</View> </View>
)} )}
<View className="flex-row items-center gap-2"> </View>
<Hash size={12} color="#64748b" /> </View>
<Text className="text-muted-foreground text-xs"> </Card>
#{proforma.id.split("-")[0]}
{/* Customer Info Card */}
<Card className="bg-card rounded-[12px] mb-4 border border-border">
<View className="p-4">
<View className="flex-row items-center gap-3 mb-3">
<View className="bg-primary/10 p-2 rounded-[8px]">
<CheckCircle2 color="#ea580c" size={16} strokeWidth={2.5} />
</View>
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
Customer Information
</Text> </Text>
</View> </View>
</View>
</View>
</View>
{/* Detailed Items Table */} <View className="gap-2">
<View className="px-5 mb-6"> <View className="flex-row justify-between">
<Text variant="h4" className="font-bold mb-4 px-1"> <Text variant="muted" className="text-xs font-medium">Name</Text>
Estimate Summary <Text className="text-foreground font-semibold text-sm">{proforma.customerName}</Text>
</Text> </View>
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60"> <View className="flex-row justify-between">
{items.map((item: any, idx: number) => ( <Text variant="muted" className="text-xs font-medium">Email</Text>
<View <Text className="text-foreground font-semibold text-sm">{proforma.customerEmail || "N/A"}</Text>
key={idx} </View>
className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`} <View className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Phone</Text>
<Text className="text-foreground font-semibold text-sm">{proforma.customerPhone || "N/A"}</Text>
</View>
</View>
</View>
</Card>
{/* Line Items Card */}
<Card className="bg-card rounded-[6px] mb-4">
<View className="p-4">
<View className="flex-row items-center gap-2 mb-2">
<Text
variant="small"
className="font-bold uppercase tracking-widest text-[10px] opacity-60"
> >
<View className="flex-row justify-between items-start mb-1"> Line Items
<Text className="text-foreground font-bold flex-1 mr-4">
{item.description}
</Text>
<Text className="text-foreground font-black">
{Number(
item.total?.value || item.total || 0,
).toLocaleString()}
</Text> </Text>
</View> </View>
<Text className="text-muted-foreground text-xs">
{item.quantity} units x{" "} {proforma.items?.map((item: any, i: number) => (
{Number( <View
item.unitPrice?.value || item.unitPrice || 0, key={item.id || i}
).toLocaleString()}{" "} className={`flex-row justify-between py-3 ${i < proforma.items.length - 1 ? "border-b border-border/40" : ""}`}
{proforma.currency} >
<View className="flex-1 pr-4">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
>
{item.description}
</Text>
<Text variant="muted" className="text-[10px] mt-0.5">
{item.quantity} × {proforma.currency}{" "}
{Number(item.unitPrice).toLocaleString()}
</Text>
</View>
<Text variant="p" className="text-foreground font-bold text-sm">
{proforma.currency} {Number(item.total).toLocaleString()}
</Text> </Text>
</View> </View>
))} ))}
{items.length === 0 && (
<View className="p-8 items-center bg-muted/20">
<Package size={32} color="#cbd5e1" className="mb-2" />
<Text variant="muted">Empty line items list</Text>
</View>
)}
</Card>
</View>
{/* Billing Breakdown */} <View className="mt-3 pt-3 border-t border-border/40 gap-2">
<View className="px-5 mb-6"> <View className="flex-row justify-between">
<Card className="bg-card rounded-[6px] p-5 shadow-sm shadow-black/5 border-border/60"> <Text
<View className="flex-row justify-between mb-4"> variant="p"
<Text className="text-muted-foreground font-medium"> className="text-foreground font-semibold text-sm"
Net Price >
Subtotal
</Text> </Text>
<Text className="text-foreground font-bold"> <Text variant="p" className="text-foreground font-bold text-sm">
{subtotalValue.toLocaleString()} {proforma.currency} {proforma.currency} {subtotal.toLocaleString()}
</Text> </Text>
</View> </View>
{Number(proforma.taxAmount) > 0 && (
{taxAmountValue > 0 && ( <View className="flex-row justify-between">
<View className="flex-row justify-between mb-4"> <Text
<Text className="text-muted-foreground font-medium"> variant="p"
Estimated Tax className="text-foreground font-semibold text-sm"
</Text> >
<Text className="text-emerald-500 font-bold"> Tax
+{taxAmountValue.toLocaleString()} {proforma.currency}
</Text>
</View>
)}
{discountValue > 0 && (
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Applicable Discount
</Text>
<Text className="text-rose-500 font-bold">
-{discountValue.toLocaleString()} {proforma.currency}
</Text>
</View>
)}
<View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center">
<View>
<Text className="text-foreground font-black text-xl">
Estimated Total
</Text> </Text>
<Text <Text
variant="muted" variant="p"
className="text-[10px] uppercase font-bold tracking-tighter" className="text-foreground font-bold text-sm"
> >
Valid as of today {proforma.currency}{" "}
{Number(proforma.taxAmount).toLocaleString()}
</Text> </Text>
</View> </View>
<Text className="text-primary font-black text-2xl"> )}
{amountValue.toLocaleString()} {proforma.currency} {Number(proforma.discountAmount) > 0 && (
<View className="flex-row justify-between">
<Text
variant="p"
className="text-red-500 font-semibold text-sm"
>
Discount
</Text> </Text>
<Text variant="p" className="text-red-500 font-bold text-sm">
-{proforma.currency}{" "}
{Number(proforma.discountAmount).toLocaleString()}
</Text>
</View>
)}
<View className="flex-row justify-between items-center mt-1">
<Text variant="p" className="text-foreground font-bold">
Total Amount
</Text>
<Text
variant="h4"
className="text-foreground font-bold tracking-tight"
>
{proforma.currency} {Number(proforma.amount).toLocaleString()}
</Text>
</View>
</View>
</View> </View>
</Card> </Card>
</View>
{/* Notes */} {/* Notes Section (New) */}
{proforma.notes && ( {proforma.notes && (
<View className="px-5 mb-10"> <Card className="bg-card rounded-[6px] mb-4">
<View className="p-4">
<Text <Text
variant="muted" variant="small"
className="text-[10px] uppercase font-bold mb-2" className="font-bold uppercase tracking-widest text-[10px] opacity-60 mb-2"
> >
Internal Notes Additional Notes
</Text> </Text>
<Text className="text-foreground font-medium italic opacity-80 leading-5"> <Text
" {proforma.notes} " variant="p"
className="text-foreground font-medium text-xs leading-5"
>
{proforma.notes}
</Text> </Text>
</View> </View>
</Card>
)} )}
{/* Premium Actions */} {/* Actions */}
<View className="px-5 gap-3"> <View className="gap-3">
<View className="flex-row gap-3">
<Button <Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20" className="h-12 rounded-[10px] bg-transparent border border-border"
onPress={() => nav.go("proforma/edit", { id: proforma.id })} onPress={() => router.push("/proforma/edit?id=" + proforma.id)}
> >
<Share2 color="#ffffff" size={18} strokeWidth={2.5} /> <DraftingCompass color="#fff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs"> <Text className="ml-2 text-foreground font-black text-[12px] uppercase tracking-widest">
Edit Detail Edit
</Text> </Text>
</Button> </Button>
<Button <Button
variant="outline" className="h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
className="flex-1 h-14 rounded-[6px] bg-card border border-border" onPress={() => {}}
onPress={handleGetPdf}
> >
<Download <Send color="#ffffff" size={16} strokeWidth={2.5} />
color={isDark ? "#f1f5f9" : "#0f172a"} <Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
size={18} Share SMS
strokeWidth={2.5}
/>
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
Export PDF
</Text> </Text>
</Button> </Button>
</View>
<Button
variant="ghost"
className="h-14 rounded-[6px] border border-rose-500/10"
onPress={handleDelete}
>
<Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">
Delete Proforma
</Text>
</Button>
</View> </View>
</ScrollView> </ScrollView>
</ScreenWrapper> </ScreenWrapper>

View File

@ -7,7 +7,6 @@ import {
StyleSheet, StyleSheet,
ActivityIndicator, ActivityIndicator,
} from "react-native"; } from "react-native";
import { useColorScheme } from "nativewind";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -35,26 +34,6 @@ import { getPlaceholderColor } from "@/lib/colors";
type Item = { id: number; description: string; qty: string; price: string }; 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({ const S = StyleSheet.create({
input: { input: {
height: 44, height: 44,
@ -76,12 +55,12 @@ const S = StyleSheet.create({
}); });
function useInputColors() { function useInputColors() {
const { colorScheme } = useColorScheme(); const { colorScheme } = useColorScheme(); // Fix usage
const isDark = colorScheme === "dark"; const dark = colorScheme === "dark";
return { return {
bg: isDark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)", bg: dark ? "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)", border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
text: isDark ? "#f1f5f9" : "#0f172a", text: dark ? "#f1f5f9" : "#0f172a",
placeholder: "rgba(100,116,139,0.45)", placeholder: "rgba(100,116,139,0.45)",
}; };
} }
@ -104,6 +83,7 @@ function Field({
flex?: number; flex?: number;
}) { }) {
const c = useInputColors(); const c = useInputColors();
const isDark = colorScheme.get() === "dark";
return ( return (
<View style={flex != null ? { flex } : undefined}> <View style={flex != null ? { flex } : undefined}>
<Text <Text
@ -180,20 +160,8 @@ export default function EditProformaScreen() {
setCurrency(data.currency || "USD"); setCurrency(data.currency || "USD");
setDescription(data.description || ""); setDescription(data.description || "");
setNotes(data.notes || ""); setNotes(data.notes || "");
setTaxAmount( setTaxAmount(String(data.taxAmount?.value || data.taxAmount || ""));
String( setDiscountAmount(String(data.discountAmount?.value || data.discountAmount || ""));
typeof data.taxAmount === "object"
? data.taxAmount?.value
: data.taxAmount || "",
),
);
setDiscountAmount(
String(
typeof data.discountAmount === "object"
? data.discountAmount?.value
: data.discountAmount || "",
),
);
setIssueDate(new Date(data.issueDate)); setIssueDate(new Date(data.issueDate));
setDueDate(new Date(data.dueDate)); setDueDate(new Date(data.dueDate));
setItems( setItems(
@ -202,7 +170,7 @@ export default function EditProformaScreen() {
description: item.description || "", description: item.description || "",
qty: String(item.quantity || ""), qty: String(item.quantity || ""),
price: String(item.unitPrice?.value || item.unitPrice || ""), price: String(item.unitPrice?.value || item.unitPrice || ""),
})) || [{ id: 1, description: "", qty: "", price: "" }], })) || [{ id: 1, description: "", qty: "", price: "" }]
); );
} catch (error) { } catch (error) {
toast.error("Error", "Failed to load proforma, using test data"); toast.error("Error", "Failed to load proforma, using test data");
@ -214,14 +182,8 @@ export default function EditProformaScreen() {
setCurrency(dummyData.currency); setCurrency(dummyData.currency);
setDescription(dummyData.description); setDescription(dummyData.description);
setNotes(dummyData.notes); setNotes(dummyData.notes);
setTaxAmount( setTaxAmount(String(dummyData.taxAmount?.value || dummyData.taxAmount || ""));
String(dummyData.taxAmount?.value || dummyData.taxAmount || ""), setDiscountAmount(String(dummyData.discountAmount?.value || dummyData.discountAmount || ""));
);
setDiscountAmount(
String(
dummyData.discountAmount?.value || dummyData.discountAmount || "",
),
);
setIssueDate(new Date(dummyData.issueDate)); setIssueDate(new Date(dummyData.issueDate));
setDueDate(new Date(dummyData.dueDate)); setDueDate(new Date(dummyData.dueDate));
setItems( setItems(
@ -230,7 +192,7 @@ export default function EditProformaScreen() {
description: item.description || "", description: item.description || "",
qty: String(item.quantity || ""), qty: String(item.quantity || ""),
price: String(item.unitPrice?.value || item.unitPrice || ""), price: String(item.unitPrice?.value || item.unitPrice || ""),
})) || [{ id: 1, description: "", qty: "", price: "" }], })) || [{ id: 1, description: "", qty: "", price: "" }]
); );
} finally { } finally {
setLoading(false); setLoading(false);
@ -249,7 +211,9 @@ export default function EditProformaScreen() {
}; };
const updateItem = (id: number, field: keyof Item, value: string) => { const updateItem = (id: number, field: keyof Item, value: string) => {
setItems(items.map((i) => (i.id === id ? { ...i, [field]: value } : i))); setItems(
items.map((i) => (i.id === id ? { ...i, [field]: value } : i))
);
}; };
const calculateSubtotal = () => { const calculateSubtotal = () => {
@ -320,10 +284,7 @@ export default function EditProformaScreen() {
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader <StandardHeader title={isEdit ? "Edit Proforma" : "Create Proforma"} showBack />
title={isEdit ? "Edit Proforma" : "Create Proforma"}
showBack
/>
<View className="flex-1 justify-center items-center"> <View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" /> <ActivityIndicator color="#ea580c" size="large" />
</View> </View>
@ -336,10 +297,9 @@ export default function EditProformaScreen() {
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title={isEdit ? "Edit Proforma" : "Create Proforma"} <StandardHeader title={isEdit ? "Edit Proforma" : "Create Proforma"} showBack />
showBack
/>
<ScrollView <ScrollView
className="flex-1" className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 150 }} contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
@ -454,9 +414,7 @@ export default function EditProformaScreen() {
onPress={addItem} onPress={addItem}
> >
<Plus color="#ffffff" size={14} /> <Plus color="#ffffff" size={14} />
<Text className="ml-1 text-white text-xs font-bold"> <Text className="ml-1 text-white text-xs font-bold">Add Item</Text>
Add Item
</Text>
</Button> </Button>
</View> </View>
@ -559,6 +517,7 @@ export default function EditProformaScreen() {
</View> </View>
</ShadowWrapper> </ShadowWrapper>
</ScrollView> </ScrollView>
{/* Bottom Action */} {/* Bottom Action */}
<View className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t border-border"> <View className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t border-border">
<Button <Button
@ -578,6 +537,7 @@ export default function EditProformaScreen() {
)} )}
</Button> </Button>
</View> </View>
{/* Modals */} {/* Modals */}
<PickerModal <PickerModal
visible={currencyModal} visible={currencyModal}
@ -597,33 +557,28 @@ export default function EditProformaScreen() {
/> />
))} ))}
</PickerModal> </PickerModal>
<PickerModal
visible={issueModal} // @ts-ignore
title="Select Issue Date"
onClose={() => setIssueModal(false)}
>
<CalendarGrid <CalendarGrid
onSelect={(dateStr: string) => { open={issueModal}
current={issueDate.toISOString().substring(0,10)}
onDateSelect={(dateStr: string) => {
setIssueDate(new Date(dateStr)); setIssueDate(new Date(dateStr));
setIssueModal(false); setIssueModal(false);
}} }}
selectedDate={issueDate.toISOString().substring(0, 10)} onClose={() => setIssueModal(false)}
/> />
</PickerModal>
<PickerModal // @ts-ignore
visible={dueModal}
title="Select Due Date"
onClose={() => setDueModal(false)}
>
<CalendarGrid <CalendarGrid
onSelect={(dateStr: string) => { open={dueModal}
current={dueDate.toISOString().substring(0,10)}
onDateSelect={(dateStr: string) => {
setDueDate(new Date(dateStr)); setDueDate(new Date(dateStr));
setDueModal(false); setDueModal(false);
}} }}
selectedDate={dueDate.toISOString().substring(0, 10)} onClose={() => setDueModal(false)}
/> />
</PickerModal>
</ScreenWrapper> </ScreenWrapper>
); );
} }

View File

@ -1,12 +1,7 @@
import { View, Image, Pressable, useColorScheme } from "react-native"; import { View, Image, Pressable, useColorScheme } from "react-native";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { import { ArrowLeft, Bell, Settings, Info } from "@/lib/icons";
ArrowLeft, import { ShadowWrapper } from "@/components/ShadowWrapper";
Bell,
Settings,
Info,
DraftingCompass as EditIcon,
} from "@/lib/icons";
import { useAuthStore } from "@/lib/auth-store"; import { useAuthStore } from "@/lib/auth-store";
import { useSirouRouter } from "@sirou/react-native"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes"; import { AppRoutes } from "@/lib/routes";
@ -14,15 +9,13 @@ import { AppRoutes } from "@/lib/routes";
interface StandardHeaderProps { interface StandardHeaderProps {
title?: string; title?: string;
showBack?: boolean; showBack?: boolean;
rightAction?: "notificationsSettings" | "companyInfo" | "edit"; rightAction?: "notificationsSettings" | "companyInfo";
onRightActionPress?: () => void;
} }
export function StandardHeader({ export function StandardHeader({
title, title,
showBack, showBack,
rightAction, rightAction,
onRightActionPress,
}: StandardHeaderProps) { }: StandardHeaderProps) {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
@ -36,8 +29,6 @@ export function StandardHeader({
encodeURIComponent(`${user?.firstName} ${user?.lastName}`) + encodeURIComponent(`${user?.firstName} ${user?.lastName}`) +
"&background=ea580c&color=fff"; "&background=ea580c&color=fff";
const iconColor = isDark ? "#f1f5f9" : "#0f172a";
return ( return (
<View className="px-5 pt-4 pb-2 flex-row justify-between items-center bg-background"> <View className="px-5 pt-4 pb-2 flex-row justify-between items-center bg-background">
<View className="flex-1 flex-row items-center gap-3"> <View className="flex-1 flex-row items-center gap-3">
@ -46,7 +37,7 @@ export function StandardHeader({
onPress={() => nav.back()} onPress={() => nav.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
> >
<ArrowLeft color={iconColor} size={20} /> <ArrowLeft color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
</Pressable> </Pressable>
)} )}
@ -86,7 +77,11 @@ export function StandardHeader({
className="rounded-full p-2.5 border border-border" className="rounded-full p-2.5 border border-border"
onPress={() => nav.go("notifications/index")} onPress={() => nav.go("notifications/index")}
> >
<Bell color={iconColor} size={20} strokeWidth={2} /> <Bell
color={isDark ? "#f1f5f9" : "#0f172a"}
size={20}
strokeWidth={2}
/>
</Pressable> </Pressable>
)} )}
@ -95,31 +90,16 @@ export function StandardHeader({
{rightAction === "notificationsSettings" ? ( {rightAction === "notificationsSettings" ? (
<Pressable <Pressable
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
onPress={() => onPress={() => nav.go("notifications/settings")}
onRightActionPress
? onRightActionPress()
: nav.go("notifications/settings")
}
> >
<Settings color={iconColor} size={18} /> <Settings color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
</Pressable> </Pressable>
) : rightAction === "companyInfo" ? ( ) : rightAction === "companyInfo" ? (
<Pressable <Pressable
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
onPress={() => onPress={() => nav.go("company-details")}
onRightActionPress
? onRightActionPress()
: nav.go("company-details")
}
> >
<Info color={iconColor} size={18} /> <Info color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
</Pressable>
) : rightAction === "edit" ? (
<Pressable
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
onPress={onRightActionPress}
>
<EditIcon color={iconColor} size={18} />
</Pressable> </Pressable>
) : ( ) : (
<View className="w-0" /> <View className="w-0" />

View File

@ -1,83 +1,21 @@
import { Middleware } from "@simple-api/core"; import { Middleware } from "@simple-api/core";
import { useAuthStore } from "./auth-store"; import { useAuthStore } from "./auth-store";
import { toast } from "./toast-store";
/**
* Decode base64url string (used for JWT payloads)
* React Native does not have a global Buffer, so we use a simple decoder.
*/
function decodeBase64(str: string) {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
let out = "";
str = str.replace(/[-_]/g, (m) => ({ "-": "+", _: "/" })[m] || m);
while (str.length % 4) str += "=";
for (let i = 0; i < str.length; i += 4) {
const a = chars.indexOf(str.charAt(i));
const b = chars.indexOf(str.charAt(i + 1));
const c = chars.indexOf(str.charAt(i + 2));
const d = chars.indexOf(str.charAt(i + 3));
out += String.fromCharCode((a << 2) | (b >> 4));
if (c !== 64) out += String.fromCharCode(((b & 15) << 4) | (c >> 2));
if (d !== 64) out += String.fromCharCode(((c & 3) << 6) | d);
}
return out;
}
/**
* Extract payload from a JWT token
*/
function decodeJwtPayload(token: string) {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const json = decodeBase64(parts[1]);
return JSON.parse(json);
} catch (e) {
return null;
}
}
/**
* Check if a token is expired or near expiry
*/
function isTokenExpired(token: string | null, bufferSeconds = 30) {
if (!token) return true;
const payload = decodeJwtPayload(token);
if (!payload || !payload.exp) return true;
const expiresAtMs = payload.exp * 1000;
return Date.now() + bufferSeconds * 1000 >= expiresAtMs;
}
/** /**
* Middleware to inject the authentication token into requests. * Middleware to inject the authentication token into requests.
* Now proactively refreshes token if it's about to expire. * Skips login, register, and refresh endpoints.
*/ */
export const authMiddleware: Middleware = async ({ config, options }, next) => { export const authMiddleware: Middleware = async ({ config, options }, next) => {
let { token } = useAuthStore.getState(); const { token } = useAuthStore.getState();
// Don't send Authorization header for sensitive auth-related endpoints,
// EXCEPT for logout which needs to identify the session.
const isAuthPath = const isAuthPath =
config.path === "auth/login" || config.path === "auth/login" ||
config.path === "auth/register" || config.path === "auth/register" ||
config.path === "auth/refresh"; config.path === "auth/refresh";
if (token && !isAuthPath) { if (token && !isAuthPath) {
// Proactive Expiration Check
if (isTokenExpired(token)) {
console.log(
`[AuthProactive] Token near expiry or invalid. Refreshing proactively...`,
);
const newToken = await refreshTokens({
path: config.path,
type: "PROACTIVE_REFRESH",
});
if (newToken) {
token = newToken;
}
}
options.headers = { options.headers = {
...options.headers, ...options.headers,
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@ -87,94 +25,8 @@ export const authMiddleware: Middleware = async ({ config, options }, next) => {
return await next(options); return await next(options);
}; };
// Global promise to handle concurrent refreshes
let refreshPromise: Promise<string | null> | null = null;
/**
* Proactively refreshes the access token using the current refresh token.
* Coordinates multiple concurrent calls to ensure only one network request is made.
*/
export async function refreshTokens(
config?: any,
force = false,
): Promise<string | null> {
const { refreshToken, token, setTokens, logout, isAuthenticated } =
useAuthStore.getState();
if (!isAuthenticated || !refreshToken) {
return null;
}
// If not forced, check if we actually need a refresh
if (!force && !isTokenExpired(token)) {
return token;
}
// Coordination: If a refresh is already in progress, wait for it
if (refreshPromise) {
return await refreshPromise;
}
// No refresh in progress, start one
refreshPromise = (async () => {
try {
const refreshUrl = `https://api.yaltopiaticket.com/auth/refresh`;
console.log("[AuthRefresh] Starting network refresh...");
const response = await fetch(refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refreshToken }),
});
if (response.status === 401) {
console.error(
`[AuthRefresh] 401 on refresh. Session definitively dead. Path: ${config?.path || "Heartbeat"}`,
);
toast.show({
type: "error",
title: "Session Expired",
message:
"You have been logged out because your session has expired. Please sign in again. 🛡️",
duration: 9000,
});
logout();
return null;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `Refresh failed: ${response.status}`,
);
}
const data = await response.json();
const newAT = data.accessToken || data.access_token;
const newRT = data.refreshToken || data.refresh_token;
if (!newAT) throw new Error("No access token in response");
setTokens(newAT, newRT);
console.log("[AuthRefresh] Tokens successfully updated.");
return newAT;
} catch (err: any) {
console.error("[AuthRefresh] Refresh failed:", err.message);
return null;
} finally {
refreshPromise = null;
}
})();
return await refreshPromise;
}
/** /**
* Middleware to handle token refreshment on 401 Unauthorized errors. * Middleware to handle token refreshment on 401 Unauthorized errors.
* Includes a queue mechanism to handle concurrent 401s.
*/ */
export const refreshMiddleware: Middleware = async ( export const refreshMiddleware: Middleware = async (
{ config, options }, { config, options },
@ -184,35 +36,81 @@ export const refreshMiddleware: Middleware = async (
return await next(options); return await next(options);
} catch (error: any) { } catch (error: any) {
const status = error.status || error.statusCode; const status = error.status || error.statusCode;
const { refreshToken } = useAuthStore.getState(); const { refreshToken, setAuth, logout } = useAuthStore.getState();
// Skip refresh logic for the login/refresh endpoints themselves
const isAuthPath = const isAuthPath =
config.path?.includes("auth/login") || config.path?.includes("auth/login") ||
config.path?.includes("auth/refresh"); config.path?.includes("auth/refresh");
// Force refresh on 401 even if we think it's fresh (since server says it's not) if (status === 401 && refreshToken && !isAuthPath) {
if (status === 401 && !isAuthPath) {
if (refreshToken) {
console.log( console.log(
`[API Refresh] 401 detected for ${config.path}. Forcing refresh...`, `[API Refresh] 401 detected for ${config.path}. Attempting refresh...`,
); );
try {
const accessToken = await refreshTokens(config, true);
if (!accessToken) { try {
throw new Error("Failed to obtain fresh access token"); // We call the refresh endpoint manually here to avoid circular dependencies with the 'api' object
const refreshUrl = `${config.baseUrl}auth/refresh`;
const response = await fetch(refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
refreshToken,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const refreshErr = new Error(
errorData.message ||
`Refresh failed with status ${response.status}`,
) as any;
refreshErr.status = response.status;
throw refreshErr;
} }
// Retry the original request const data = await response.json();
console.log(`[API Refresh] Retrying ${config.path} with new token.`);
// Backend might return snake_case (access_token) or camelCase (accessToken)
// We handle both to be safe when using raw fetch
const accessToken = data.accessToken || data.access_token;
const newRefreshToken = data.refreshToken || data.refresh_token;
const user = data.user;
if (!accessToken) {
throw new Error("No access token returned from refresh");
}
setAuth(user, accessToken, newRefreshToken);
console.log("[API Refresh] Success. Retrying original request...");
// Update headers and retry
options.headers = { options.headers = {
...options.headers, ...options.headers,
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}; };
return await next(options); return await next(options);
} catch (refreshError: any) { } catch (refreshError: any) {
throw refreshError; // Only logout if the refresh token itself is invalid (400, 401, 403)
// If it's a network error, we should NOT logout the user.
const refreshStatus = refreshError.status || refreshError.statusCode;
const isAuthError = refreshStatus === 401;
if (isAuthError) {
console.error("[API Refresh] Invalid refresh token. Logging out.");
logout();
} else {
console.error(
"[API Refresh] Network error or server issues during refresh. Staying logged in.",
);
} }
throw refreshError;
} }
} }

View File

@ -43,8 +43,6 @@ export const api = createApi({
logout: { method: "POST", path: "auth/logout" }, logout: { method: "POST", path: "auth/logout" },
profile: { method: "GET", path: "auth/profile" }, profile: { method: "GET", path: "auth/profile" },
googleMobile: { method: "POST", path: "auth/google/mobile" }, googleMobile: { method: "POST", path: "auth/google/mobile" },
sendOtp: { method: "POST", path: "auth/phone/otp/send" },
verifyOtp: { method: "POST", path: "auth/phone/otp/verify" },
}, },
}, },
invoices: { invoices: {
@ -53,10 +51,6 @@ export const api = createApi({
stats: { method: "GET", path: "invoices/stats" }, stats: { method: "GET", path: "invoices/stats" },
getAll: { method: "GET", path: "invoices" }, getAll: { method: "GET", path: "invoices" },
getById: { method: "GET", path: "invoices/:id" }, getById: { method: "GET", path: "invoices/:id" },
create: { method: "POST", path: "invoices" },
update: { method: "PUT", path: "invoices/:id" },
delete: { method: "DELETE", path: "invoices/:id" },
getPdf: { method: "GET", path: "invoices/:id/pdf" },
}, },
}, },
users: { users: {
@ -84,9 +78,6 @@ export const api = createApi({
middleware: [authMiddleware], middleware: [authMiddleware],
endpoints: { endpoints: {
getAll: { method: "GET", path: "payments" }, getAll: { method: "GET", path: "payments" },
getById: { method: "GET", path: "payments/:id" },
associate: { method: "POST", path: "payments/:id/associate" },
delete: { method: "DELETE", path: "payments/:id" },
}, },
}, },
paymentRequests: { paymentRequests: {
@ -102,8 +93,6 @@ export const api = createApi({
getById: { method: "GET", path: "proforma/:id" }, getById: { method: "GET", path: "proforma/:id" },
create: { method: "POST", path: "proforma" }, create: { method: "POST", path: "proforma" },
update: { method: "PUT", path: "proforma/:id" }, update: { method: "PUT", path: "proforma/:id" },
delete: { method: "DELETE", path: "proforma/:id" },
getPdf: { method: "GET", path: "proforma/:id/pdf" },
}, },
}, },
rbac: { rbac: {

View File

@ -19,7 +19,7 @@ export const authGuard: RouteGuard = {
console.log(`[AUTH_GUARD] DENIED -> redirect /login`); console.log(`[AUTH_GUARD] DENIED -> redirect /login`);
return { return {
allowed: false, allowed: false,
redirect: "/login", redirect: "login", // Use name, not path
}; };
} }
@ -37,7 +37,7 @@ export const guestGuard: RouteGuard = {
console.log(`[GUEST_GUARD] Authenticated user blocked -> redirect /`); console.log(`[GUEST_GUARD] Authenticated user blocked -> redirect /`);
return { return {
allowed: false, allowed: false,
redirect: "/", redirect: "(tabs)", // Redirect to home if already logged in
}; };
} }

View File

@ -27,58 +27,37 @@ interface AuthState {
refreshToken: string | null; refreshToken: string | null;
permissions: string[]; permissions: string[];
isAuthenticated: boolean; isAuthenticated: boolean;
setAuth: ( setAuth: (user: User, token: string, refreshToken?: string, permissions?: string[]) => void;
user: User,
token: string,
refreshToken?: string,
permissions?: string[],
) => void;
setTokens: (token: string, refreshToken?: string) => void;
logout: () => Promise<void>; logout: () => Promise<void>;
updateUser: (user: Partial<User>) => void; updateUser: (user: Partial<User>) => void;
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set, get) => ({ (set) => ({
user: null, user: null,
token: null, token: null,
refreshToken: null, refreshToken: null,
permissions: [], permissions: [],
isAuthenticated: false, isAuthenticated: false,
setAuth: (user, token, refreshToken = undefined, permissions = []) => { setAuth: (user, token, refreshToken = undefined, permissions = []) => {
const state = get();
console.log("[AuthStore] Setting auth state:", { console.log("[AuthStore] Setting auth state:", {
hasUser: !!user, hasUser: !!user,
hasToken: !!token, hasToken: !!token,
hasRefreshToken: !!refreshToken, hasRefreshToken: !!refreshToken,
permissionsCount: permissions?.length || 0, permissions,
}); });
set({ set({
user, user,
token, token,
refreshToken: refreshToken ?? state.refreshToken, refreshToken: refreshToken ?? null,
permissions: permissions,
permissions && permissions.length > 0
? permissions
: state.permissions,
isAuthenticated: true, isAuthenticated: true,
}); });
}, },
setTokens: (token, refreshToken) => {
console.log("[AuthStore] Updating tokens surgically:", {
hasToken: !!token,
hasRefreshToken: !!refreshToken,
});
set((state) => ({
token,
refreshToken: refreshToken ?? state.refreshToken,
isAuthenticated: true,
}));
},
logout: async () => { logout: async () => {
console.log("[AuthStore] Logging out..."); console.log("[AuthStore] Logging out...");
const { isAuthenticated, token } = get(); const { isAuthenticated, token } = useAuthStore.getState();
if (isAuthenticated && token) { if (isAuthenticated && token) {
try { try {

View File

@ -24,7 +24,6 @@ export {
ArrowLeft, ArrowLeft,
MoreVertical, MoreVertical,
AlertCircle, AlertCircle,
Package,
DollarSign, DollarSign,
Mail, Mail,
Globe, Globe,
@ -73,6 +72,4 @@ export {
ChevronDown, ChevronDown,
CalendarSearch, CalendarSearch,
Search, Search,
Network,
Terminal,
} from "lucide-react-native"; } from "lucide-react-native";

View File

@ -72,7 +72,6 @@ export const routes = defineRoutes({
}, },
"proforma/edit": { "proforma/edit": {
path: "/proforma/edit", path: "/proforma/edit",
params: { id: "string" },
guards: ["auth"], guards: ["auth"],
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
@ -93,12 +92,6 @@ export const routes = defineRoutes({
guards: ["auth"], guards: ["auth"],
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
"invoices/edit": {
path: "/invoices/edit",
params: { id: "string" },
guards: ["auth"],
meta: { requiresAuth: true },
},
"notifications/index": { "notifications/index": {
path: "/notifications/index", path: "/notifications/index",
guards: ["auth"], guards: ["auth"],
@ -160,12 +153,6 @@ export const routes = defineRoutes({
guards: ["guest"], guards: ["guest"],
meta: { requiresAuth: false, guestOnly: true }, meta: { requiresAuth: false, guestOnly: true },
}, },
otp: {
path: "/otp",
params: { phone: "string", verificationId: "string" },
guards: ["guest"],
meta: { requiresAuth: false, guestOnly: true },
},
register: { register: {
path: "/register", path: "/register",
guards: ["guest"], guards: ["guest"],

View File

@ -35,12 +35,6 @@ export const useToast = create<ToastState>((set) => ({
})); }));
export const toast = { export const toast = {
show: (params: {
type: ToastType;
title: string;
message: string;
duration?: number;
}) => useToast.getState().show(params),
success: (title: string, message: string) => success: (title: string, message: string) =>
useToast.getState().show({ type: "success", title, message }), useToast.getState().show({ type: "success", title, message }),
error: (title: string, message: string) => error: (title: string, message: string) =>