Compare commits

..

2 Commits

Author SHA1 Message Date
db5ac60987 expo eject before 2026-05-13 15:21:17 +03:00
6185cdc4d3 before locale 2026-03-17 09:57:10 +03:00
20 changed files with 2871 additions and 938 deletions

View File

@ -22,7 +22,7 @@ import { ShadowWrapper } from "@/components/ShadowWrapper";
import { useAuthStore } from "@/lib/auth-store";
const { width } = Dimensions.get("window");
const LATEST_CARD_WIDTH = width * 0.8;
const LATEST_CARD_WIDTH = width * 0.6;
interface NewsItem {
id: string;
@ -36,7 +36,9 @@ interface NewsItem {
export default function NewsScreen() {
const nav = useSirouRouter<AppRoutes>();
const permissions = useAuthStore((s: { permissions: string[] }) => s.permissions);
const permissions = useAuthStore(
(s: { permissions: string[] }) => s.permissions,
);
// Safe accessor to handle initialization race conditions
const getNewsApi = () => {
@ -136,88 +138,84 @@ export default function NewsScreen() {
const LatestItem = ({ item }: { item: NewsItem }) => (
<Pressable className="mr-4" key={item.id}>
<ShadowWrapper level="md">
<Card
className="overflow-hidden rounded-[20px] bg-card border-border/50"
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
>
<View className="p-5 flex-1 justify-between">
<View>
<View className="flex-row items-center gap-2 mb-2">
<View
className={`px-2 py-0.5 rounded-full ${getCategoryColor(item.category)}`}
>
<Text className="text-[8px] font-black text-white uppercase tracking-tighter">
{item.category}
</Text>
</View>
<Text variant="muted" className="text-[10px] font-bold">
{new Date(item.publishedAt).toLocaleDateString()}
<Card
className="overflow-hidden rounded-[20px] bg-card border-border/50"
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
>
<View className="p-5 flex-1 justify-between">
<View>
<View className="flex-row items-center gap-2 mb-2">
<View
className={`px-2 py-0.5 rounded-full ${getCategoryColor(item.category)}`}
>
<Text className="text-[8px] font-black text-white uppercase tracking-tighter">
{item.category}
</Text>
</View>
<Text
className="text-foreground font-black text-lg leading-tight"
numberOfLines={2}
>
{item.title}
<Text variant="muted" className="text-[10px] font-bold">
{new Date(item.publishedAt).toLocaleDateString()}
</Text>
</View>
<Text
className="text-foreground font-black text-lg leading-tight"
numberOfLines={2}
>
{item.title}
</Text>
</View>
<View className="flex-row justify-between items-center">
<Text variant="muted" className="text-xs font-medium opacity-60">
Tap to read more
</Text>
<View className="bg-primary/10 p-1.5 rounded-full">
<ChevronRight color="#ea580c" size={14} strokeWidth={3} />
</View>
<View className="flex-row justify-between items-center">
<Text variant="muted" className="text-xs font-medium opacity-60">
Tap to read more
</Text>
<View className="bg-primary/10 p-1.5 rounded-full">
<ChevronRight color="#ea580c" size={14} strokeWidth={3} />
</View>
</View>
</Card>
</ShadowWrapper>
</View>
</Card>
</Pressable>
);
const NewsItem = ({ item }: { item: NewsItem }) => (
<Pressable className="mb-4" key={item.id}>
<ShadowWrapper level="xs">
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
<View className="p-4">
<View className="flex-row items-center gap-2 mb-1.5">
<View
className={`w-1.5 h-1.5 rounded-full ${getCategoryColor(item.category)}`}
/>
<Text
variant="muted"
className="text-[10px] font-black uppercase tracking-widest opacity-60"
>
{item.category}
</Text>
</View>
<Text
className="text-foreground font-bold text-sm mb-1"
numberOfLines={2}
>
{item.title}
</Text>
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
<View className="p-4">
<View className="flex-row items-center gap-2 mb-1.5">
<View
className={`w-1.5 h-1.5 rounded-full ${getCategoryColor(item.category)}`}
/>
<Text
variant="muted"
className="text-[11px] leading-relaxed"
numberOfLines={2}
className="text-[10px] font-black uppercase tracking-widest opacity-60"
>
{item.content}
{item.category}
</Text>
</View>
<Text
className="text-foreground font-bold text-sm mb-1"
numberOfLines={2}
>
{item.title}
</Text>
<Text
variant="muted"
className="text-[11px] leading-relaxed"
numberOfLines={2}
>
{item.content}
</Text>
<View className="flex-row items-center gap-3 mt-3">
<View className="flex-row items-center gap-1">
<Clock color="#94a3b8" size={10} strokeWidth={2.5} />
<Text variant="muted" className="text-[10px] font-medium">
{new Date(item.publishedAt).toLocaleDateString()}
</Text>
</View>
<View className="flex-row items-center gap-3 mt-3">
<View className="flex-row items-center gap-1">
<Clock color="#94a3b8" size={10} strokeWidth={2.5} />
<Text variant="muted" className="text-[10px] font-medium">
{new Date(item.publishedAt).toLocaleDateString()}
</Text>
</View>
</View>
</Card>
</ShadowWrapper>
</View>
</Card>
</Pressable>
);

View File

@ -64,7 +64,10 @@ export default function PaymentsScreen() {
const [loadingMore, setLoadingMore] = useState(false);
// Check permissions
const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]);
const canCreatePayments = hasPermission(
permissions,
PERMISSION_MAP["payments:create"],
);
const fetchPayments = useCallback(
async (pageNum: number, isRefresh = false) => {
@ -124,6 +127,10 @@ export default function PaymentsScreen() {
reconciled: payments.filter((p) => p.invoiceId && !p.isFlagged),
};
useEffect(() => {
console.log(payments);
}, [payments]);
const renderPaymentItem = (
pay: Payment,
type: "reconciled" | "pending" | "flagged",
@ -208,7 +215,7 @@ export default function PaymentsScreen() {
<ScreenWrapper className="bg-background">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 150 }}
contentContainerStyle={{ paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
onScroll={({ nativeEvent }) => {
const isCloseToBottom =
@ -231,70 +238,71 @@ export default function PaymentsScreen() {
</Text>
</Button>
{/* Flagged Section */}
{categorized.flagged.length > 0 && (
<>
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-red-600">
Flagged Payments
</Text>
</View>
<View className="gap-2 mb-6">
{categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))}
</View>
</>
)}
{/* Pending Section */}
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-foreground">
Pending Match
</Text>
</View>
<View className="gap-2 mb-6">
{categorized.pending.length > 0 ? (
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
) : (
<View className="py-1">
<EmptyState
title="No pending payments"
description="Payments that haven't been matched to invoices yet will appear here."
hint="Upload receipts or scan SMS to add payments."
previewLines={3}
/>
</View>
{/* Flagged Section */}
{categorized.flagged.length > 0 && (
<>
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-red-600">
Flagged Payments
</Text>
</View>
<View className="gap-2 mb-6">
{categorized.flagged.map((p) =>
renderPaymentItem(p, "flagged"),
)}
</View>
</>
)}
</View>
{/* Reconciled Section */}
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-foreground">
Reconciled
</Text>
</View>
<View className="gap-2">
{categorized.reconciled.length > 0 ? (
categorized.reconciled.map((p) =>
renderPaymentItem(p, "reconciled"),
)
) : (
<View className="py-4">
<EmptyState
title="No reconciled payments"
description="Payments matched to invoices will show up here once reconciled."
hint="Match pending payments to invoices for reconciliation."
previewLines={3}
/>
</View>
)}
</View>
{loadingMore && (
<View className="py-4">
<ActivityIndicator color={PRIMARY} />
{/* Pending Section */}
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-foreground">
Pending Match
</Text>
</View>
)}
<View className="gap-2 mb-6">
{categorized.pending.length > 0 ? (
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
) : (
<View className="py-1">
<EmptyState
title="No pending payments"
description="Payments that haven't been matched to invoices yet will appear here."
hint="Upload receipts or scan SMS to add payments."
previewLines={3}
/>
</View>
)}
</View>
{/* Reconciled Section */}
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-foreground">
Reconciled
</Text>
</View>
<View className="gap-2">
{categorized.reconciled.length > 0 ? (
categorized.reconciled.map((p) =>
renderPaymentItem(p, "reconciled"),
)
) : (
<View className="py-4">
<EmptyState
title="No reconciled payments"
description="Payments matched to invoices will show up here once reconciled."
hint="Match pending payments to invoices for reconciliation."
previewLines={3}
/>
</View>
)}
</View>
{loadingMore && (
<View className="py-4">
<ActivityIndicator color={PRIMARY} />
</View>
)}
</View>
</ScrollView>
</ScreenWrapper>

View File

@ -14,7 +14,7 @@ import { CameraView, useCameraPermissions } from "expo-camera";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { useNavigation } from "expo-router";
import { BASE_URL } from "@/lib/api";
import { api, BASE_URL } from "@/lib/api";
import { useAuthStore } from "@/lib/auth-store";
import { toast } from "@/lib/toast-store";
@ -94,13 +94,63 @@ export default function ScanScreen() {
throw new Error(err.message || "Scan failed.");
}
const data = await response.json();
console.log("[Scan] Extracted invoice data:", data);
const scanResult = await response.json();
console.log("[Scan] Extracted invoice data:", scanResult);
toast.success("Scan Complete!", "Invoice data extracted successfully.");
if (!scanResult.success) {
throw new Error(scanResult.message || "Extraction failed.");
}
// Navigate to create invoice screen
nav.go("proforma/create");
toast.success("Scan Complete!", "Drafting your invoice now...");
// 4. Map OCR data to Invoice structure
const ocr = scanResult.data || {};
const invoicePayload = {
invoiceNumber: ocr.invoiceNumber || `INV-${Date.now()}`,
customerName: ocr.customerName?.trim() || "Unknown Customer",
customerEmail: ocr.customerEmail || "",
customerPhone: ocr.customerPhone || "",
amount: ocr.totalAmount || ocr.subtotalAmount || 0,
currency: ocr.currency || "ETB",
type: "SALES",
status: "DRAFT",
issueDate: ocr.issueDate
? new Date(ocr.issueDate).toISOString()
: new Date().toISOString(),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
description: `Scanned Invoice #${ocr.invoiceNumber || ""}`,
notes: scanResult.message || "Automatically generated from scan.",
taxAmount: ocr.taxAmount || 0,
discountAmount: 0,
isScanned: true,
scannedData: {
sellerTIN: ocr.sellerTIN || "",
items: ocr.items || [],
},
items: (ocr.items || []).map((item: any) => ({
description:
typeof item === "string" ? item : item.description || "Item",
quantity: item.quantity || 1,
unitPrice: item.unitPrice || item.total || 0,
total: item.total || 0,
})),
};
// 5. Create the invoice in the backend
const createResponse = await api.invoices.create({
body: invoicePayload,
});
console.log("[Scan] Invoice created successfully:", createResponse);
toast.success("Success!", "Invoice created and ready for review.");
// 6. Navigate to the new invoice detail page
if (createResponse?.id) {
nav.go(`invoices/${createResponse.id}`);
} else {
nav.go("(tabs)/payments");
}
} catch (err: any) {
console.error("[Scan] Error:", err);
toast.error(

View File

@ -6,18 +6,28 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Toast } from "@/components/Toast";
import "@/global.css";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { View, ActivityIndicator, InteractionManager } from "react-native";
import {
View,
ActivityIndicator,
InteractionManager,
AppState,
} from "react-native";
import { useRestoreTheme, NAV_THEME } from "@/lib/theme";
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
import { NavigationContainer, NavigationIndependentTree, ThemeProvider } from "@react-navigation/native";
import { refreshTokens } from "@/lib/api-middlewares";
import {
NavigationContainer,
NavigationIndependentTree,
ThemeProvider,
} from "@react-navigation/native";
import { routes } from "@/lib/routes";
import { authGuard, guestGuard } from "@/lib/auth-guards";
import { useAuthStore } from "@/lib/auth-store";
import { useFonts } from 'expo-font';
import { useFonts } from "expo-font";
import { api } from "@/lib/api";
import { useColorScheme } from 'react-native';
import { useColorScheme } from "nativewind";
import { useSegments } from "expo-router";
import { useSegments, useLocalSearchParams, useRouter } from "expo-router";
function BackupGuard() {
const segments = useSegments();
@ -30,18 +40,57 @@ function BackupGuard() {
useEffect(() => {
if (!isMounted) return;
// Intentionally disabled: redirecting here can happen before the root layout
// navigator is ready and cause "Attempted to navigate before mounting".
// Sirou guards handle redirects.
}, [segments, isAuthed, isMounted]);
return null;
}
/**
* SessionHeartbeat: Proactively refreshes tokens every 5 minutes and upon app foregrounding.
*/
function SessionHeartbeat() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
if (!isAuthenticated) return;
// Refresh every 5 minutes
const INTERVAL_MS = 5 * 60 * 1000;
const performRefresh = async (reason: string) => {
try {
console.log(`[SessionHeartbeat] Refresh triggered by: ${reason}`);
await refreshTokens();
} catch (err) {
console.warn(`[SessionHeartbeat] Refresh failed (${reason}):`, err);
}
};
// 1. Initial/Interval Refresh
performRefresh("Mount"); // Refresh immediately on mount
const interval = setInterval(() => performRefresh("Interval"), INTERVAL_MS);
// 2. Foreground Refresh (AppState listener)
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (nextAppState === "active") {
performRefresh("Foreground");
}
});
return () => {
clearInterval(interval);
subscription.remove();
};
}, [isAuthenticated]);
return null;
}
function SirouBridge() {
const sirou = useSirouRouter();
const router = useRouter();
const segments = useSegments();
const params = useLocalSearchParams();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [isMounted, setIsMounted] = useState(false);
@ -53,19 +102,20 @@ function SirouBridge() {
if (!isMounted) return;
const checkAuth = async () => {
// Create EXACT name from segments: (tabs), index => (tabs)/index
// Use "root" if segments are empty (initial layout)
const routeName = segments.length > 0 ? segments.join("/") : "root";
console.log(`[SirouBridge] checking route: "${routeName}"`);
console.log(
`[SirouBridge] checking route: "${routeName}" with params:`,
params,
);
try {
const result = await (sirou as any).checkGuards(routeName);
const result = await (sirou as any).checkGuards(routeName, params);
if (!result.allowed && result.redirect) {
console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`);
// Use Sirou navigation safely
InteractionManager.runAfterInteractions(() => {
sirou.go(result.redirect);
// Use Expo Router directly — sirou.go fires NAVIGATE which Expo can't resolve
router.replace(result.redirect as any);
});
}
} catch (e: any) {
@ -77,26 +127,26 @@ function SirouBridge() {
};
checkAuth();
}, [segments, sirou, isMounted, isAuthenticated]);
}, [segments, params, sirou, router, isMounted, isAuthenticated]);
return null;
}
export default function RootLayout() {
const colorScheme = useColorScheme();
const { colorScheme } = useColorScheme();
useRestoreTheme();
const [isMounted, setIsMounted] = useState(false);
const [hasHydrated, setHasHydrated] = useState(false);
const [fontsLoaded] = useFonts({
'DMSans-Regular': require('../assets/fonts/DMSans-Regular.ttf'),
'DMSans-Bold': require('../assets/fonts/DMSans-Bold.ttf'),
'DMSans-Medium': require('../assets/fonts/DMSans-Medium.ttf'),
'DMSans-SemiBold': require('../assets/fonts/DMSans-SemiBold.ttf'),
'DMSans-Light': require('../assets/fonts/DMSans-Light.ttf'),
'DMSans-ExtraLight': require('../assets/fonts/DMSans-ExtraLight.ttf'),
'DMSans-Thin': require('../assets/fonts/DMSans-Thin.ttf'),
'DMSans-Black': require('../assets/fonts/DMSans-Black.ttf'),
'DMSans-ExtraBold': require('../assets/fonts/DMSans-ExtraBold.ttf'),
"DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"),
"DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"),
"DMSans-Medium": require("../assets/fonts/DMSans-Medium.ttf"),
"DMSans-SemiBold": require("../assets/fonts/DMSans-SemiBold.ttf"),
"DMSans-Light": require("../assets/fonts/DMSans-Light.ttf"),
"DMSans-ExtraLight": require("../assets/fonts/DMSans-ExtraLight.ttf"),
"DMSans-Thin": require("../assets/fonts/DMSans-Thin.ttf"),
"DMSans-Black": require("../assets/fonts/DMSans-Black.ttf"),
"DMSans-ExtraBold": require("../assets/fonts/DMSans-ExtraBold.ttf"),
});
useEffect(() => {
@ -134,85 +184,90 @@ export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<NavigationIndependentTree>
<NavigationContainer>
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
<ThemeProvider
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
<ThemeProvider
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
>
<View className="flex-1 bg-background">
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<Stack
screenOptions={{
headerShown: false,
}}
>
<View className="flex-1 bg-background">
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="sms-scan"
options={{ headerShown: false }}
/>
<Stack.Screen
name="proforma/[id]"
options={{ title: "Proforma request" }}
/>
<Stack.Screen
name="payments/[id]"
options={{ title: "Payment" }}
/>
<Stack.Screen
name="notifications/index"
options={{ title: "Notifications" }}
/>
<Stack.Screen
name="notifications/settings"
options={{ title: "Notification settings" }}
/>
<Stack.Screen name="help" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="history" options={{ headerShown: false }} />
<Stack.Screen name="company" options={{ headerShown: false }} />
<Stack.Screen
name="company-details"
options={{ headerShown: false }}
/>
<Stack.Screen
name="login"
options={{ title: "Sign in", headerShown: false }}
/>
<Stack.Screen
name="register"
options={{ title: "Create account", headerShown: false }}
/>
<Stack.Screen
name="invoices/[id]"
options={{ title: "Invoice" }}
/>
<Stack.Screen
name="reports/index"
options={{ title: "Reports" }}
/>
<Stack.Screen
name="documents/index"
options={{ title: "Documents" }}
/>
<Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="profile" options={{ headerShown: false }} />
<Stack.Screen
name="edit-profile"
options={{ headerShown: false }}
/>
</Stack>
<SirouBridge />
<BackupGuard />
<PortalHost />
<Toast />
</View>
</ThemeProvider>
</SirouRouterProvider>
</NavigationContainer>
</NavigationIndependentTree>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="sms-scan"
options={{ headerShown: false }}
/>
<Stack.Screen
name="proforma/[id]"
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
name="payments/[id]"
options={{ title: "Payment" }}
/>
<Stack.Screen
name="notifications/index"
options={{ title: "Notifications" }}
/>
<Stack.Screen
name="notifications/settings"
options={{ title: "Notification settings" }}
/>
<Stack.Screen name="help" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="history" options={{ headerShown: false }} />
<Stack.Screen name="company" options={{ headerShown: false }} />
<Stack.Screen
name="company-details"
options={{ headerShown: false }}
/>
<Stack.Screen
name="login"
options={{ title: "Sign in", headerShown: false }}
/>
<Stack.Screen
name="register"
options={{ title: "Create account", headerShown: false }}
/>
<Stack.Screen
name="reports/index"
options={{ title: "Reports" }}
/>
<Stack.Screen
name="documents/index"
options={{ title: "Documents" }}
/>
<Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="profile" options={{ headerShown: false }} />
<Stack.Screen
name="edit-profile"
options={{ headerShown: false }}
/>
</Stack>
<SirouBridge />
<BackupGuard />
<SessionHeartbeat />
<PortalHost />
<Toast />
</View>
</ThemeProvider>
</SirouRouterProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);

View File

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

View File

@ -1,5 +1,13 @@
import React, { useState, useEffect } from "react";
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
import {
View,
ScrollView,
ActivityIndicator,
Alert,
Linking,
useColorScheme,
Pressable,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router";
@ -11,18 +19,28 @@ import {
Calendar,
Share2,
Download,
ArrowLeft,
Trash2,
Package,
Clock,
ExternalLink,
ChevronRight,
User,
CreditCard,
Hash,
AlertCircle,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api } from "@/lib/api";
import { api, BASE_URL } from "@/lib/api";
import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store";
export default function InvoiceDetailScreen() {
const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [loading, setLoading] = useState(true);
const [invoice, setInvoice] = useState<any>(null);
@ -34,7 +52,11 @@ export default function InvoiceDetailScreen() {
const fetchInvoice = async () => {
try {
setLoading(true);
const data = await api.invoices.getById({ params: { id: id as string } });
// Ensure id is a string if useLocalSearchParams returns an array
const invoiceId = Array.isArray(id) ? id[0] : id;
if (!invoiceId) throw new Error("No ID provided");
const data = await api.invoices.getById({ params: { id: invoiceId } });
setInvoice(data);
} catch (error: any) {
console.error("[InvoiceDetail] Error:", error);
@ -44,11 +66,51 @@ export default function InvoiceDetailScreen() {
}
};
const handleGetPdf = async () => {
try {
const { token } = useAuthStore.getState();
const pdfUrl = `${BASE_URL}invoices/${id}/pdf?token=${token}`;
await Linking.openURL(pdfUrl);
} catch (error) {
console.error("[InvoiceDetail] PDF Error:", error);
toast.error("Error", "Failed to open PDF");
}
};
const handleDelete = async () => {
Alert.alert(
"Delete Invoice",
"Are you sure you want to delete this invoice? This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
setLoading(true);
const invoiceId = Array.isArray(id) ? id[0] : id;
await api.invoices.delete({
params: { id: invoiceId as string },
});
toast.success("Success", "Invoice deleted successfully");
nav.back();
} catch (error) {
console.error("[InvoiceDetail] Delete Error:", error);
toast.error("Error", "Failed to delete invoice");
setLoading(false);
}
},
},
],
);
};
if (loading) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice Details" showBack />
<StandardHeader title="Invoice" showBack />
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" />
</View>
@ -60,220 +122,343 @@ export default function InvoiceDetailScreen() {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice Details" showBack />
<StandardHeader title="Invoice" showBack />
<View className="flex-1 justify-center items-center">
<Text variant="muted">Invoice not found</Text>
<AlertCircle size={48} color="#ef4444" className="mb-4" />
<Text variant="h4" className="mb-1">
Invoice Not Found
</Text>
<Text variant="muted">
The requested document could not be retrieved.
</Text>
</View>
</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 (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice Details" showBack />
<StandardHeader
title={"Invoice Detail"}
showBack
rightAction="edit"
onRightActionPress={() => nav.go("invoices/edit", { id: invoice.id })}
/>
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
>
{/* Status Hero Card */}
<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
className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`}
>
<Text
className={`text-[10px] font-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
>
{invoice.status || "Pending"}
</Text>
</View>
</View>
<Text variant="small" className="text-white/70 mb-0.5">
Total Amount
{/* Modern Hero Area */}
<View className="px-5 pt-4">
<View
className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}
>
<View className={`w-2 h-2 rounded-full ${colors.dot}`} />
<Text
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}
>
{status}
</Text>
<Text variant="h3" className="text-white font-bold mb-3">
${Number(invoice.amount).toLocaleString()}
</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 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 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
variant="p"
className="text-foreground font-semibold"
numberOfLines={1}
>
{invoice.customerName || "—"}
</Text>
</View>
</View>
<View className="w-[1px] bg-border/70 mx-3" />
<View className="flex-1 flex-row items-center">
<View className="flex-col">
<Text className="text-foreground text-xs opacity-60">
Category
</Text>
<Text
variant="p"
className="text-foreground font-semibold"
numberOfLines={1}
>
General
</Text>
</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">
{/* 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="small"
className="font-bold opacity-60 uppercase text-[10px] tracking-widest"
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Billing Summary
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 className="flex-row justify-between py-3 border-b border-border/70">
<View className="flex-1 pr-4">
{/* Client Box */}
<View className="px-5 mb-6">
<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="p"
className="text-foreground font-semibold text-sm"
variant="muted"
className="text-[10px] uppercase font-bold"
>
Subtotal
Billed To
</Text>
<Text variant="p" className="text-foreground font-bold text-lg">
{invoice.customerName?.replace("Customer Name: ", "") ||
"Walking Client"}
</Text>
</View>
<Text variant="p" className="text-foreground font-bold text-sm">
$
{(
Number(invoice.amount) - (Number(invoice.taxAmount) || 0)
).toLocaleString()}
</Text>
</View>
{Number(invoice.taxAmount) > 0 && (
<View className="flex-row justify-between py-3 border-b border-border/70">
<View className="flex-1 pr-4">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
>
Tax
<View className="flex-row flex-wrap gap-4 pt-4 border-t border-primary/10">
{invoice.customerEmail && (
<View className="flex-row items-center gap-2">
<CreditCard size={12} color="#64748b" />
<Text className="text-muted-foreground text-xs">
{invoice.customerEmail}
</Text>
</View>
<Text variant="p" className="text-foreground font-bold text-sm">
+ ${Number(invoice.taxAmount).toLocaleString()}
)}
<View className="flex-row items-center gap-2">
<Hash size={12} color="#64748b" />
<Text className="text-muted-foreground text-xs">
#{invoice.id.split("-")[0]}
</Text>
</View>
</View>
</View>
</View>
{/* Detailed Items Table */}
<View className="px-5 mb-6">
<Text variant="h4" className="font-bold mb-4 px-1">
Order Summary
</Text>
<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">
<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>
</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 */}
<View className="px-5 mb-6">
<Card className="bg-card rounded-[6px] p-5 shadow-sm shadow-black/5 border-border/60">
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Subtotal
</Text>
<Text className="text-foreground font-bold">
{subtotalValue.toLocaleString()} {invoice.currency}
</Text>
</View>
{taxAmountValue > 0 && (
<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>
</View>
)}
<View className="mt-3 pt-3 flex-row justify-between items-center border-t border-border/70">
<Text variant="muted" className="font-semibold text-sm">
Total Balance
</Text>
<Text
variant="h3"
className="text-foreground font-semibold text-xl tracking-tight"
>
${Number(invoice.amount).toLocaleString()}
</Text>
</View>
</View>
</Card>
{discountValue > 0 && (
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Discount
</Text>
<Text className="text-rose-500 font-bold">
-{discountValue.toLocaleString()} {invoice.currency}
</Text>
</View>
)}
{/* Notes Section (New) */}
{invoice.notes && (
<Card className="mb-4 bg-card rounded-[6px]">
<View className="p-4">
<Text
variant="small"
className="font-bold opacity-60 uppercase text-[10px] tracking-widest mb-2"
>
Additional Notes
</Text>
<Text
variant="p"
className="text-foreground font-medium text-xs leading-5"
>
{invoice.notes}
<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
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter"
>
Verified from data
</Text>
</View>
<Text className="text-primary font-black text-2xl">
{amountValue.toLocaleString()} {invoice.currency}
</Text>
</View>
</Card>
)}
{/* Timeline Section (New) */}
<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">
<Button
className=" flex-1 mb-4 h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
onPress={() => {}}
>
<Share2 color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white text-[12px] font-black uppercase tracking-widest">
Share SMS
</Text>
</Button>
<ShadowWrapper>
<Button
className=" flex-1 mb-4 h-12 rounded-[10px] bg-card border border-border"
onPress={() => {}}
{/* Notes */}
{invoice.notes && (
<View className="px-5 mb-10">
<Text
variant="muted"
className="text-[10px] uppercase font-bold mb-2"
>
<Download color="#0f172a" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-foreground text-[12px] font-black uppercase tracking-widest">
Note / Description
</Text>
<Text className="text-foreground font-medium italic opacity-80 leading-5">
" {invoice.notes} "
</Text>
</View>
)}
{/* Premium Actions */}
<View className="px-5 gap-3">
<View className="flex-row gap-3">
<Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
onPress={() =>
toast.info(
"Coming Soon",
"SMS sharing enabled for matched accounts.",
)
}
>
<Share2 color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
Scan SMS
</Text>
</Button>
<Button
variant="outline"
className="flex-1 h-14 rounded-[6px] bg-card border border-border"
onPress={handleGetPdf}
>
<Download
color={isDark ? "#f1f5f9" : "#0f172a"}
size={18}
strokeWidth={2.5}
/>
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
Get PDF
</Text>
</Button>
</ShadowWrapper>
</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 Invoice
</Text>
</Button>
</View>
</ScrollView>
</ScreenWrapper>

659
app/invoices/edit.tsx Normal file
View File

@ -0,0 +1,659 @@
import React, { useState, useEffect } from "react";
import {
View,
ScrollView,
Pressable,
TextInput,
StyleSheet,
ActivityIndicator,
} from "react-native";
import { useColorScheme } from "nativewind";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import {
ArrowLeft,
ArrowRight,
Trash2,
Send,
Plus,
Calendar,
ChevronDown,
CalendarSearch,
FileText,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router";
import { useRouter } from "expo-router";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { api } from "@/lib/api";
import { toast } from "@/lib/toast-store";
import { PickerModal, SelectOption } from "@/components/PickerModal";
import { CalendarGrid } from "@/components/CalendarGrid";
import { StandardHeader } from "@/components/StandardHeader";
type Item = { id: number; description: string; qty: string; price: string };
const S = StyleSheet.create({
input: {
height: 44,
paddingHorizontal: 12,
fontSize: 12,
fontWeight: "500",
borderRadius: 6,
borderWidth: 1,
},
inputCenter: {
height: 44,
paddingHorizontal: 12,
fontSize: 14,
fontWeight: "500",
borderRadius: 6,
borderWidth: 1,
textAlign: "center",
},
});
function useInputColors() {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
return {
bg: isDark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
border: isDark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
text: isDark ? "#f1f5f9" : "#0f172a",
placeholder: "rgba(100,116,139,0.45)",
};
}
function Field({
label,
value,
onChangeText,
placeholder,
numeric = false,
center = false,
flex,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder: string;
numeric?: boolean;
center?: boolean;
flex?: number;
}) {
const c = useInputColors();
return (
<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,9 +12,20 @@ import {
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { useRouter } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User, Globe } from "@/lib/icons";
import {
Mail,
Lock,
ArrowRight,
Eye,
EyeOff,
Chrome,
User,
Globe,
Phone,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useAuthStore } from "@/lib/auth-store";
import * as Linking from "expo-linking";
@ -24,21 +35,31 @@ import { toast } from "@/lib/toast-store";
import { useLanguageStore, AppLanguage } from "@/lib/language-store";
import { getPlaceholderColor } from "@/lib/colors";
import { LanguageModal } from "@/components/LanguageModal";
import {
GoogleSignin,
statusCodes,
} from "@react-native-google-signin/google-signin";
// Lazy-load Google Sign-In to prevent crash when native module is missing (e.g. Expo Go)
let GoogleSignin: any = null;
let statusCodes: any = {};
let googleAvailable = false;
GoogleSignin.configure({
webClientId:
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
iosClientId:
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", // Placeholder: replace with your actual iOS Client ID from Google Cloud Console
offlineAccess: true,
});
try {
const gsi = require("@react-native-google-signin/google-signin");
GoogleSignin = gsi.GoogleSignin;
statusCodes = gsi.statusCodes;
googleAvailable = true;
GoogleSignin.configure({
webClientId:
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
iosClientId:
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
offlineAccess: true,
});
} catch (e) {
console.warn("[Login] Google Sign-In native module not available:", (e as any).message);
}
export default function LoginScreen() {
const nav = useSirouRouter<AppRoutes>();
const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth);
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
@ -49,8 +70,32 @@ export default function LoginScreen() {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [loginMode, setLoginMode] = useState<"email" | "phone">("email");
const handleLogin = async () => {
if (loginMode === "phone") {
if (!identifier) {
toast.error("Required Field", "Please enter your phone number");
return;
}
setLoading(true);
const fullPhone = `+251${identifier}`;
try {
const response = await api.auth.sendOtp({ body: { phone: fullPhone } });
toast.success("Success", response.message || "OTP sent successfully");
// Navigate to OTP screen
router.push({
pathname: "/otp",
params: { phone: fullPhone, verificationId: response.verificationId },
});
} catch (err: any) {
toast.error("Error", err.message || "Failed to send OTP");
} finally {
setLoading(false);
}
return;
}
if (!identifier || !password) {
toast.error(
"Required Fields",
@ -64,25 +109,19 @@ export default function LoginScreen() {
const isEmail = identifier.includes("@");
const payload = isEmail
? { email: identifier, password }
: { phone: identifier, password };
: { phone: `+251${identifier}`, password };
try {
// Using the new api.auth.login which is powered by simple-api
const response = await api.auth.login({ body: payload });
// Store user, access token, refresh token, and permissions
// // Fetch roles to get permissions
// const rolesResponse = await rbacApi.roles();
// const userRole = response.user.role;
// const roleData = rolesResponse.find((r: any) => r.role === userRole);
// const permissions = roleData ? roleData.permissions : [];
const permissions: string[] = [];
// Store user, access token, refresh token, and permissions
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
setAuth(
response.user,
response.accessToken,
response.refreshToken,
permissions,
);
toast.success("Welcome Back!", "You have successfully logged in.");
// Explicitly navigate to home
nav.go("(tabs)");
} catch (err: any) {
toast.error("Login Failed", err.message || "Invalid credentials");
@ -92,6 +131,10 @@ export default function LoginScreen() {
};
const handleGoogleLogin = async () => {
if (!googleAvailable || !GoogleSignin) {
toast.error("Unavailable", "Google Sign-In requires a native build. Please use email/phone login.");
return;
}
try {
setLoading(true);
await GoogleSignin.hasPlayServices();
@ -115,7 +158,12 @@ export default function LoginScreen() {
// const permissions = roleData ? roleData.permissions : [];
const permissions: string[] = [];
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
setAuth(
response.user,
response.accessToken,
response.refreshToken,
permissions,
);
toast.success("Welcome!", "Signed in with Google.");
nav.go("(tabs)");
} catch (error: any) {
@ -158,7 +206,7 @@ export default function LoginScreen() {
</View>
{/* Logo / Branding */}
<View className="items-center mb-10">
<View className="items-center mb-8">
<Text variant="h2" className="mt-6 font-bold text-foreground">
Login
</Text>
@ -167,48 +215,96 @@ export default function LoginScreen() {
</Text>
</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 */}
<View className="gap-5">
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Email or Phone Number
{loginMode === "email" ? "Email Address" : "Phone Number"}
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
{loginMode === "email" ? (
<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
className="flex-1 ml-3 text-foreground"
placeholder="john@example.com or +251..."
placeholder={
loginMode === "email" ? "john@example.com" : "912345678"
}
placeholderTextColor={getPlaceholderColor(isDark)}
value={identifier}
onChangeText={setIdentifier}
autoCapitalize="none"
keyboardType={
loginMode === "email" ? "email-address" : "phone-pad"
}
maxLength={loginMode === "phone" ? 9 : undefined}
/>
</View>
</View>
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Password
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="••••••••"
placeholderTextColor={getPlaceholderColor(isDark)}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff size={18} color={isDark ? "#94a3b8" : "#64748b"} />
) : (
<Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} />
)}
</Pressable>
{loginMode === "email" && (
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Password
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="••••••••"
placeholderTextColor={getPlaceholderColor(isDark)}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff
size={18}
color={isDark ? "#94a3b8" : "#64748b"}
/>
) : (
<Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} />
)}
</Pressable>
</View>
</View>
</View>
)}
<Button
className="h-10 bg-primary rounded-[6px] shadow-lg shadow-primary/30 mt-2"
@ -220,7 +316,9 @@ export default function LoginScreen() {
) : (
<>
<Text className="text-white font-bold text-base mr-2">
Sign In
{loginMode === "email"
? "Sign In"
: "Send Verification Code"}
</Text>
<ArrowRight color="white" size={18} strokeWidth={2.5} />
</>

185
app/otp.tsx Normal file
View File

@ -0,0 +1,185 @@
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,130 +1,475 @@
import { View, ScrollView, Pressable } from "react-native";
import { useSirouRouter, useSirouParams } from "@sirou/react-native";
import React, { useState, useEffect } from "react";
import {
View,
ScrollView,
ActivityIndicator,
Alert,
Linking,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack } from "expo-router";
import { Stack, useLocalSearchParams } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons";
import {
Wallet,
Link2,
Clock,
AlertTriangle,
User,
ShieldCheck,
Building2,
Hash,
CheckCircle2,
Eye,
Trash2,
Network,
AlertCircle,
Info,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api, BASE_URL } from "@/lib/api";
import { useColorScheme } from "nativewind";
import { toast } from "@/lib/toast-store";
export default function PaymentDetailScreen() {
const nav = useSirouRouter<AppRoutes>();
const { id } = useSirouParams<AppRoutes, "payments/[id]">();
const { id } = useLocalSearchParams();
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 (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Payment Details" showBack />
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" />
<Text
variant="muted"
className="mt-4 font-bold uppercase tracking-widest text-[10px]"
>
Retrieving Transaction...
</Text>
</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 }} />
<View className="px-6 pt-4 flex-row justify-between items-center">
<Pressable
onPress={() => nav.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<ArrowLeft color="#0f172a" size={20} />
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
Payment Match
</Text>
<View className="w-9" />
</View>
<StandardHeader title={"Payment Details"} showBack />
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
contentContainerStyle={{ paddingBottom: 10 }}
showsVerticalScrollIndicator={false}
>
<Card className=" 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]">
<Wallet color="white" size={18} strokeWidth={2.5} />
</View>
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
<Text className={`text-[10px] font-bold text-white`}>
Pending Match
</Text>
</View>
{/* Urgent Alerts */}
{isFlagged && (
<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="bg-red-500/20 p-2 rounded-full mr-4">
<AlertTriangle color="#ef4444" size={20} />
</View>
<Text variant="small" className="text-white/70 mb-0.5">
Received Amount
</Text>
<Text variant="h3" className="text-white font-bold mb-3">
$2,000.00
</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">
<Text className="text-white/90 text-xs font-semibold">
TXN-9982734
</Text>
</View>
<View className="h-3 w-[1px] bg-white/60" />
<Text className="text-white/90 text-xs font-semibold">
Telebirr SMS
<View className="flex-1">
<Text className="text-red-600 font-black text-[10px] uppercase tracking-[2px] mb-1">
Security Flag ({payment.flagReason || "Audit Needed"})
</Text>
<Text className="text-foreground/80 font-medium text-xs leading-5">
{payment.flagNotes || "System flagged this for manual review."}
</Text>
</View>
</View>
</Card>
)}
{/* Transaction Details */}
<Text variant="h4" className="text-foreground mt-4 mb-2">
Transaction Details
</Text>
<Card className="bg-card rounded-[6px] mb-3">
<View className="p-4">
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-2">
<Clock color="#000" size={13} />
<Text variant="muted" className="text-sm">
Received On
</Text>
</View>
<Text variant="p" className="text-foreground text-sm">
Sep 11, 2022 · 14:30
{/* 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>
<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
{isFailed && (
<View className="bg-red-500/10 px-3 py-1 rounded-full flex-row items-center gap-2">
<AlertCircle size={12} color="#ef4444" />
<Text className="text-red-600 text-[10px] font-black uppercase tracking-widest">
Verify Failed
</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
)}
{isScanned && (
<View className="bg-primary/10 px-3 py-1 rounded-full flex-row items-center gap-2 border border-primary/20">
<CheckCircle2 size={12} color="#ea580c" />
<Text className="text-primary text-[10px] font-black uppercase tracking-widest">
Scanned
</Text>
</View>
</View>
)}
</View>
</Card>
{/* SMS Message */}
<Card className="bg-card rounded-[6px] mb-6">
<View className="p-4">
<Text variant="muted" className="mb-3 font-semibold">
Original SMS
</Text>
<Text className="text-foreground/70 font-medium leading-6 text-sm">
"Payment received from Elnatan Jansen for order #2322 via
Telebirr. Amount: $2,000. Ref: B88-22X7."
</Text>
</View>
</Card>
{/* Action */}
<Button className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30">
<Link2 color="white" size={18} strokeWidth={2.5} />
<Text className=" text-white text-xs font-semibold uppercase tracking-widest">
Associate to Invoice
<Text
variant="muted"
className="text-[10px] font-black uppercase tracking-[3px] mb-2 opacity-60"
>
Total Transaction Amount
</Text>
</Button>
<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>
</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 className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
<Network size={18} color="#ea580c" className="mb-3" />
<Text
variant="muted"
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
>
Provider
</Text>
<Text
className="text-foreground font-black text-sm"
numberOfLines={1}
>
{extracted.provider ||
payment.paymentMethod ||
"Direct Payment"}
</Text>
</Card>
</View>
</View>
{/* Sender / Payer Box */}
<View className="px-5 mb-8">
<View className="bg-card/50 rounded-[6px] p-6 border border-border/40 shadow-sm shadow-black/5">
<View className="flex-row items-center gap-4 mb-5">
<View className="h-12 w-12 rounded-full bg-secondary/10 items-center justify-center border border-secondary/20">
<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 className="text-foreground font-black text-lg">
{payment.senderName ||
(payment.user
? `${payment.user.firstName} ${payment.user.lastName}`
: "Business Account")}
</Text>
</View>
</View>
<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 */}
{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 */}
<View className="px-5 gap-3">
<View className="flex-row gap-3">
{scanned?.imageUrl && (
<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>
</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>
</ScreenWrapper>
);

View File

@ -1,59 +1,46 @@
import React, { useState, useEffect } from "react";
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
import {
View,
ScrollView,
ActivityIndicator,
Alert,
Linking,
useColorScheme,
Pressable,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router";
import { useRouter } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
ArrowLeft,
DraftingCompass,
FileText,
Calendar,
Share2,
Download,
Trash2,
Package,
Clock,
Send,
ExternalLink,
ChevronRight,
CheckCircle2,
User,
CreditCard,
Hash,
AlertCircle,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api } from "@/lib/api";
import { api, BASE_URL } from "@/lib/api";
import { toast } from "@/lib/toast-store";
const dummyData = {
id: "dummy-1",
proformaNumber: "PF-001",
customerName: "John Doe",
customerEmail: "john@example.com",
customerPhone: "+1234567890",
amount: { value: 1000, currency: "USD" },
currency: "USD",
issueDate: "2026-03-10T11:51:36.134Z",
dueDate: "2026-03-10T11:51:36.134Z",
description: "Dummy proforma",
notes: "Test notes",
taxAmount: { value: 100, currency: "USD" },
discountAmount: { value: 50, currency: "USD" },
pdfPath: "dummy.pdf",
userId: "user-1",
items: [
{
id: "item-1",
description: "Test item",
quantity: 1,
unitPrice: { value: 1000, currency: "USD" },
total: { value: 1000, currency: "USD" }
}
],
createdAt: "2026-03-10T11:51:36.134Z",
updatedAt: "2026-03-10T11:51:36.134Z"
};
import { useAuthStore } from "@/lib/auth-store";
export default function ProformaDetailScreen() {
const nav = useSirouRouter<AppRoutes>();
const router = useRouter();
const { id } = useLocalSearchParams();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [loading, setLoading] = useState(true);
const [proforma, setProforma] = useState<any>(null);
@ -65,17 +52,60 @@ export default function ProformaDetailScreen() {
const fetchProforma = async () => {
try {
setLoading(true);
const data = await api.proforma.getById({ params: { id: id as string } });
// Ensure id is a string if useLocalSearchParams returns an array
const proformaId = Array.isArray(id) ? id[0] : id;
if (!proformaId) throw new Error("No ID provided");
const data = await api.proforma.getById({ params: { id: proformaId } });
setProforma(data);
} catch (error: any) {
console.error("[ProformaDetail] Error:", error);
toast.error("Error", "Failed to load proforma details");
setProforma(dummyData); // Use dummy data for testing
} finally {
setLoading(false);
}
};
const handleGetPdf = async () => {
try {
const { token } = useAuthStore.getState();
const pdfUrl = `${BASE_URL}proforma/${id}/pdf?token=${token}`;
await Linking.openURL(pdfUrl);
} catch (error) {
console.error("[ProformaDetail] PDF Error:", error);
toast.error("Error", "Failed to open PDF");
}
};
const handleDelete = async () => {
Alert.alert(
"Delete Proforma",
"Are you sure you want to delete this proforma? This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
setLoading(true);
const proformaId = Array.isArray(id) ? id[0] : id;
await api.proforma.delete({
params: { id: proformaId as string },
});
toast.success("Success", "Proforma deleted successfully");
nav.back();
} catch (error) {
console.error("[ProformaDetail] Delete Error:", error);
toast.error("Error", "Failed to delete proforma");
setLoading(false);
}
},
},
],
);
};
if (loading) {
return (
<ScreenWrapper className="bg-background">
@ -94,234 +124,320 @@ export default function ProformaDetailScreen() {
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Proforma" showBack />
<View className="flex-1 justify-center items-center">
<Text variant="muted">Proforma not found</Text>
<AlertCircle size={48} color="#ef4444" className="mb-4" />
<Text variant="h4" className="mb-1">
Proforma Not Found
</Text>
<Text variant="muted">
The requested document could not be retrieved.
</Text>
</View>
</ScreenWrapper>
);
}
const subtotal =
proforma.items?.reduce(
(acc: number, item: any) => acc + (Number(item.total) || 0),
0,
) || 0;
const amountValue = Number(
typeof proforma.amount === "object"
? proforma.amount.value
: proforma.amount,
);
const taxAmountValue = Number(
typeof proforma.taxAmount === "object"
? proforma.taxAmount?.value
: proforma.taxAmount || 0,
);
const discountValue = Number(
typeof proforma.discountAmount === "object"
? proforma.discountAmount?.value
: proforma.discountAmount || 0,
);
const subtotalValue = amountValue - taxAmountValue + discountValue;
const items = proforma.items || [];
const statusColors = {
PAID: {
bg: "bg-emerald-500/10",
text: "text-emerald-500",
dot: "bg-emerald-500",
},
PENDING: {
bg: "bg-amber-500/10",
text: "text-amber-500",
dot: "bg-amber-500",
},
DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" },
DEFAULT: {
bg: "bg-slate-500/10",
text: "text-slate-500",
dot: "bg-slate-500",
},
};
const status = (proforma.status || "PENDING").toUpperCase();
const colors =
statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
{/* Header */}
<StandardHeader title="Proforma" showBack />
<StandardHeader
title={"Proforma Detail"}
showBack
// rightAction="edit"
// onRightActionPress={() => nav.go("proforma/edit", { id: proforma.id })}
/>
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
contentContainerStyle={{ paddingBottom: 40 }}
showsVerticalScrollIndicator={false}
>
{/* Proforma 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]">
<DraftingCompass color="#ea580c" size={16} strokeWidth={2.5} />
</View>
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
Proforma Details
{/* Modern Hero Area */}
<View className="px-5 pt-4">
<View
className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}
>
<View className={`w-2 h-2 rounded-full ${colors.dot}`} />
<Text
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}
>
{status}
</Text>
</View>
<Text
variant="muted"
className="text-xs font-bold uppercase tracking-wider mb-1"
>
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>
{/* 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(
proforma.issueDate || proforma.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(proforma.dueDate).toLocaleDateString()}
</Text>
</View>
</View>
</View>
<View className="gap-2">
<View className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Proforma Number</Text>
<Text className="text-foreground font-semibold text-sm">{proforma.proformaNumber}</Text>
{/* Client Box */}
<View className="px-5 mb-6">
<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 className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Issued Date</Text>
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.issueDate).toLocaleDateString()}</Text>
<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 className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Due Date</Text>
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.dueDate).toLocaleDateString()}</Text>
</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>
{proforma.description && (
<View className="mt-2">
<Text variant="muted" className="text-xs font-medium mb-1">Description</Text>
<Text className="text-foreground text-sm">{proforma.description}</Text>
</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>
</Card>
{/* 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>
</View>
<View className="gap-2">
<View className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Name</Text>
<Text className="text-foreground font-semibold text-sm">{proforma.customerName}</Text>
</View>
<View className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Email</Text>
<Text className="text-foreground font-semibold text-sm">{proforma.customerEmail || "N/A"}</Text>
</View>
<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 className="flex-row items-center gap-2">
<Hash size={12} color="#64748b" />
<Text className="text-muted-foreground text-xs">
#{proforma.id.split("-")[0]}
</Text>
</View>
</View>
</View>
</Card>
</View>
{/* 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"
>
Line Items
</Text>
</View>
{proforma.items?.map((item: any, i: number) => (
{/* Detailed Items Table */}
<View className="px-5 mb-6">
<Text variant="h4" className="font-bold mb-4 px-1">
Estimate Summary
</Text>
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
{items.map((item: any, idx: number) => (
<View
key={item.id || i}
className={`flex-row justify-between py-3 ${i < proforma.items.length - 1 ? "border-b border-border/40" : ""}`}
key={idx}
className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}
>
<View className="flex-1 pr-4">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
>
<View className="flex-row justify-between items-start mb-1">
<Text className="text-foreground font-bold flex-1 mr-4">
{item.description}
</Text>
<Text variant="muted" className="text-[10px] mt-0.5">
{item.quantity} × {proforma.currency}{" "}
{Number(item.unitPrice).toLocaleString()}
<Text className="text-foreground font-black">
{Number(
item.total?.value || item.total || 0,
).toLocaleString()}
</Text>
</View>
<Text variant="p" className="text-foreground font-bold text-sm">
{proforma.currency} {Number(item.total).toLocaleString()}
<Text className="text-muted-foreground text-xs">
{item.quantity} units x{" "}
{Number(
item.unitPrice?.value || item.unitPrice || 0,
).toLocaleString()}{" "}
{proforma.currency}
</Text>
</View>
))}
<View className="mt-3 pt-3 border-t border-border/40 gap-2">
<View className="flex-row justify-between">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
>
Subtotal
</Text>
<Text variant="p" className="text-foreground font-bold text-sm">
{proforma.currency} {subtotal.toLocaleString()}
</Text>
{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>
{Number(proforma.taxAmount) > 0 && (
<View className="flex-row justify-between">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
>
Tax
</Text>
<Text
variant="p"
className="text-foreground font-bold text-sm"
>
{proforma.currency}{" "}
{Number(proforma.taxAmount).toLocaleString()}
</Text>
</View>
)}
{Number(proforma.discountAmount) > 0 && (
<View className="flex-row justify-between">
<Text
variant="p"
className="text-red-500 font-semibold text-sm"
>
Discount
</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>
</Card>
)}
</Card>
</View>
{/* Notes Section (New) */}
{proforma.notes && (
<Card className="bg-card rounded-[6px] mb-4">
<View className="p-4">
<Text
variant="small"
className="font-bold uppercase tracking-widest text-[10px] opacity-60 mb-2"
>
Additional Notes
{/* Billing Breakdown */}
<View className="px-5 mb-6">
<Card className="bg-card rounded-[6px] p-5 shadow-sm shadow-black/5 border-border/60">
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Net Price
</Text>
<Text
variant="p"
className="text-foreground font-medium text-xs leading-5"
>
{proforma.notes}
<Text className="text-foreground font-bold">
{subtotalValue.toLocaleString()} {proforma.currency}
</Text>
</View>
{taxAmountValue > 0 && (
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Estimated Tax
</Text>
<Text className="text-emerald-500 font-bold">
+{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
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter"
>
Valid as of today
</Text>
</View>
<Text className="text-primary font-black text-2xl">
{amountValue.toLocaleString()} {proforma.currency}
</Text>
</View>
</Card>
</View>
{/* Notes */}
{proforma.notes && (
<View className="px-5 mb-10">
<Text
variant="muted"
className="text-[10px] uppercase font-bold mb-2"
>
Internal Notes
</Text>
<Text className="text-foreground font-medium italic opacity-80 leading-5">
" {proforma.notes} "
</Text>
</View>
)}
{/* Actions */}
<View className="gap-3">
<Button
className="h-12 rounded-[10px] bg-transparent border border-border"
onPress={() => router.push("/proforma/edit?id=" + proforma.id)}
>
<DraftingCompass color="#fff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-foreground font-black text-[12px] uppercase tracking-widest">
Edit
</Text>
</Button>
{/* Premium Actions */}
<View className="px-5 gap-3">
<View className="flex-row gap-3">
<Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
onPress={() => nav.go("proforma/edit", { id: proforma.id })}
>
<Share2 color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
Edit Detail
</Text>
</Button>
<Button
variant="outline"
className="flex-1 h-14 rounded-[6px] bg-card border border-border"
onPress={handleGetPdf}
>
<Download
color={isDark ? "#f1f5f9" : "#0f172a"}
size={18}
strokeWidth={2.5}
/>
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
Export PDF
</Text>
</Button>
</View>
<Button
className="h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
onPress={() => {}}
variant="ghost"
className="h-14 rounded-[6px] border border-rose-500/10"
onPress={handleDelete}
>
<Send color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
Share SMS
<Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">
Delete Proforma
</Text>
</Button>
</View>
</ScrollView>
</ScreenWrapper>

View File

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

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

View File

@ -1,21 +1,83 @@
import { Middleware } from "@simple-api/core";
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.
* Skips login, register, and refresh endpoints.
* Now proactively refreshes token if it's about to expire.
*/
export const authMiddleware: Middleware = async ({ config, options }, next) => {
const { token } = useAuthStore.getState();
let { token } = useAuthStore.getState();
// Don't send Authorization header for sensitive auth-related endpoints,
// EXCEPT for logout which needs to identify the session.
const isAuthPath =
config.path === "auth/login" ||
config.path === "auth/register" ||
config.path === "auth/refresh";
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,
Authorization: `Bearer ${token}`,
@ -25,8 +87,94 @@ export const authMiddleware: Middleware = async ({ config, options }, next) => {
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.
* Includes a queue mechanism to handle concurrent 401s.
*/
export const refreshMiddleware: Middleware = async (
{ config, options },
@ -36,81 +184,35 @@ export const refreshMiddleware: Middleware = async (
return await next(options);
} catch (error: any) {
const status = error.status || error.statusCode;
const { refreshToken, setAuth, logout } = useAuthStore.getState();
const { refreshToken } = useAuthStore.getState();
// Skip refresh logic for the login/refresh endpoints themselves
const isAuthPath =
config.path?.includes("auth/login") ||
config.path?.includes("auth/refresh");
if (status === 401 && refreshToken && !isAuthPath) {
console.log(
`[API Refresh] 401 detected for ${config.path}. Attempting refresh...`,
);
// Force refresh on 401 even if we think it's fresh (since server says it's not)
if (status === 401 && !isAuthPath) {
if (refreshToken) {
console.log(
`[API Refresh] 401 detected for ${config.path}. Forcing refresh...`,
);
try {
const accessToken = await refreshTokens(config, true);
try {
// We call the refresh endpoint manually here to avoid circular dependencies with the 'api' object
const refreshUrl = `${config.baseUrl}auth/refresh`;
if (!accessToken) {
throw new Error("Failed to obtain fresh access token");
}
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
console.log(`[API Refresh] Retrying ${config.path} with new token.`);
options.headers = {
...options.headers,
Authorization: `Bearer ${accessToken}`,
};
return await next(options);
} catch (refreshError: any) {
throw refreshError;
}
const data = await response.json();
// 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,
Authorization: `Bearer ${accessToken}`,
};
return await next(options);
} catch (refreshError: any) {
// 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,6 +43,8 @@ export const api = createApi({
logout: { method: "POST", path: "auth/logout" },
profile: { method: "GET", path: "auth/profile" },
googleMobile: { method: "POST", path: "auth/google/mobile" },
sendOtp: { method: "POST", path: "auth/phone/otp/send" },
verifyOtp: { method: "POST", path: "auth/phone/otp/verify" },
},
},
invoices: {
@ -51,6 +53,10 @@ export const api = createApi({
stats: { method: "GET", path: "invoices/stats" },
getAll: { method: "GET", path: "invoices" },
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: {
@ -78,6 +84,9 @@ export const api = createApi({
middleware: [authMiddleware],
endpoints: {
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: {
@ -93,6 +102,8 @@ export const api = createApi({
getById: { method: "GET", path: "proforma/:id" },
create: { method: "POST", path: "proforma" },
update: { method: "PUT", path: "proforma/:id" },
delete: { method: "DELETE", path: "proforma/:id" },
getPdf: { method: "GET", path: "proforma/:id/pdf" },
},
},
rbac: {

View File

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

View File

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

View File

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

View File

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

View File

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