expo eject before

This commit is contained in:
elnatansamuel25 2026-05-13 15:21:17 +03:00
parent 6185cdc4d3
commit db5ac60987
25 changed files with 2871 additions and 1404 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,7 +138,6 @@ 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 }}
@ -173,13 +174,11 @@ export default function NewsScreen() {
</View>
</View>
</Card>
</ShadowWrapper>
</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">
@ -217,7 +216,6 @@ export default function NewsScreen() {
</View>
</View>
</Card>
</ShadowWrapper>
</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",
@ -231,7 +238,6 @@ export default function PaymentsScreen() {
</Text>
</Button>
{/* Flagged Section */}
{categorized.flagged.length > 0 && (
<>
@ -241,7 +247,9 @@ export default function PaymentsScreen() {
</Text>
</View>
<View className="gap-2 mb-6">
{categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))}
{categorized.flagged.map((p) =>
renderPaymentItem(p, "flagged"),
)}
</View>
</>
)}

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,8 +184,6 @@ 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}
@ -156,6 +204,18 @@ export default function RootLayout() {
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" }}
@ -185,10 +245,6 @@ export default function RootLayout() {
name="register"
options={{ title: "Create account", headerShown: false }}
/>
<Stack.Screen
name="invoices/[id]"
options={{ title: "Invoice" }}
/>
<Stack.Screen
name="reports/index"
options={{ title: "Reports" }}
@ -206,13 +262,12 @@ export default function RootLayout() {
</Stack>
<SirouBridge />
<BackupGuard />
<SessionHeartbeat />
<PortalHost />
<Toast />
</View>
</ThemeProvider>
</SirouRouterProvider>
</NavigationContainer>
</NavigationIndependentTree>
</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">
{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}
<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}
/>
<Text variant="muted" className="mt-4">
No workers found
</Text>
</View>
)}
</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>
{/* Modern Hero Area */}
<View className="px-5 pt-4">
<View
className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`}
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-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}
>
{invoice.status || "Pending"}
</Text>
</View>
</View>
<Text variant="small" className="text-white/70 mb-0.5">
Total Amount
</Text>
<Text variant="h3" className="text-white font-bold mb-3">
${Number(invoice.amount).toLocaleString()}
</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">
<Text
variant="small"
className="font-bold opacity-60 uppercase text-[10px] tracking-widest"
>
Billing Summary
{status}
</Text>
</View>
<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"
variant="muted"
className="text-xs font-bold uppercase tracking-wider mb-1"
>
Subtotal
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>
<Text variant="p" className="text-foreground font-bold text-sm">
$
{(
Number(invoice.amount) - (Number(invoice.taxAmount) || 0)
{/* Quick Stats Grid */}
<View className="flex-row gap-3 mb-6">
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Calendar size={16} color="#ea580c" className="mb-2" />
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Issue Date
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(
invoice.issueDate || invoice.createdAt,
).toLocaleDateString()}
</Text>
</View>
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Clock size={16} color="#ef4444" className="mb-2" />
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Due Date
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(invoice.dueDate).toLocaleDateString()}
</Text>
</View>
</View>
</View>
{/* 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="muted"
className="text-[10px] uppercase font-bold"
>
Billed To
</Text>
<Text variant="p" className="text-foreground font-bold text-lg">
{invoice.customerName?.replace("Customer Name: ", "") ||
"Walking Client"}
</Text>
</View>
</View>
<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>
)}
<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>
{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
<Text className="text-muted-foreground text-xs">
{item.quantity} units x{" "}
{Number(
item.unitPrice?.value || item.unitPrice || 0,
).toLocaleString()}{" "}
{invoice.currency}
</Text>
</View>
<Text variant="p" className="text-foreground font-bold text-sm">
+ ${Number(invoice.taxAmount).toLocaleString()}
))}
{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
{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>
)}
<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="h3"
className="text-foreground font-semibold text-xl tracking-tight"
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter"
>
${Number(invoice.amount).toLocaleString()}
Verified from data
</Text>
</View>
<Text className="text-primary font-black text-2xl">
{amountValue.toLocaleString()} {invoice.currency}
</Text>
</View>
</Card>
</View>
{/* Notes Section (New) */}
{/* Notes */}
{invoice.notes && (
<Card className="mb-4 bg-card rounded-[6px]">
<View className="p-4">
<View className="px-5 mb-10">
<Text
variant="small"
className="font-bold opacity-60 uppercase text-[10px] tracking-widest mb-2"
variant="muted"
className="text-[10px] uppercase font-bold mb-2"
>
Additional Notes
Note / Description
</Text>
<Text
variant="p"
className="text-foreground font-medium text-xs leading-5"
>
{invoice.notes}
<Text className="text-foreground font-medium italic opacity-80 leading-5">
" {invoice.notes} "
</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 */}
{/* Premium Actions */}
<View className="px-5 gap-3">
<View className="flex-row gap-3">
<Button
className=" flex-1 mb-4 h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
onPress={() => {}}
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={16} strokeWidth={2.5} />
<Text className="ml-2 text-white text-[12px] font-black uppercase tracking-widest">
Share SMS
<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>
<ShadowWrapper>
<Button
className=" flex-1 mb-4 h-12 rounded-[10px] bg-card border border-border"
onPress={() => {}}
variant="outline"
className="flex-1 h-14 rounded-[6px] bg-card border border-border"
onPress={handleGetPdf}
>
<Download color="#0f172a" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-foreground text-[12px] font-black uppercase tracking-widest">
<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;
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", // Placeholder: replace with your actual iOS Client ID from Google Cloud Console
"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,25 +215,69 @@ 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>
{loginMode === "email" && (
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Password
@ -202,13 +294,17 @@ export default function LoginScreen() {
/>
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<EyeOff
size={18}
color={isDark ? "#94a3b8" : "#64748b"}
/>
) : (
<Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} />
)}
</Pressable>
</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} />
{/* 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>
<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
<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>
)}
{/* 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>
<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
{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="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
{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 className="h-3 w-[1px] bg-white/60" />
<Text className="text-white/90 text-xs font-semibold">
Telebirr SMS
)}
</View>
<Text
variant="muted"
className="text-[10px] font-black uppercase tracking-[3px] mb-2 opacity-60"
>
Total Transaction Amount
</Text>
<View className="flex-row items-end gap-3 mb-8">
<Text
variant="h1"
className="text-4xl font-black text-foreground tracking-tighter"
>
{amountValue.toLocaleString()}
</Text>
<Text className="text-2xl font-black text-primary mb-1">
{payment.currency || "USD"}
</Text>
</View>
</View>
{/* Core Info Grid */}
<View className="flex-row gap-3 mb-6">
<Card className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
<Building2 size={18} color="#ea580c" className="mb-3" />
<Text
variant="muted"
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
>
Merchant
</Text>
<Text
className="text-foreground font-black text-sm"
numberOfLines={1}
>
{extracted.merchantName ||
payment.merchantName ||
"Unknown Merchant"}
</Text>
</Card>
{/* Transaction Details */}
<Text variant="h4" className="text-foreground mt-4 mb-2">
Transaction Details
<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>
<Card className="bg-card rounded-[6px] mb-3">
<View className="p-4">
<View className="flex-row items-center justify-between">
{/* 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">
<Clock color="#000" size={13} />
<Text variant="muted" className="text-sm">
Received On
<Hash size={14} color="#64748b" />
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
{payment.transactionId || "INTERNAL-TXN"}
</Text>
</View>
<Text variant="p" className="text-foreground text-sm">
Sep 11, 2022 · 14:30
</Text>
</View>
<View className="h-[1px] bg-border/70 my-3" />
<View className="flex-row items-center justify-between py-1">
<View className="flex-row items-center gap-2">
<Link2 color="#000" size={13} />
<Text variant="muted" className="text-sm">
Status
</Text>
</View>
<View className="bg-amber-500/10 px-2.5 py-1 rounded-[4px]">
<Text className="text-amber-600 text-xs font-semibold">
Awaiting Link
<Clock size={14} color="#64748b" />
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
{paymentDate.toLocaleString()}
</Text>
</View>
</View>
</View>
</Card>
</View>
{/* 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."
{/* 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>
</Card>
<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>
)}
{/* 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
{/* 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>
<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>
<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>
<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>
{/* 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-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 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 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>
{/* 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="muted"
className="text-[10px] uppercase font-bold"
>
Quotation For
</Text>
<Text variant="p" className="text-foreground font-bold text-lg">
{proforma.customerName || "Interested Client"}
</Text>
</View>
</View>
<View className="flex-row flex-wrap gap-4 pt-4 border-t border-primary/10">
{proforma.customerEmail && (
<View className="flex-row items-center gap-2">
<CreditCard size={12} color="#64748b" />
<Text className="text-muted-foreground text-xs">
{proforma.customerEmail}
</Text>
</View>
)}
</View>
</View>
</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
<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>
</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>
</View>
</View>
</Card>
{/* Line Items Card */}
<Card className="bg-card rounded-[6px] mb-4">
<View className="p-4">
<View className="flex-row items-center gap-2 mb-2">
<Text
variant="small"
className="font-bold uppercase tracking-widest text-[10px] opacity-60"
>
Line Items
{/* Detailed Items Table */}
<View className="px-5 mb-6">
<Text variant="h4" className="font-bold mb-4 px-1">
Estimate Summary
</Text>
</View>
{proforma.items?.map((item: any, i: number) => (
<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" : ""}`}
>
<View className="flex-1 pr-4">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
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 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>
))}
{items.length === 0 && (
<View className="p-8 items-center bg-muted/20">
<Package size={32} color="#cbd5e1" className="mb-2" />
<Text variant="muted">Empty line items list</Text>
</View>
)}
</Card>
</View>
<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
{/* 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-bold text-sm">
{proforma.currency} {subtotal.toLocaleString()}
<Text className="text-foreground font-bold">
{subtotalValue.toLocaleString()} {proforma.currency}
</Text>
</View>
{Number(proforma.taxAmount) > 0 && (
<View className="flex-row justify-between">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
>
Tax
{taxAmountValue > 0 && (
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Estimated Tax
</Text>
<Text
variant="p"
className="text-foreground font-bold text-sm"
>
{proforma.currency}{" "}
{Number(proforma.taxAmount).toLocaleString()}
<Text className="text-emerald-500 font-bold">
+{taxAmountValue.toLocaleString()} {proforma.currency}
</Text>
</View>
)}
{Number(proforma.discountAmount) > 0 && (
<View className="flex-row justify-between">
<Text
variant="p"
className="text-red-500 font-semibold text-sm"
>
Discount
{discountValue > 0 && (
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Applicable Discount
</Text>
<Text variant="p" className="text-red-500 font-bold text-sm">
-{proforma.currency}{" "}
{Number(proforma.discountAmount).toLocaleString()}
<Text className="text-rose-500 font-bold">
-{discountValue.toLocaleString()} {proforma.currency}
</Text>
</View>
)}
<View className="flex-row justify-between items-center mt-1">
<Text variant="p" className="text-foreground font-bold">
Total Amount
<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="h4"
className="text-foreground font-bold tracking-tight"
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter"
>
{proforma.currency} {Number(proforma.amount).toLocaleString()}
Valid as of today
</Text>
</View>
</View>
<Text className="text-primary font-black text-2xl">
{amountValue.toLocaleString()} {proforma.currency}
</Text>
</View>
</Card>
</View>
{/* Notes Section (New) */}
{/* Notes */}
{proforma.notes && (
<Card className="bg-card rounded-[6px] mb-4">
<View className="p-4">
<View className="px-5 mb-10">
<Text
variant="small"
className="font-bold uppercase tracking-widest text-[10px] opacity-60 mb-2"
variant="muted"
className="text-[10px] uppercase font-bold mb-2"
>
Additional Notes
Internal Notes
</Text>
<Text
variant="p"
className="text-foreground font-medium text-xs leading-5"
>
{proforma.notes}
<Text className="text-foreground font-medium italic opacity-80 leading-5">
" {proforma.notes} "
</Text>
</View>
</Card>
)}
{/* Actions */}
<View className="gap-3">
{/* Premium Actions */}
<View className="px-5 gap-3">
<View className="flex-row gap-3">
<Button
className="h-12 rounded-[10px] bg-transparent border border-border"
onPress={() => router.push("/proforma/edit?id=" + proforma.id)}
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
onPress={() => nav.go("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
<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
className="h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
onPress={() => {}}
variant="outline"
className="flex-1 h-14 rounded-[6px] bg-card border border-border"
onPress={handleGetPdf}
>
<Send color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
Share SMS
<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
variant="ghost"
className="h-14 rounded-[6px] border border-rose-500/10"
onPress={handleDelete}
>
<Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">
Delete Proforma
</Text>
</Button>
</View>
</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
<PickerModal
visible={issueModal}
title="Select Issue Date"
onClose={() => setIssueModal(false)}
>
<CalendarGrid
open={issueModal}
current={issueDate.toISOString().substring(0,10)}
onDateSelect={(dateStr: string) => {
onSelect={(dateStr: string) => {
setIssueDate(new Date(dateStr));
setIssueModal(false);
}}
onClose={() => setIssueModal(false)}
selectedDate={issueDate.toISOString().substring(0, 10)}
/>
</PickerModal>
// @ts-ignore
<PickerModal
visible={dueModal}
title="Select Due Date"
onClose={() => setDueModal(false)}
>
<CalendarGrid
open={dueModal}
current={dueDate.toISOString().substring(0,10)}
onDateSelect={(dateStr: string) => {
onSelect={(dateStr: string) => {
setDueDate(new Date(dateStr));
setDueModal(false);
}}
onClose={() => 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,83 +184,37 @@ 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) {
// 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}. Attempting refresh...`,
`[API Refresh] 401 detected for ${config.path}. Forcing refresh...`,
);
try {
// We call the refresh endpoint manually here to avoid circular dependencies with the 'api' object
const refreshUrl = `${config.baseUrl}auth/refresh`;
const response = await fetch(refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
refreshToken,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const refreshErr = new Error(
errorData.message ||
`Refresh failed with status ${response.status}`,
) as any;
refreshErr.status = response.status;
throw refreshErr;
}
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;
const accessToken = await refreshTokens(config, true);
if (!accessToken) {
throw new Error("No access token returned from refresh");
throw new Error("Failed to obtain fresh access token");
}
setAuth(user, accessToken, newRefreshToken);
console.log("[API Refresh] Success. Retrying original request...");
// Update headers and retry
// 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) {
// 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;
}
}
}
throw error;
}

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) =>

View File

@ -1,418 +0,0 @@
{
"app": {
"yaltopia_tickets_app": "Yaltopia Tickets App",
"scan_send_reconcile": "Scan. Send. Reconcile.",
"get_started": "Get started"
},
"index": {
"available_balance": "Available Balance",
"": "·",
"pending": "Pending",
"income": "Income",
"company": "Company",
"scan_sms": "Scan SMS",
"create_proforma": "Create Proforma",
"history": "History",
"recent_activity": "Recent Activity",
"view_all": "View all",
"proforma": "·\n Proforma",
"no_transactions_yet": "No transactions yet",
"create_a_proforma_invoice_to": "Create a proforma invoice to get started.",
"documents": "Documents",
"uploaded_invoices_scans_and_attachments": "Uploaded invoices, scans, and attachments. Synced with your account.",
"upload_document": "Upload document",
"back": "Back",
"notifications": "Notifications",
"no_notifications": "No notifications",
"pull_to_refresh_to_check": "Pull to refresh to check for new notifications.",
"reports": "Reports",
"monthly_reports_and_pdf_exports": "Monthly reports and PDF exports. Generate from the web app or view here.",
"generated": "Generated"
},
"news": {
"tap_to_read_more": "Tap to read more",
"latest_news": "Latest News",
"no_latest_updates": "No latest updates",
"pull_to_refresh_to_check": "Pull to refresh to check again.",
"all_news": "All News",
"load_more": "Load More",
"no_news_yet": "No news yet",
"pull_to_refresh_to_fetch": "Pull to refresh to fetch the latest posts."
},
"payments": {
"": "·",
"flagged": "Flagged",
"match": "Match",
"create_payment_request": "Create Payment Request",
"flagged_payments": "Flagged Payments",
"pending_match": "Pending Match",
"no_pending_payments": "No pending payments",
"upload_receipts_or_scan_sms": "Upload receipts or scan SMS to add payments.",
"reconciled": "Reconciled",
"no_reconciled_payments": "No reconciled payments",
"match_pending_payments_to_invoices": "Match pending payments to invoices for reconciliation."
},
"proforma": {
"issued": "Issued:",
"due": "| Due:",
"": "|",
"item": "item",
"create_new_proforma": "Create New Proforma",
"no_proformas_yet": "No proformas yet",
"tap_the_button_above_to": "Tap the button above to create a new proforma."
},
"scan": {
"camera_access": "Camera Access",
"we_need_your_permission_to": "We need your permission to use the camera to scan invoices and\n receipts automatically.",
"enable_camera": "Enable Camera",
"go_back": "Go Back",
"align_invoice_within_frame": "Align Invoice Within Frame"
},
"company-details": {
"company_details": "Company details",
"company_name": "Company Name",
"tin": "TIN",
"contact_information": "Contact Information",
"phone": "Phone",
"email": "Email",
"website": "Website",
"address": "Address",
"street_address": "Street Address",
"city": "City",
"state": "State",
"zip_code": "Zip Code",
"country": "Country",
"system_information": "System Information",
"user_id": "User ID",
"created": "Created",
"last_updated": "Last Updated"
},
"company": {
"company": "Company",
"search_workers": "Search workers...",
"workers": "Workers (",
"": ")",
"no_workers_found": "No workers found"
},
"edit-profile": {
"edit_profile": "Edit Profile",
"first_name": "First Name",
"enter_first_name": "Enter first name",
"last_name": "Last Name",
"enter_last_name": "Enter last name",
"save_changes": "Save Changes",
"cancel": "Cancel"
},
"help": {
"help_support": "Help & Support",
"faq": "FAQ",
"quick_answers_to_common_questions": "Quick answers to common questions.",
"need_more_help": "Need more help?",
"placeholder_add_contact_info_emailphonewhatsapp": "Placeholder — add contact info (email/phone/WhatsApp) or a support chat link here."
},
"history": {
"activity_history": "Activity History",
"total_inflow": "Total Inflow",
"": "$",
"pending": "Pending",
"all_activity": "All Activity",
"proforma": "·\n Proforma",
"no_activity_yet": "No activity yet",
"create_a_proforma_invoice_to": "Create a proforma invoice to generate your first activity."
},
"[id]": {
"200000": "$2,000.00",
"invoice_details": "Invoice Details",
"invoice_not_found": "Invoice not found",
"total_amount": "Total Amount",
"": "-",
"due": "Due",
"recipient": "Recipient",
"category": "Category",
"general": "General",
"billing_summary": "Billing Summary",
"subtotal": "Subtotal",
"tax": "Tax",
"total_balance": "Total Balance",
"additional_notes": "Additional Notes",
"created": "Created",
"last_updated": "Last Updated",
"share_sms": "Share SMS",
"get_pdf": "Get PDF",
"payment_match": "Payment Match",
"pending_match": "Pending Match",
"received_amount": "Received Amount",
"txn9982734": "TXN-9982734",
"telebirr_sms": "Telebirr SMS",
"transaction_details": "Transaction Details",
"received_on": "Received On",
"sep_11_2022_1430": "Sep 11, 2022 · 14:30",
"status": "Status",
"awaiting_link": "Awaiting Link",
"original_sms": "Original SMS",
"payment_received_from_elnatan_jansen": "\"Payment received from Elnatan Jansen for order #2322 via\n Telebirr. Amount: $2,000. Ref: B88-22X7.\"",
"associate_to_invoice": "Associate to Invoice",
"proforma": "Proforma",
"proforma_not_found": "Proforma not found",
"proforma_details": "Proforma Details",
"proforma_number": "Proforma Number",
"issued_date": "Issued Date",
"due_date": "Due Date",
"currency": "Currency",
"description": "Description",
"customer_information": "Customer Information",
"name": "Name",
"email": "Email",
"phone": "Phone",
"line_items": "Line Items",
"discount": "Discount",
"edit": "Edit"
},
"login": {
"login": "Login",
"sign_in_to_manage_your": "Sign in to manage your tickets & invoices",
"email_or_phone_number": "Email or Phone Number",
"johnexamplecom_or_251": "john@example.com or +251...",
"password": "Password",
"": "••••••••",
"sign_in": "Sign In",
"or": "or",
"continue_with_google": "Continue with Google",
"dont_have_an_account": "Don't have an account?",
"create_one": "Create one"
},
"settings": {
"notification_settings": "Notification settings",
"preferences": "Preferences",
"invoice_reminders": "Invoice reminders",
"get_reminders_before_invoices_are": "Get reminders before invoices are due",
"days_before_due_date": "Days before due date",
"currently": "Currently:",
"days": "days",
"news_alerts": "News alerts",
"product_updates_and_announcements": "Product updates and announcements",
"report_ready": "Report ready",
"notify_when_reports_are_generated": "Notify when reports are generated",
"select_days": "Select Days",
"settings": "Settings",
"notifications": "Notifications",
"language": "Language",
"english": "English",
"about": "About",
"yaltopia_tickets_app_v10_scan": "Yaltopia Tickets App v1.0 — Scan. Send. Reconcile.",
"api_invoices_proforma_payments_reports": "API: Invoices, Proforma, Payments, Reports, Documents, Notifications —\n see swagger.json and README for integration.",
"back": "Back"
},
"create": {
"0": "0",
"1": "1",
"251": "+251...",
"1500": "1500",
"123456789": "123456789",
"create_payment_request": "Create Payment Request",
"general_information": "General Information",
"payment_request_number": "Payment Request Number",
"eg_payreq2024001": "e.g. PAYREQ-2024-001",
"description": "Description",
"eg_payment_request_for_services": "e.g. Payment request for services",
"customer_details": "Customer Details",
"customer_name": "Customer Name",
"eg_acme_corporation": "e.g. Acme Corporation",
"email": "Email",
"billingacmecom": "billing@acme.com",
"phone": "Phone",
"customer_id": "Customer ID",
"optional": "Optional",
"schedule_currency": "Schedule & Currency",
"issue_date": "Issue Date",
"due_date": "Due Date",
"currency": "Currency",
"status": "Status",
"amount": "Amount",
"payment_id": "Payment ID",
"pay123456": "PAY-123456",
"items": "Items",
"add_item": "Add Item",
"item": "Item",
"eg_web_development_service": "e.g. Web Development Service",
"qty": "Qty",
"unit_price": "Unit Price",
"000": "0.00",
"total": "Total",
"accounts": "Accounts",
"add_account": "Add Account",
"account": "Account",
"bank_name": "Bank Name",
"eg_yaltopia_bank": "e.g. Yaltopia Bank",
"account_name": "Account Name",
"eg_yaltopia_tech_plc": "e.g. Yaltopia Tech PLC",
"account_number": "Account Number",
"etb": "ETB",
"totals_taxes": "Totals & Taxes",
"subtotal": "Subtotal",
"tax": "Tax",
"discount": "Discount",
"notes": "Notes",
"eg_payment_terms_net_30": "e.g. Payment terms: Net 30",
"total_amount": "Total Amount",
"discard": "Discard",
"create_request": "Create Request",
"select_currency": "Select Currency",
"select_status": "Select Status",
"select_issue_date": "Select Issue Date",
"select_due_date": "Select Due Date",
"create_proforma": "Create Proforma",
"proforma_number": "Proforma Number",
"eg_prof2024001": "e.g. PROF-2024-001",
"project_description": "Project Description",
"eg_web_development_services": "e.g. Web Development Services",
"eg_acme_corp": "e.g. Acme Corp",
"billable_items": "Billable Items",
"eg_ui_design": "e.g. UI Design",
"price": "Price",
"eg_payment_due_within_30": "e.g. Payment due within 30 days",
"add_new_user": "Add New User",
"user_details": "User Details",
"configure_credentials_and_system_access": "Configure credentials and system access",
"first_name": "First Name",
"last_name": "Last Name",
"email_address": "Email Address",
"emailcompanycom": "email@company.com",
"phone_number": "Phone Number",
"911_234_567": "911 234 567",
"system_role": "System Role",
"initial_password": "Initial Password",
"": "••••••••",
"create_user": "Create User",
"select_system_role": "Select System Role"
},
"privacy": {
"privacy_policy": "Privacy Policy",
"last_updated_march_10_2026": "Last updated: March 10, 2026",
"1_introduction": "1. Introduction",
"this_privacy_policy_describes_how": "This Privacy Policy describes how we collect, use, and share your personal information when you use our mobile application (\"App\").",
"2_information_we_collect": "2. Information We Collect",
"we_may_collect_information_about": "We may collect information about you in various ways, including:",
"personal_information_you_provide_directly": "• Personal information you provide directly to us",
"information_we_collect_automatically_when": "• Information we collect automatically when you use the App",
"information_from_thirdparty_services": "• Information from third-party services",
"3_how_we_use_your": "3. How We Use Your Information",
"we_use_the_information_we": "We use the information we collect to:",
"provide_and_maintain_our_services": "• Provide and maintain our services",
"process_transactions_and_send_related": "• Process transactions and send related information",
"communicate_with_you_about_our": "• Communicate with you about our services",
"4_information_sharing": "4. Information Sharing",
"we_do_not_sell_trade": "We do not sell, trade, or otherwise transfer your personal information to third parties without your consent, except as described in this policy.",
"5_data_security": "5. Data Security",
"we_implement_appropriate_security_measures": "We implement appropriate security measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction.",
"6_contact_us": "6. Contact Us",
"if_you_have_any_questions": "If you have any questions about this Privacy Policy, please contact us at privacy@example.com."
},
"profile": {
"profile": "Profile",
"account": "Account",
"transaction_history": "Transaction History",
"preferences": "Preferences",
"appearance": "Appearance",
"language": "Language",
"security": "Security",
"support_legal": "Support & Legal",
"help_support": "Help & Support",
"privacy_policy": "Privacy Policy",
"terms_of_use": "Terms of Use",
"log_out": "Log Out"
},
"edit": {
"0": "0",
"proforma_details": "Proforma Details",
"proforma_number": "Proforma Number",
"enter_proforma_number": "Enter proforma number",
"description": "Description",
"brief_description": "Brief description",
"notes": "Notes",
"additional_notes": "Additional notes",
"customer_details": "Customer Details",
"customer_name": "Customer Name",
"enter_customer_name": "Enter customer name",
"customer_email": "Customer Email",
"enter_customer_email": "Enter customer email",
"customer_phone": "Customer Phone",
"enter_customer_phone": "Enter customer phone",
"dates": "Dates",
"issue_date": "Issue Date:",
"due_date": "Due Date:",
"items": "Items",
"add_item": "Add Item",
"item_description": "Item description",
"qty": "Qty",
"price": "Price",
"000": "0.00",
"totals": "Totals",
"subtotal": "Subtotal",
"tax_amount": "Tax Amount",
"discount_amount": "Discount Amount",
"total": "Total",
"currency": "Currency",
"select_currency": "Select Currency",
"tsignore": "// @ts-ignore"
},
"register": {
"251": "+251",
"create_account": "Create Account",
"join_yaltopia_and_start_managing": "Join Yaltopia and start managing your business",
"first_name": "First Name",
"john": "John",
"last_name": "Last Name",
"doe": "Doe",
"email_address": "Email Address",
"johnexamplecom": "john@example.com",
"phone_number": "Phone Number",
"911_234_567": "911 234 567",
"password": "Password",
"": "••••••••",
"already_have_an_account": "Already have an account?",
"sign_in": "Sign In"
},
"sms-scan": {
"scan_sms": "Scan SMS",
"finds_banking_messages_from_the": "Finds banking messages from the last 5 minutes",
"tap_scan_now_to_search": "Tap \"Scan Now\" to search for CBE, Dashen Bank, and Telebirr\n messages from the last 5 minutes.",
"no_banking_messages_found_in": "No banking messages found in the last 5 minutes.",
"amount": "Amount",
"etb": "ETB",
"reference": "Reference",
"": "\""
},
"terms": {
"terms_of_service": "Terms of Service",
"last_updated_march_10_2026": "Last updated: March 10, 2026",
"1_acceptance_of_terms": "1. Acceptance of Terms",
"by_accessing_and_using_our": "By accessing and using our mobile application, you accept and agree to be bound by the terms and provision of this agreement.",
"2_use_of_service": "2. Use of Service",
"our_service_is_provided_as": "Our service is provided \"as is\" and \"as available\" without warranties of any kind. You agree to use the service at your own risk.",
"3_user_accounts": "3. User Accounts",
"when_you_create_an_account": "When you create an account with us, you must provide information that is accurate, complete, and current at all times.",
"4_prohibited_uses": "4. Prohibited Uses",
"you_may_not_use_our": "You may not use our service:",
"for_any_unlawful_purpose_or": "• For any unlawful purpose or to solicit others to perform unlawful acts",
"to_violate_any_international_federal": "• To violate any international, federal, provincial, or state regulations, rules, laws, or local ordinances",
"to_infringe_upon_or_violate": "• To infringe upon or violate our intellectual property rights or the intellectual property rights of others",
"5_termination": "5. Termination",
"we_may_terminate_or_suspend": "We may terminate or suspend your account and bar access to the service immediately, without prior notice or liability, under our sole discretion.",
"6_limitation_of_liability": "6. Limitation of Liability",
"in_no_event_shall_our": "In no event shall our company, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages.",
"7_changes_to_terms": "7. Changes to Terms",
"we_reserve_the_right_at": "We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.",
"8_contact_information": "8. Contact Information",
"if_you_have_any_questions": "If you have any questions about these Terms of Service, please contact us at terms@example.com."
},
"languagemodal": {
"language": "Language"
},
"standardheader": {
"welcome_back": "Welcome back,"
},
"thememodal": {
"appearance": "Appearance"
}
}

View File

@ -1,4 +0,0 @@
{
"frameworks": ["react"],
"localesDir": "./locales"
}

View File

@ -1,19 +0,0 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from '../../locales/en.json';
// Localey Professional Initialization
i18n
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
},
lng: 'en',
fallbackLng: 'en',
interpolation: {
escapeValue: false, // React already safes from XSS
},
});
export default i18n;

View File

@ -1,10 +0,0 @@
import { useTranslation } from 'react-i18next';
import { useI18nStore } from './store';
export const useLocaley = () => {
const { t } = useTranslation();
const locale = useI18nStore((state) => state.locale);
const setLocale = useI18nStore((state) => state.setLocale);
return { t, locale, setLocale };
};

View File

@ -1,15 +0,0 @@
import { create } from 'zustand';
import i18n from './config';
interface I18nState {
locale: string;
setLocale: (locale: string) => void;
}
export const useI18nStore = create<I18nState>((set) => ({
locale: i18n.language || 'en',
setLocale: (locale: string) => {
i18n.changeLanguage(locale);
set({ locale });
},
}));