expo eject before
This commit is contained in:
parent
6185cdc4d3
commit
db5ac60987
|
|
@ -22,7 +22,7 @@ import { ShadowWrapper } from "@/components/ShadowWrapper";
|
|||
import { useAuthStore } from "@/lib/auth-store";
|
||||
|
||||
const { width } = Dimensions.get("window");
|
||||
const LATEST_CARD_WIDTH = width * 0.8;
|
||||
const LATEST_CARD_WIDTH = width * 0.6;
|
||||
|
||||
interface NewsItem {
|
||||
id: string;
|
||||
|
|
@ -36,7 +36,9 @@ interface NewsItem {
|
|||
|
||||
export default function NewsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const permissions = useAuthStore((s: { permissions: string[] }) => s.permissions);
|
||||
const permissions = useAuthStore(
|
||||
(s: { permissions: string[] }) => s.permissions,
|
||||
);
|
||||
|
||||
// Safe accessor to handle initialization race conditions
|
||||
const getNewsApi = () => {
|
||||
|
|
@ -136,88 +138,84 @@ export default function NewsScreen() {
|
|||
|
||||
const LatestItem = ({ item }: { item: NewsItem }) => (
|
||||
<Pressable className="mr-4" key={item.id}>
|
||||
<ShadowWrapper level="md">
|
||||
<Card
|
||||
className="overflow-hidden rounded-[20px] bg-card border-border/50"
|
||||
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
|
||||
>
|
||||
<View className="p-5 flex-1 justify-between">
|
||||
<View>
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<View
|
||||
className={`px-2 py-0.5 rounded-full ${getCategoryColor(item.category)}`}
|
||||
>
|
||||
<Text className="text-[8px] font-black text-white uppercase tracking-tighter">
|
||||
{item.category}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="muted" className="text-[10px] font-bold">
|
||||
{new Date(item.publishedAt).toLocaleDateString()}
|
||||
<Card
|
||||
className="overflow-hidden rounded-[20px] bg-card border-border/50"
|
||||
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
|
||||
>
|
||||
<View className="p-5 flex-1 justify-between">
|
||||
<View>
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<View
|
||||
className={`px-2 py-0.5 rounded-full ${getCategoryColor(item.category)}`}
|
||||
>
|
||||
<Text className="text-[8px] font-black text-white uppercase tracking-tighter">
|
||||
{item.category}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-foreground font-black text-lg leading-tight"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.title}
|
||||
<Text variant="muted" className="text-[10px] font-bold">
|
||||
{new Date(item.publishedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-foreground font-black text-lg leading-tight"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text variant="muted" className="text-xs font-medium opacity-60">
|
||||
Tap to read more
|
||||
</Text>
|
||||
<View className="bg-primary/10 p-1.5 rounded-full">
|
||||
<ChevronRight color="#ea580c" size={14} strokeWidth={3} />
|
||||
</View>
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text variant="muted" className="text-xs font-medium opacity-60">
|
||||
Tap to read more
|
||||
</Text>
|
||||
<View className="bg-primary/10 p-1.5 rounded-full">
|
||||
<ChevronRight color="#ea580c" size={14} strokeWidth={3} />
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</ShadowWrapper>
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const NewsItem = ({ item }: { item: NewsItem }) => (
|
||||
<Pressable className="mb-4" key={item.id}>
|
||||
<ShadowWrapper level="xs">
|
||||
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-2 mb-1.5">
|
||||
<View
|
||||
className={`w-1.5 h-1.5 rounded-full ${getCategoryColor(item.category)}`}
|
||||
/>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] font-black uppercase tracking-widest opacity-60"
|
||||
>
|
||||
{item.category}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-foreground font-bold text-sm mb-1"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-2 mb-1.5">
|
||||
<View
|
||||
className={`w-1.5 h-1.5 rounded-full ${getCategoryColor(item.category)}`}
|
||||
/>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[11px] leading-relaxed"
|
||||
numberOfLines={2}
|
||||
className="text-[10px] font-black uppercase tracking-widest opacity-60"
|
||||
>
|
||||
{item.content}
|
||||
{item.category}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-foreground font-bold text-sm mb-1"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[11px] leading-relaxed"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.content}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-3 mt-3">
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Clock color="#94a3b8" size={10} strokeWidth={2.5} />
|
||||
<Text variant="muted" className="text-[10px] font-medium">
|
||||
{new Date(item.publishedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-3 mt-3">
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Clock color="#94a3b8" size={10} strokeWidth={2.5} />
|
||||
<Text variant="muted" className="text-[10px] font-medium">
|
||||
{new Date(item.publishedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</ShadowWrapper>
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ export default function PaymentsScreen() {
|
|||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
// Check permissions
|
||||
const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]);
|
||||
const canCreatePayments = hasPermission(
|
||||
permissions,
|
||||
PERMISSION_MAP["payments:create"],
|
||||
);
|
||||
|
||||
const fetchPayments = useCallback(
|
||||
async (pageNum: number, isRefresh = false) => {
|
||||
|
|
@ -124,6 +127,10 @@ export default function PaymentsScreen() {
|
|||
reconciled: payments.filter((p) => p.invoiceId && !p.isFlagged),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log(payments);
|
||||
}, [payments]);
|
||||
|
||||
const renderPaymentItem = (
|
||||
pay: Payment,
|
||||
type: "reconciled" | "pending" | "flagged",
|
||||
|
|
@ -208,7 +215,7 @@ export default function PaymentsScreen() {
|
|||
<ScreenWrapper className="bg-background">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingBottom: 150 }}
|
||||
contentContainerStyle={{ paddingBottom: 150 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={({ nativeEvent }) => {
|
||||
const isCloseToBottom =
|
||||
|
|
@ -231,70 +238,71 @@ export default function PaymentsScreen() {
|
|||
</Text>
|
||||
</Button>
|
||||
|
||||
|
||||
{/* Flagged Section */}
|
||||
{categorized.flagged.length > 0 && (
|
||||
<>
|
||||
<View className="mb-4 flex-row items-center gap-3">
|
||||
<Text variant="h4" className="text-red-600">
|
||||
Flagged Payments
|
||||
</Text>
|
||||
</View>
|
||||
<View className="gap-2 mb-6">
|
||||
{categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pending Section */}
|
||||
<View className="mb-4 flex-row items-center gap-3">
|
||||
<Text variant="h4" className="text-foreground">
|
||||
Pending Match
|
||||
</Text>
|
||||
</View>
|
||||
<View className="gap-2 mb-6">
|
||||
{categorized.pending.length > 0 ? (
|
||||
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
|
||||
) : (
|
||||
<View className="py-1">
|
||||
<EmptyState
|
||||
title="No pending payments"
|
||||
description="Payments that haven't been matched to invoices yet will appear here."
|
||||
hint="Upload receipts or scan SMS to add payments."
|
||||
previewLines={3}
|
||||
/>
|
||||
</View>
|
||||
{/* Flagged Section */}
|
||||
{categorized.flagged.length > 0 && (
|
||||
<>
|
||||
<View className="mb-4 flex-row items-center gap-3">
|
||||
<Text variant="h4" className="text-red-600">
|
||||
Flagged Payments
|
||||
</Text>
|
||||
</View>
|
||||
<View className="gap-2 mb-6">
|
||||
{categorized.flagged.map((p) =>
|
||||
renderPaymentItem(p, "flagged"),
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Reconciled Section */}
|
||||
<View className="mb-4 flex-row items-center gap-3">
|
||||
<Text variant="h4" className="text-foreground">
|
||||
Reconciled
|
||||
</Text>
|
||||
</View>
|
||||
<View className="gap-2">
|
||||
{categorized.reconciled.length > 0 ? (
|
||||
categorized.reconciled.map((p) =>
|
||||
renderPaymentItem(p, "reconciled"),
|
||||
)
|
||||
) : (
|
||||
<View className="py-4">
|
||||
<EmptyState
|
||||
title="No reconciled payments"
|
||||
description="Payments matched to invoices will show up here once reconciled."
|
||||
hint="Match pending payments to invoices for reconciliation."
|
||||
previewLines={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{loadingMore && (
|
||||
<View className="py-4">
|
||||
<ActivityIndicator color={PRIMARY} />
|
||||
{/* Pending Section */}
|
||||
<View className="mb-4 flex-row items-center gap-3">
|
||||
<Text variant="h4" className="text-foreground">
|
||||
Pending Match
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="gap-2 mb-6">
|
||||
{categorized.pending.length > 0 ? (
|
||||
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
|
||||
) : (
|
||||
<View className="py-1">
|
||||
<EmptyState
|
||||
title="No pending payments"
|
||||
description="Payments that haven't been matched to invoices yet will appear here."
|
||||
hint="Upload receipts or scan SMS to add payments."
|
||||
previewLines={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Reconciled Section */}
|
||||
<View className="mb-4 flex-row items-center gap-3">
|
||||
<Text variant="h4" className="text-foreground">
|
||||
Reconciled
|
||||
</Text>
|
||||
</View>
|
||||
<View className="gap-2">
|
||||
{categorized.reconciled.length > 0 ? (
|
||||
categorized.reconciled.map((p) =>
|
||||
renderPaymentItem(p, "reconciled"),
|
||||
)
|
||||
) : (
|
||||
<View className="py-4">
|
||||
<EmptyState
|
||||
title="No reconciled payments"
|
||||
description="Payments matched to invoices will show up here once reconciled."
|
||||
hint="Match pending payments to invoices for reconciliation."
|
||||
previewLines={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{loadingMore && (
|
||||
<View className="py-4">
|
||||
<ActivityIndicator color={PRIMARY} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenWrapper>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
263
app/_layout.tsx
263
app/_layout.tsx
|
|
@ -6,18 +6,28 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|||
import { Toast } from "@/components/Toast";
|
||||
import "@/global.css";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { View, ActivityIndicator, InteractionManager } from "react-native";
|
||||
import {
|
||||
View,
|
||||
ActivityIndicator,
|
||||
InteractionManager,
|
||||
AppState,
|
||||
} from "react-native";
|
||||
import { useRestoreTheme, NAV_THEME } from "@/lib/theme";
|
||||
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
|
||||
import { NavigationContainer, NavigationIndependentTree, ThemeProvider } from "@react-navigation/native";
|
||||
import { refreshTokens } from "@/lib/api-middlewares";
|
||||
import {
|
||||
NavigationContainer,
|
||||
NavigationIndependentTree,
|
||||
ThemeProvider,
|
||||
} from "@react-navigation/native";
|
||||
import { routes } from "@/lib/routes";
|
||||
import { authGuard, guestGuard } from "@/lib/auth-guards";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
import { useFonts } from 'expo-font';
|
||||
import { useFonts } from "expo-font";
|
||||
import { api } from "@/lib/api";
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
import { useSegments } from "expo-router";
|
||||
import { useSegments, useLocalSearchParams, useRouter } from "expo-router";
|
||||
|
||||
function BackupGuard() {
|
||||
const segments = useSegments();
|
||||
|
|
@ -30,18 +40,57 @@ function BackupGuard() {
|
|||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
// Intentionally disabled: redirecting here can happen before the root layout
|
||||
// navigator is ready and cause "Attempted to navigate before mounting".
|
||||
// Sirou guards handle redirects.
|
||||
}, [segments, isAuthed, isMounted]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionHeartbeat: Proactively refreshes tokens every 5 minutes and upon app foregrounding.
|
||||
*/
|
||||
function SessionHeartbeat() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
// Refresh every 5 minutes
|
||||
const INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
const performRefresh = async (reason: string) => {
|
||||
try {
|
||||
console.log(`[SessionHeartbeat] Refresh triggered by: ${reason}`);
|
||||
await refreshTokens();
|
||||
} catch (err) {
|
||||
console.warn(`[SessionHeartbeat] Refresh failed (${reason}):`, err);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Initial/Interval Refresh
|
||||
performRefresh("Mount"); // Refresh immediately on mount
|
||||
const interval = setInterval(() => performRefresh("Interval"), INTERVAL_MS);
|
||||
|
||||
// 2. Foreground Refresh (AppState listener)
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (nextAppState === "active") {
|
||||
performRefresh("Foreground");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
subscription.remove();
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function SirouBridge() {
|
||||
const sirou = useSirouRouter();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const params = useLocalSearchParams();
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
|
|
@ -53,19 +102,20 @@ function SirouBridge() {
|
|||
if (!isMounted) return;
|
||||
|
||||
const checkAuth = async () => {
|
||||
// Create EXACT name from segments: (tabs), index => (tabs)/index
|
||||
// Use "root" if segments are empty (initial layout)
|
||||
const routeName = segments.length > 0 ? segments.join("/") : "root";
|
||||
|
||||
console.log(`[SirouBridge] checking route: "${routeName}"`);
|
||||
console.log(
|
||||
`[SirouBridge] checking route: "${routeName}" with params:`,
|
||||
params,
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await (sirou as any).checkGuards(routeName);
|
||||
const result = await (sirou as any).checkGuards(routeName, params);
|
||||
if (!result.allowed && result.redirect) {
|
||||
console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`);
|
||||
// Use Sirou navigation safely
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
sirou.go(result.redirect);
|
||||
// Use Expo Router directly — sirou.go fires NAVIGATE which Expo can't resolve
|
||||
router.replace(result.redirect as any);
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
|
|
@ -77,26 +127,26 @@ function SirouBridge() {
|
|||
};
|
||||
|
||||
checkAuth();
|
||||
}, [segments, sirou, isMounted, isAuthenticated]);
|
||||
}, [segments, params, sirou, router, isMounted, isAuthenticated]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { colorScheme } = useColorScheme();
|
||||
useRestoreTheme();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
const [fontsLoaded] = useFonts({
|
||||
'DMSans-Regular': require('../assets/fonts/DMSans-Regular.ttf'),
|
||||
'DMSans-Bold': require('../assets/fonts/DMSans-Bold.ttf'),
|
||||
'DMSans-Medium': require('../assets/fonts/DMSans-Medium.ttf'),
|
||||
'DMSans-SemiBold': require('../assets/fonts/DMSans-SemiBold.ttf'),
|
||||
'DMSans-Light': require('../assets/fonts/DMSans-Light.ttf'),
|
||||
'DMSans-ExtraLight': require('../assets/fonts/DMSans-ExtraLight.ttf'),
|
||||
'DMSans-Thin': require('../assets/fonts/DMSans-Thin.ttf'),
|
||||
'DMSans-Black': require('../assets/fonts/DMSans-Black.ttf'),
|
||||
'DMSans-ExtraBold': require('../assets/fonts/DMSans-ExtraBold.ttf'),
|
||||
"DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"),
|
||||
"DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"),
|
||||
"DMSans-Medium": require("../assets/fonts/DMSans-Medium.ttf"),
|
||||
"DMSans-SemiBold": require("../assets/fonts/DMSans-SemiBold.ttf"),
|
||||
"DMSans-Light": require("../assets/fonts/DMSans-Light.ttf"),
|
||||
"DMSans-ExtraLight": require("../assets/fonts/DMSans-ExtraLight.ttf"),
|
||||
"DMSans-Thin": require("../assets/fonts/DMSans-Thin.ttf"),
|
||||
"DMSans-Black": require("../assets/fonts/DMSans-Black.ttf"),
|
||||
"DMSans-ExtraBold": require("../assets/fonts/DMSans-ExtraBold.ttf"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -134,85 +184,90 @@ export default function RootLayout() {
|
|||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<NavigationIndependentTree>
|
||||
<NavigationContainer>
|
||||
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
||||
<ThemeProvider
|
||||
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
|
||||
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
||||
<ThemeProvider
|
||||
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
|
||||
>
|
||||
<View className="flex-1 bg-background">
|
||||
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<View className="flex-1 bg-background">
|
||||
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="sms-scan"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="proforma/[id]"
|
||||
options={{ title: "Proforma request" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="payments/[id]"
|
||||
options={{ title: "Payment" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="notifications/index"
|
||||
options={{ title: "Notifications" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="notifications/settings"
|
||||
options={{ title: "Notification settings" }}
|
||||
/>
|
||||
<Stack.Screen name="help" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="history" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="company" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="company-details"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ title: "Sign in", headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="register"
|
||||
options={{ title: "Create account", headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="invoices/[id]"
|
||||
options={{ title: "Invoice" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="reports/index"
|
||||
options={{ title: "Reports" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="documents/index"
|
||||
options={{ title: "Documents" }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
||||
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="edit-profile"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
</Stack>
|
||||
<SirouBridge />
|
||||
<BackupGuard />
|
||||
<PortalHost />
|
||||
<Toast />
|
||||
</View>
|
||||
</ThemeProvider>
|
||||
</SirouRouterProvider>
|
||||
</NavigationContainer>
|
||||
</NavigationIndependentTree>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="sms-scan"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="proforma/[id]"
|
||||
options={{ title: "Proforma request" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="proforma/edit"
|
||||
options={{ title: "Edit Proforma" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="invoices/[id]"
|
||||
options={{ title: "Invoice" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="invoices/edit"
|
||||
options={{ title: "Edit Invoice" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="payments/[id]"
|
||||
options={{ title: "Payment" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="notifications/index"
|
||||
options={{ title: "Notifications" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="notifications/settings"
|
||||
options={{ title: "Notification settings" }}
|
||||
/>
|
||||
<Stack.Screen name="help" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="history" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="company" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="company-details"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ title: "Sign in", headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="register"
|
||||
options={{ title: "Create account", headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="reports/index"
|
||||
options={{ title: "Reports" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="documents/index"
|
||||
options={{ title: "Documents" }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
||||
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="edit-profile"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
</Stack>
|
||||
<SirouBridge />
|
||||
<BackupGuard />
|
||||
<SessionHeartbeat />
|
||||
<PortalHost />
|
||||
<Toast />
|
||||
</View>
|
||||
</ThemeProvider>
|
||||
</SirouRouterProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import {
|
|||
TextInput,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
useColorScheme,
|
||||
Image,
|
||||
} from "react-native";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
|
|
@ -19,6 +20,7 @@ import { Stack } from "expo-router";
|
|||
import { useAuthStore } from "@/lib/auth-store";
|
||||
import { api } from "@/lib/api";
|
||||
import { getPlaceholderColor } from "@/lib/colors";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import {
|
||||
UserPlus,
|
||||
Search,
|
||||
|
|
@ -31,7 +33,7 @@ import {
|
|||
|
||||
export default function CompanyScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const colorScheme = useColorScheme();
|
||||
const { colorScheme } = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
|
||||
const [workers, setWorkers] = useState<any[]>([]);
|
||||
|
|
@ -42,7 +44,9 @@ export default function CompanyScreen() {
|
|||
const fetchWorkers = async () => {
|
||||
try {
|
||||
const response = await api.users.getAll();
|
||||
setWorkers(response.data || []);
|
||||
// Handle both direct array and { data: [] } response formats
|
||||
const data = Array.isArray(response) ? response : response.data || [];
|
||||
setWorkers(data);
|
||||
} catch (error) {
|
||||
console.error("[CompanyScreen] Error fetching workers:", error);
|
||||
} finally {
|
||||
|
|
@ -90,7 +94,7 @@ export default function CompanyScreen() {
|
|||
{/* Worker List Header */}
|
||||
<View className="flex-row justify-between items-center mb-4">
|
||||
<Text variant="h4" className="text-foreground tracking-tight">
|
||||
Workers ({filteredWorkers.length})
|
||||
Workers ({filteredWorkers?.length || 0})
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
|
@ -115,21 +119,33 @@ export default function CompanyScreen() {
|
|||
<ShadowWrapper key={worker.id} level="xs">
|
||||
<Card className="mb-3 overflow-hidden rounded-[12px] bg-card border-0">
|
||||
<CardContent className="flex-row items-center p-4">
|
||||
<View className="h-12 w-12 rounded-full bg-secondary/50 items-center justify-center mr-4">
|
||||
<Text className="text-primary font-bold text-lg">
|
||||
{worker.firstName?.[0]}
|
||||
{worker.lastName?.[0]}
|
||||
</Text>
|
||||
</View>
|
||||
{worker.avatar ? (
|
||||
<Image
|
||||
source={{ uri: worker.avatar }}
|
||||
className="h-12 w-12 rounded-full mr-4"
|
||||
/>
|
||||
) : (
|
||||
<View className="h-12 w-12 rounded-full bg-secondary items-center justify-center mr-4">
|
||||
<Text className="text-primary font-bold text-lg">
|
||||
{worker.firstName?.[0]}
|
||||
{worker.lastName?.[0]}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className="text-foreground font-bold text-base">
|
||||
{worker.firstName} {worker.lastName}
|
||||
</Text>
|
||||
<View className="flex-row items-center mt-1">
|
||||
<Text className="text-muted-foreground text-xs bg-secondary px-2 py-0.5 rounded-md uppercase font-bold tracking-widest text-[10px]">
|
||||
{worker.role || "WORKER"}
|
||||
<Text className="text-muted-foreground text-xs bg-muted px-2 py-0.5 rounded-md uppercase font-bold tracking-widest text-[9px]">
|
||||
{worker.role?.replace("_", " ") || "WORKER"}
|
||||
</Text>
|
||||
{worker.email && (
|
||||
<Text variant="muted" className="text-[10px] ml-2">
|
||||
{worker.email}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
|
@ -142,16 +158,13 @@ export default function CompanyScreen() {
|
|||
</ShadowWrapper>
|
||||
))
|
||||
) : (
|
||||
<View className="py-20 items-center">
|
||||
<Briefcase
|
||||
size={48}
|
||||
color={isDark ? "#1e293b" : "#f1f5f9"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<Text variant="muted" className="mt-4">
|
||||
No workers found
|
||||
</Text>
|
||||
</View>
|
||||
<EmptyState
|
||||
title="No workers found"
|
||||
description="Start by adding your first employee to manage your company."
|
||||
hint="Use the + button below to add a new worker"
|
||||
actionLabel="Refresh List"
|
||||
onActionPress={onRefresh}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Linking,
|
||||
useColorScheme,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
|
|
@ -11,18 +19,28 @@ import {
|
|||
Calendar,
|
||||
Share2,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
Trash2,
|
||||
Package,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
User,
|
||||
CreditCard,
|
||||
Hash,
|
||||
AlertCircle,
|
||||
} from "@/lib/icons";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||
import { StandardHeader } from "@/components/StandardHeader";
|
||||
import { api } from "@/lib/api";
|
||||
import { api, BASE_URL } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast-store";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
|
||||
export default function InvoiceDetailScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const { id } = useLocalSearchParams();
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [invoice, setInvoice] = useState<any>(null);
|
||||
|
|
@ -34,7 +52,11 @@ export default function InvoiceDetailScreen() {
|
|||
const fetchInvoice = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.invoices.getById({ params: { id: id as string } });
|
||||
// Ensure id is a string if useLocalSearchParams returns an array
|
||||
const invoiceId = Array.isArray(id) ? id[0] : id;
|
||||
if (!invoiceId) throw new Error("No ID provided");
|
||||
|
||||
const data = await api.invoices.getById({ params: { id: invoiceId } });
|
||||
setInvoice(data);
|
||||
} catch (error: any) {
|
||||
console.error("[InvoiceDetail] Error:", error);
|
||||
|
|
@ -44,11 +66,51 @@ export default function InvoiceDetailScreen() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleGetPdf = async () => {
|
||||
try {
|
||||
const { token } = useAuthStore.getState();
|
||||
const pdfUrl = `${BASE_URL}invoices/${id}/pdf?token=${token}`;
|
||||
await Linking.openURL(pdfUrl);
|
||||
} catch (error) {
|
||||
console.error("[InvoiceDetail] PDF Error:", error);
|
||||
toast.error("Error", "Failed to open PDF");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
Alert.alert(
|
||||
"Delete Invoice",
|
||||
"Are you sure you want to delete this invoice? This action cannot be undone.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const invoiceId = Array.isArray(id) ? id[0] : id;
|
||||
await api.invoices.delete({
|
||||
params: { id: invoiceId as string },
|
||||
});
|
||||
toast.success("Success", "Invoice deleted successfully");
|
||||
nav.back();
|
||||
} catch (error) {
|
||||
console.error("[InvoiceDetail] Delete Error:", error);
|
||||
toast.error("Error", "Failed to delete invoice");
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Invoice Details" showBack />
|
||||
<StandardHeader title="Invoice" showBack />
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<ActivityIndicator color="#ea580c" size="large" />
|
||||
</View>
|
||||
|
|
@ -60,220 +122,343 @@ export default function InvoiceDetailScreen() {
|
|||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Invoice Details" showBack />
|
||||
<StandardHeader title="Invoice" showBack />
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text variant="muted">Invoice not found</Text>
|
||||
<AlertCircle size={48} color="#ef4444" className="mb-4" />
|
||||
<Text variant="h4" className="mb-1">
|
||||
Invoice Not Found
|
||||
</Text>
|
||||
<Text variant="muted">
|
||||
The requested document could not be retrieved.
|
||||
</Text>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Robust data extraction with fallback for scanned invoices
|
||||
const originalData = invoice.scannedData?.originalData || {};
|
||||
const items =
|
||||
(invoice.items?.length > 0 ? invoice.items : originalData.items) || [];
|
||||
|
||||
const taxAmountValue = Number(
|
||||
typeof invoice.taxAmount === "object"
|
||||
? invoice.taxAmount?.value
|
||||
: invoice.taxAmount || originalData.taxAmount || 0,
|
||||
);
|
||||
|
||||
const discountValue = Number(
|
||||
typeof invoice.discountAmount === "object"
|
||||
? invoice.discountAmount?.value
|
||||
: invoice.discountAmount || originalData.discountAmount || 0,
|
||||
);
|
||||
|
||||
let amountValue = Number(
|
||||
typeof invoice.amount === "object" ? invoice.amount.value : invoice.amount,
|
||||
);
|
||||
// Intelligence: If amount looks like it's just the tax, and we have items, use items total
|
||||
if (items.length > 0) {
|
||||
const itemsTotal = items.reduce(
|
||||
(acc: number, item: any) =>
|
||||
acc + (Number(item.total?.value || item.total) || 0),
|
||||
0,
|
||||
);
|
||||
if (
|
||||
itemsTotal > 0 &&
|
||||
(amountValue === taxAmountValue || amountValue < itemsTotal)
|
||||
) {
|
||||
amountValue = itemsTotal + taxAmountValue - discountValue;
|
||||
}
|
||||
}
|
||||
|
||||
const subtotalValue = amountValue - taxAmountValue + discountValue;
|
||||
const statusColors = {
|
||||
PAID: {
|
||||
bg: "bg-emerald-500/10",
|
||||
text: "text-emerald-500",
|
||||
dot: "bg-emerald-500",
|
||||
},
|
||||
PENDING: {
|
||||
bg: "bg-amber-500/10",
|
||||
text: "text-amber-500",
|
||||
dot: "bg-amber-500",
|
||||
},
|
||||
DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" },
|
||||
DEFAULT: {
|
||||
bg: "bg-slate-500/10",
|
||||
text: "text-slate-500",
|
||||
dot: "bg-slate-500",
|
||||
},
|
||||
};
|
||||
const status = (invoice.status || "PENDING").toUpperCase();
|
||||
const colors =
|
||||
statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Invoice Details" showBack />
|
||||
<StandardHeader
|
||||
title={"Invoice Detail"}
|
||||
showBack
|
||||
rightAction="edit"
|
||||
onRightActionPress={() => nav.go("invoices/edit", { id: invoice.id })}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Status Hero Card */}
|
||||
<Card className="mb-4 overflow-hidden rounded-[6px] border-0 bg-primary">
|
||||
<View className="p-5">
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
||||
<FileText color="white" size={16} strokeWidth={2.5} />
|
||||
</View>
|
||||
<View
|
||||
className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-[10px] font-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
|
||||
>
|
||||
{invoice.status || "Pending"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text variant="small" className="text-white/70 mb-0.5">
|
||||
Total Amount
|
||||
{/* Modern Hero Area */}
|
||||
<View className="px-5 pt-4">
|
||||
<View
|
||||
className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}
|
||||
>
|
||||
<View className={`w-2 h-2 rounded-full ${colors.dot}`} />
|
||||
<Text
|
||||
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}
|
||||
>
|
||||
{status}
|
||||
</Text>
|
||||
<Text variant="h3" className="text-white font-bold mb-3">
|
||||
${Number(invoice.amount).toLocaleString()}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-xs font-bold uppercase tracking-wider mb-1"
|
||||
>
|
||||
Total Payable Amount
|
||||
</Text>
|
||||
<View className="flex-row items-end gap-2 mb-6">
|
||||
<Text variant="h1" className="text-4xl font-black text-foreground">
|
||||
{Number(amountValue).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</Text>
|
||||
<Text className="text-xl font-bold text-primary mb-2">
|
||||
{invoice.currency || "ETB"}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Calendar color="rgba(255,255,255,0.9)" size={12} />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
Due {new Date(invoice.dueDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-3 w-[1px] bg-white/60" />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
#{invoice.invoiceNumber || id}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Recipient & Category — inline info strip */}
|
||||
<Card className="bg-card rounded-[6px] mb-4">
|
||||
<View className="flex-row px-4 py-2">
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<View className="flex-col">
|
||||
<Text className="text-foreground text-xs opacity-60">
|
||||
Recipient
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{invoice.customerName || "—"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="w-[1px] bg-border/70 mx-3" />
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<View className="flex-col">
|
||||
<Text className="text-foreground text-xs opacity-60">
|
||||
Category
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
General
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Items / Billing Summary */}
|
||||
<Card className="mb-4 bg-card rounded-[6px]">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
{/* Quick Stats Grid */}
|
||||
<View className="flex-row gap-3 mb-6">
|
||||
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
|
||||
<Calendar size={16} color="#ea580c" className="mb-2" />
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-bold opacity-60 uppercase text-[10px] tracking-widest"
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
|
||||
>
|
||||
Billing Summary
|
||||
Issue Date
|
||||
</Text>
|
||||
<Text className="text-foreground font-bold text-sm">
|
||||
{new Date(
|
||||
invoice.issueDate || invoice.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
|
||||
<Clock size={16} color="#ef4444" className="mb-2" />
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
|
||||
>
|
||||
Due Date
|
||||
</Text>
|
||||
<Text className="text-foreground font-bold text-sm">
|
||||
{new Date(invoice.dueDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between py-3 border-b border-border/70">
|
||||
<View className="flex-1 pr-4">
|
||||
{/* Client Box */}
|
||||
<View className="px-5 mb-6">
|
||||
<View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10">
|
||||
<View className="flex-row items-center gap-3 mb-4">
|
||||
<View className="h-10 w-10 rounded-full bg-primary/20 items-center justify-center">
|
||||
<User color="#ea580c" size={20} />
|
||||
</View>
|
||||
<View>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold text-sm"
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold"
|
||||
>
|
||||
Subtotal
|
||||
Billed To
|
||||
</Text>
|
||||
<Text variant="p" className="text-foreground font-bold text-lg">
|
||||
{invoice.customerName?.replace("Customer Name: ", "") ||
|
||||
"Walking Client"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||
$
|
||||
{(
|
||||
Number(invoice.amount) - (Number(invoice.taxAmount) || 0)
|
||||
).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{Number(invoice.taxAmount) > 0 && (
|
||||
<View className="flex-row justify-between py-3 border-b border-border/70">
|
||||
<View className="flex-1 pr-4">
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold text-sm"
|
||||
>
|
||||
Tax
|
||||
<View className="flex-row flex-wrap gap-4 pt-4 border-t border-primary/10">
|
||||
{invoice.customerEmail && (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<CreditCard size={12} color="#64748b" />
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{invoice.customerEmail}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||
+ ${Number(invoice.taxAmount).toLocaleString()}
|
||||
)}
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Hash size={12} color="#64748b" />
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
#{invoice.id.split("-")[0]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Detailed Items Table */}
|
||||
<View className="px-5 mb-6">
|
||||
<Text variant="h4" className="font-bold mb-4 px-1">
|
||||
Order Summary
|
||||
</Text>
|
||||
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
|
||||
{items.map((item: any, idx: number) => (
|
||||
<View
|
||||
key={idx}
|
||||
className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}
|
||||
>
|
||||
<View className="flex-row justify-between items-start mb-1">
|
||||
<Text className="text-foreground font-bold flex-1 mr-4">
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text className="text-foreground font-black">
|
||||
{Number(
|
||||
item.total?.value || item.total || 0,
|
||||
).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{item.quantity} units x{" "}
|
||||
{Number(
|
||||
item.unitPrice?.value || item.unitPrice || 0,
|
||||
).toLocaleString()}{" "}
|
||||
{invoice.currency}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<View className="p-8 items-center bg-muted/20">
|
||||
<Package size={32} color="#cbd5e1" className="mb-2" />
|
||||
<Text variant="muted">No line items specified</Text>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
{/* Billing Breakdown */}
|
||||
<View className="px-5 mb-6">
|
||||
<Card className="bg-card rounded-[6px] p-5 shadow-sm shadow-black/5 border-border/60">
|
||||
<View className="flex-row justify-between mb-4">
|
||||
<Text className="text-muted-foreground font-medium">
|
||||
Subtotal
|
||||
</Text>
|
||||
<Text className="text-foreground font-bold">
|
||||
{subtotalValue.toLocaleString()} {invoice.currency}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{taxAmountValue > 0 && (
|
||||
<View className="flex-row justify-between mb-4">
|
||||
<Text className="text-muted-foreground font-medium">
|
||||
Tax (extracted)
|
||||
</Text>
|
||||
<Text className="text-emerald-500 font-bold">
|
||||
+{taxAmountValue.toLocaleString()} {invoice.currency}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="mt-3 pt-3 flex-row justify-between items-center border-t border-border/70">
|
||||
<Text variant="muted" className="font-semibold text-sm">
|
||||
Total Balance
|
||||
</Text>
|
||||
<Text
|
||||
variant="h3"
|
||||
className="text-foreground font-semibold text-xl tracking-tight"
|
||||
>
|
||||
${Number(invoice.amount).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
{discountValue > 0 && (
|
||||
<View className="flex-row justify-between mb-4">
|
||||
<Text className="text-muted-foreground font-medium">
|
||||
Discount
|
||||
</Text>
|
||||
<Text className="text-rose-500 font-bold">
|
||||
-{discountValue.toLocaleString()} {invoice.currency}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Notes Section (New) */}
|
||||
{invoice.notes && (
|
||||
<Card className="mb-4 bg-card rounded-[6px]">
|
||||
<View className="p-4">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-bold opacity-60 uppercase text-[10px] tracking-widest mb-2"
|
||||
>
|
||||
Additional Notes
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-medium text-xs leading-5"
|
||||
>
|
||||
{invoice.notes}
|
||||
<View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center">
|
||||
<View>
|
||||
<Text className="text-foreground font-black text-xl">
|
||||
Grand Total
|
||||
</Text>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold tracking-tighter"
|
||||
>
|
||||
Verified from data
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-primary font-black text-2xl">
|
||||
{amountValue.toLocaleString()} {invoice.currency}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Timeline Section (New) */}
|
||||
<View className="mt-2 mb-6 px-4 py-3 bg-secondary/20 rounded-[8px] border border-border/30">
|
||||
<View className="flex-row justify-between mb-1.5">
|
||||
<Text className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">
|
||||
Created
|
||||
</Text>
|
||||
<Text className="text-[10px] text-foreground font-bold">
|
||||
{new Date(invoice.createdAt).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">
|
||||
Last Updated
|
||||
</Text>
|
||||
<Text className="text-[10px] text-foreground font-bold">
|
||||
{new Date(invoice.updatedAt).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
className=" flex-1 mb-4 h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
|
||||
onPress={() => {}}
|
||||
>
|
||||
<Share2 color="#ffffff" size={16} strokeWidth={2.5} />
|
||||
<Text className="ml-2 text-white text-[12px] font-black uppercase tracking-widest">
|
||||
Share SMS
|
||||
</Text>
|
||||
</Button>
|
||||
<ShadowWrapper>
|
||||
<Button
|
||||
className=" flex-1 mb-4 h-12 rounded-[10px] bg-card border border-border"
|
||||
onPress={() => {}}
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<View className="px-5 mb-10">
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold mb-2"
|
||||
>
|
||||
<Download color="#0f172a" size={16} strokeWidth={2.5} />
|
||||
<Text className="ml-2 text-foreground text-[12px] font-black uppercase tracking-widest">
|
||||
Note / Description
|
||||
</Text>
|
||||
<Text className="text-foreground font-medium italic opacity-80 leading-5">
|
||||
" {invoice.notes} "
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Premium Actions */}
|
||||
<View className="px-5 gap-3">
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
|
||||
onPress={() =>
|
||||
toast.info(
|
||||
"Coming Soon",
|
||||
"SMS sharing enabled for matched accounts.",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Share2 color="#ffffff" size={18} strokeWidth={2.5} />
|
||||
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
|
||||
Scan SMS
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-14 rounded-[6px] bg-card border border-border"
|
||||
onPress={handleGetPdf}
|
||||
>
|
||||
<Download
|
||||
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||
size={18}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
|
||||
Get PDF
|
||||
</Text>
|
||||
</Button>
|
||||
</ShadowWrapper>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-14 rounded-[6px] border border-rose-500/10"
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<Trash2 color="#ef4444" size={18} />
|
||||
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">
|
||||
Delete Invoice
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenWrapper>
|
||||
|
|
|
|||
659
app/invoices/edit.tsx
Normal file
659
app/invoices/edit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
app/login.tsx
204
app/login.tsx
|
|
@ -12,9 +12,20 @@ import {
|
|||
} from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User, Globe } from "@/lib/icons";
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Chrome,
|
||||
User,
|
||||
Globe,
|
||||
Phone,
|
||||
} from "@/lib/icons";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
import * as Linking from "expo-linking";
|
||||
|
|
@ -24,21 +35,31 @@ import { toast } from "@/lib/toast-store";
|
|||
import { useLanguageStore, AppLanguage } from "@/lib/language-store";
|
||||
import { getPlaceholderColor } from "@/lib/colors";
|
||||
import { LanguageModal } from "@/components/LanguageModal";
|
||||
import {
|
||||
GoogleSignin,
|
||||
statusCodes,
|
||||
} from "@react-native-google-signin/google-signin";
|
||||
// Lazy-load Google Sign-In to prevent crash when native module is missing (e.g. Expo Go)
|
||||
let GoogleSignin: any = null;
|
||||
let statusCodes: any = {};
|
||||
let googleAvailable = false;
|
||||
|
||||
GoogleSignin.configure({
|
||||
webClientId:
|
||||
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
|
||||
iosClientId:
|
||||
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", // Placeholder: replace with your actual iOS Client ID from Google Cloud Console
|
||||
offlineAccess: true,
|
||||
});
|
||||
try {
|
||||
const gsi = require("@react-native-google-signin/google-signin");
|
||||
GoogleSignin = gsi.GoogleSignin;
|
||||
statusCodes = gsi.statusCodes;
|
||||
googleAvailable = true;
|
||||
|
||||
GoogleSignin.configure({
|
||||
webClientId:
|
||||
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
|
||||
iosClientId:
|
||||
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
|
||||
offlineAccess: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("[Login] Google Sign-In native module not available:", (e as any).message);
|
||||
}
|
||||
|
||||
export default function LoginScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const router = useRouter();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
|
|
@ -49,8 +70,32 @@ export default function LoginScreen() {
|
|||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loginMode, setLoginMode] = useState<"email" | "phone">("email");
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (loginMode === "phone") {
|
||||
if (!identifier) {
|
||||
toast.error("Required Field", "Please enter your phone number");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const fullPhone = `+251${identifier}`;
|
||||
try {
|
||||
const response = await api.auth.sendOtp({ body: { phone: fullPhone } });
|
||||
toast.success("Success", response.message || "OTP sent successfully");
|
||||
// Navigate to OTP screen
|
||||
router.push({
|
||||
pathname: "/otp",
|
||||
params: { phone: fullPhone, verificationId: response.verificationId },
|
||||
});
|
||||
} catch (err: any) {
|
||||
toast.error("Error", err.message || "Failed to send OTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!identifier || !password) {
|
||||
toast.error(
|
||||
"Required Fields",
|
||||
|
|
@ -64,25 +109,19 @@ export default function LoginScreen() {
|
|||
const isEmail = identifier.includes("@");
|
||||
const payload = isEmail
|
||||
? { email: identifier, password }
|
||||
: { phone: identifier, password };
|
||||
: { phone: `+251${identifier}`, password };
|
||||
|
||||
try {
|
||||
// Using the new api.auth.login which is powered by simple-api
|
||||
const response = await api.auth.login({ body: payload });
|
||||
|
||||
// Store user, access token, refresh token, and permissions
|
||||
// // Fetch roles to get permissions
|
||||
// const rolesResponse = await rbacApi.roles();
|
||||
// const userRole = response.user.role;
|
||||
// const roleData = rolesResponse.find((r: any) => r.role === userRole);
|
||||
// const permissions = roleData ? roleData.permissions : [];
|
||||
const permissions: string[] = [];
|
||||
|
||||
// Store user, access token, refresh token, and permissions
|
||||
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
|
||||
setAuth(
|
||||
response.user,
|
||||
response.accessToken,
|
||||
response.refreshToken,
|
||||
permissions,
|
||||
);
|
||||
toast.success("Welcome Back!", "You have successfully logged in.");
|
||||
|
||||
// Explicitly navigate to home
|
||||
nav.go("(tabs)");
|
||||
} catch (err: any) {
|
||||
toast.error("Login Failed", err.message || "Invalid credentials");
|
||||
|
|
@ -92,6 +131,10 @@ export default function LoginScreen() {
|
|||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
if (!googleAvailable || !GoogleSignin) {
|
||||
toast.error("Unavailable", "Google Sign-In requires a native build. Please use email/phone login.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await GoogleSignin.hasPlayServices();
|
||||
|
|
@ -115,7 +158,12 @@ export default function LoginScreen() {
|
|||
// const permissions = roleData ? roleData.permissions : [];
|
||||
const permissions: string[] = [];
|
||||
|
||||
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
|
||||
setAuth(
|
||||
response.user,
|
||||
response.accessToken,
|
||||
response.refreshToken,
|
||||
permissions,
|
||||
);
|
||||
toast.success("Welcome!", "Signed in with Google.");
|
||||
nav.go("(tabs)");
|
||||
} catch (error: any) {
|
||||
|
|
@ -158,7 +206,7 @@ export default function LoginScreen() {
|
|||
</View>
|
||||
|
||||
{/* Logo / Branding */}
|
||||
<View className="items-center mb-10">
|
||||
<View className="items-center mb-8">
|
||||
<Text variant="h2" className="mt-6 font-bold text-foreground">
|
||||
Login
|
||||
</Text>
|
||||
|
|
@ -167,48 +215,96 @@ export default function LoginScreen() {
|
|||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Login Type Toggle */}
|
||||
<View className="flex-row bg-card border border-border rounded-xl p-1 mb-6">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setLoginMode("email");
|
||||
setIdentifier("");
|
||||
}}
|
||||
className={`flex-1 py-2 rounded-lg items-center ${loginMode === "email" ? "bg-primary" : ""}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-bold text-sm ${loginMode === "email" ? "text-white" : "text-muted-foreground"}`}
|
||||
>
|
||||
Email Login
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setLoginMode("phone");
|
||||
setIdentifier("");
|
||||
}}
|
||||
className={`flex-1 py-2 rounded-lg items-center ${loginMode === "phone" ? "bg-primary" : ""}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-bold text-sm ${loginMode === "phone" ? "text-white" : "text-muted-foreground"}`}
|
||||
>
|
||||
Phone Number
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Form */}
|
||||
<View className="gap-5">
|
||||
<View>
|
||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||
Email or Phone Number
|
||||
{loginMode === "email" ? "Email Address" : "Phone Number"}
|
||||
</Text>
|
||||
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||
{loginMode === "email" ? (
|
||||
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||
) : (
|
||||
<View className="flex-row items-center">
|
||||
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||
<Text className="ml-2 text-foreground font-bold">+251</Text>
|
||||
</View>
|
||||
)}
|
||||
<TextInput
|
||||
className="flex-1 ml-3 text-foreground"
|
||||
placeholder="john@example.com or +251..."
|
||||
placeholder={
|
||||
loginMode === "email" ? "john@example.com" : "912345678"
|
||||
}
|
||||
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||
value={identifier}
|
||||
onChangeText={setIdentifier}
|
||||
autoCapitalize="none"
|
||||
keyboardType={
|
||||
loginMode === "email" ? "email-address" : "phone-pad"
|
||||
}
|
||||
maxLength={loginMode === "phone" ? 9 : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||
Password
|
||||
</Text>
|
||||
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||
<TextInput
|
||||
className="flex-1 ml-3 text-foreground"
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
/>
|
||||
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
||||
{showPassword ? (
|
||||
<EyeOff size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||
) : (
|
||||
<Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||
)}
|
||||
</Pressable>
|
||||
{loginMode === "email" && (
|
||||
<View>
|
||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||
Password
|
||||
</Text>
|
||||
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||
<TextInput
|
||||
className="flex-1 ml-3 text-foreground"
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
/>
|
||||
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
size={18}
|
||||
color={isDark ? "#94a3b8" : "#64748b"}
|
||||
/>
|
||||
) : (
|
||||
<Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="h-10 bg-primary rounded-[6px] shadow-lg shadow-primary/30 mt-2"
|
||||
|
|
@ -220,7 +316,9 @@ export default function LoginScreen() {
|
|||
) : (
|
||||
<>
|
||||
<Text className="text-white font-bold text-base mr-2">
|
||||
Sign In
|
||||
{loginMode === "email"
|
||||
? "Sign In"
|
||||
: "Send Verification Code"}
|
||||
</Text>
|
||||
<ArrowRight color="white" size={18} strokeWidth={2.5} />
|
||||
</>
|
||||
|
|
|
|||
185
app/otp.tsx
Normal file
185
app/otp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,130 +1,475 @@
|
|||
import { View, ScrollView, Pressable } from "react-native";
|
||||
import { useSirouRouter, useSirouParams } from "@sirou/react-native";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Stack } from "expo-router";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons";
|
||||
import {
|
||||
Wallet,
|
||||
Link2,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
User,
|
||||
ShieldCheck,
|
||||
Building2,
|
||||
Hash,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Trash2,
|
||||
Network,
|
||||
AlertCircle,
|
||||
Info,
|
||||
} from "@/lib/icons";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
import { StandardHeader } from "@/components/StandardHeader";
|
||||
import { api, BASE_URL } from "@/lib/api";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { toast } from "@/lib/toast-store";
|
||||
|
||||
export default function PaymentDetailScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const { id } = useSirouParams<AppRoutes, "payments/[id]">();
|
||||
const { id } = useLocalSearchParams();
|
||||
const { colorScheme } = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
|
||||
const [payment, setPayment] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [matching, setMatching] = useState(false);
|
||||
|
||||
const paymentId = Array.isArray(id) ? id[0] : id;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPayment = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!paymentId) throw new Error("No ID provided");
|
||||
|
||||
console.log("[PaymentDetail] Fetching ID:", paymentId);
|
||||
const response = await api.payments.getById({
|
||||
params: { id: paymentId },
|
||||
});
|
||||
setPayment(response);
|
||||
console.log("[PaymentDetail] Response:", response);
|
||||
} catch (error) {
|
||||
console.error("[PaymentDetail] Error fetching payment:", error);
|
||||
toast.error("Error", "Failed to fetch payment details.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPayment();
|
||||
}, [paymentId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
Alert.alert(
|
||||
"Delete Payment",
|
||||
"Are you sure you want to delete this payment record?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
if (!paymentId) return;
|
||||
await api.payments.delete({ params: { id: paymentId } });
|
||||
toast.success("Deleted", "Payment record has been removed.");
|
||||
nav.back();
|
||||
} catch (err: any) {
|
||||
toast.error("Error", err.message || "Failed to delete payment.");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!payment || matching || !paymentId) return;
|
||||
|
||||
setMatching(true);
|
||||
toast.info("Matching...", "Searching for a corresponding invoice.");
|
||||
|
||||
try {
|
||||
// 1. Fetch all invoices
|
||||
const invoices = await api.invoices.getAll();
|
||||
const invoiceList = Array.isArray(invoices)
|
||||
? invoices
|
||||
: (invoices as any).data || [];
|
||||
|
||||
// 2. Algorithm: Match Amount AND (Sender OR Receiver Name)
|
||||
const pAmount = Number(payment.amount);
|
||||
const pSender = (payment.senderName || "").toLowerCase().trim();
|
||||
const pReceiver = (payment.receiverName || "").toLowerCase().trim();
|
||||
|
||||
const match = invoiceList.find((inv: any) => {
|
||||
const invAmount = Number(inv.amount);
|
||||
const invCustomer = (inv.customerName || "").toLowerCase().trim();
|
||||
|
||||
// Exact amount match is primary
|
||||
const amountMatches = Math.abs(invAmount - pAmount) < 0.01;
|
||||
|
||||
// Name proximity match (either sender or receiver)
|
||||
const nameMatches =
|
||||
(invCustomer && pSender && pSender.includes(invCustomer)) ||
|
||||
(invCustomer && pSender && invCustomer.includes(pSender)) ||
|
||||
(invCustomer && pReceiver && pReceiver.includes(invCustomer)) ||
|
||||
(invCustomer && pReceiver && invCustomer.includes(pReceiver));
|
||||
|
||||
return amountMatches && nameMatches;
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
toast.info(
|
||||
"No Match Found",
|
||||
"Could not find an invoice with the same amount and customer name.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Confirm match with user
|
||||
Alert.alert(
|
||||
"Match Found!",
|
||||
`Associate this payment with Invoice #${match.invoiceNumber} for ${match.customerName}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Associate",
|
||||
style: "default",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await api.payments.associate({
|
||||
params: { id: paymentId },
|
||||
body: { invoiceId: match.id },
|
||||
});
|
||||
toast.success(
|
||||
"Success",
|
||||
"Payment successfully associated with invoice.",
|
||||
);
|
||||
// Refresh data
|
||||
const updated = await api.payments.getById({
|
||||
params: { id: paymentId },
|
||||
});
|
||||
setPayment(updated);
|
||||
} catch (err: any) {
|
||||
toast.error("Error", err.message || "Failed to associate.");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error("[Match] Error:", err);
|
||||
toast.error("Error", "Failed to fetch invoices for matching.");
|
||||
} finally {
|
||||
setMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Payment Details" showBack />
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<ActivityIndicator color="#ea580c" size="large" />
|
||||
<Text
|
||||
variant="muted"
|
||||
className="mt-4 font-bold uppercase tracking-widest text-[10px]"
|
||||
>
|
||||
Retrieving Transaction...
|
||||
</Text>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!payment) {
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Payment Details" showBack />
|
||||
<View className="flex-1 justify-center items-center p-6">
|
||||
<AlertTriangle color="#ef4444" size={48} strokeWidth={1.5} />
|
||||
<Text variant="h4" className="mt-4 text-foreground font-black">
|
||||
Transaction Not Found
|
||||
</Text>
|
||||
<Text variant="muted" className="mt-2 text-center">
|
||||
The requested payment record could not be retrieved from the server.
|
||||
</Text>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const amountValue = Number(
|
||||
typeof payment.amount === "object" ? payment.amount.value : payment.amount,
|
||||
);
|
||||
|
||||
const paymentDate = payment.paymentDate
|
||||
? new Date(payment.paymentDate)
|
||||
: new Date(payment.createdAt);
|
||||
|
||||
const isFlagged = payment.isFlagged === true;
|
||||
const isScanned = payment.isScanned === true;
|
||||
const scanned = payment.scannedData || {};
|
||||
const extracted = scanned.extractedFields || {};
|
||||
const verification = payment.verification || scanned.verification || {};
|
||||
const isFailed =
|
||||
verification.verificationStatus === "failed" ||
|
||||
verification.isVerified === false;
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||
<Pressable
|
||||
onPress={() => nav.back()}
|
||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||
>
|
||||
<ArrowLeft color="#0f172a" size={20} />
|
||||
</Pressable>
|
||||
<Text variant="h4" className="text-foreground font-semibold">
|
||||
Payment Match
|
||||
</Text>
|
||||
<View className="w-9" />
|
||||
</View>
|
||||
<StandardHeader title={"Payment Details"} showBack />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
contentContainerStyle={{ paddingBottom: 10 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Card className=" overflow-hidden rounded-[6px] border-0 bg-primary">
|
||||
<View className="p-5">
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
||||
<Wallet color="white" size={18} strokeWidth={2.5} />
|
||||
</View>
|
||||
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
|
||||
<Text className={`text-[10px] font-bold text-white`}>
|
||||
Pending Match
|
||||
</Text>
|
||||
</View>
|
||||
{/* Urgent Alerts */}
|
||||
{isFlagged && (
|
||||
<View className="mx-5 my-4 bg-red-500/10 border border-red-500/20 rounded-[24px] p-5 flex-row items-start">
|
||||
<View className="bg-red-500/20 p-2 rounded-full mr-4">
|
||||
<AlertTriangle color="#ef4444" size={20} />
|
||||
</View>
|
||||
|
||||
<Text variant="small" className="text-white/70 mb-0.5">
|
||||
Received Amount
|
||||
</Text>
|
||||
<Text variant="h3" className="text-white font-bold mb-3">
|
||||
$2,000.00
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
TXN-9982734
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-3 w-[1px] bg-white/60" />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
Telebirr SMS
|
||||
<View className="flex-1">
|
||||
<Text className="text-red-600 font-black text-[10px] uppercase tracking-[2px] mb-1">
|
||||
Security Flag ({payment.flagReason || "Audit Needed"})
|
||||
</Text>
|
||||
<Text className="text-foreground/80 font-medium text-xs leading-5">
|
||||
{payment.flagNotes || "System flagged this for manual review."}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transaction Details */}
|
||||
|
||||
<Text variant="h4" className="text-foreground mt-4 mb-2">
|
||||
Transaction Details
|
||||
</Text>
|
||||
|
||||
<Card className="bg-card rounded-[6px] mb-3">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Clock color="#000" size={13} />
|
||||
<Text variant="muted" className="text-sm">
|
||||
Received On
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="p" className="text-foreground text-sm">
|
||||
Sep 11, 2022 · 14:30
|
||||
{/* Hero Section */}
|
||||
<View className="px-5 pt-2">
|
||||
{/* Status Badges */}
|
||||
<View className="flex-row flex-wrap gap-2 mb-6">
|
||||
<View
|
||||
className={`px-3 py-1 rounded-full flex-row items-center gap-2 ${payment.invoiceId ? "bg-emerald-500/10" : "bg-amber-500/10"}`}
|
||||
>
|
||||
<View
|
||||
className={`w-2 h-2 rounded-full ${payment.invoiceId ? "bg-emerald-500" : "bg-amber-500"}`}
|
||||
/>
|
||||
<Text
|
||||
className={`text-[10px] font-black uppercase tracking-widest ${payment.invoiceId ? "text-emerald-600" : "text-amber-600"}`}
|
||||
>
|
||||
{payment.invoiceId ? "Matched" : "Pending Match"}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-[1px] bg-border/70 my-3" />
|
||||
<View className="flex-row items-center justify-between py-1">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Link2 color="#000" size={13} />
|
||||
<Text variant="muted" className="text-sm">
|
||||
Status
|
||||
|
||||
{isFailed && (
|
||||
<View className="bg-red-500/10 px-3 py-1 rounded-full flex-row items-center gap-2">
|
||||
<AlertCircle size={12} color="#ef4444" />
|
||||
<Text className="text-red-600 text-[10px] font-black uppercase tracking-widest">
|
||||
Verify Failed
|
||||
</Text>
|
||||
</View>
|
||||
<View className="bg-amber-500/10 px-2.5 py-1 rounded-[4px]">
|
||||
<Text className="text-amber-600 text-xs font-semibold">
|
||||
Awaiting Link
|
||||
)}
|
||||
|
||||
{isScanned && (
|
||||
<View className="bg-primary/10 px-3 py-1 rounded-full flex-row items-center gap-2 border border-primary/20">
|
||||
<CheckCircle2 size={12} color="#ea580c" />
|
||||
<Text className="text-primary text-[10px] font-black uppercase tracking-widest">
|
||||
Scanned
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* SMS Message */}
|
||||
<Card className="bg-card rounded-[6px] mb-6">
|
||||
<View className="p-4">
|
||||
<Text variant="muted" className="mb-3 font-semibold">
|
||||
Original SMS
|
||||
</Text>
|
||||
<Text className="text-foreground/70 font-medium leading-6 text-sm">
|
||||
"Payment received from Elnatan Jansen for order #2322 via
|
||||
Telebirr. Amount: $2,000. Ref: B88-22X7."
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Action */}
|
||||
|
||||
<Button className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30">
|
||||
<Link2 color="white" size={18} strokeWidth={2.5} />
|
||||
<Text className=" text-white text-xs font-semibold uppercase tracking-widest">
|
||||
Associate to Invoice
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] font-black uppercase tracking-[3px] mb-2 opacity-60"
|
||||
>
|
||||
Total Transaction Amount
|
||||
</Text>
|
||||
</Button>
|
||||
<View className="flex-row items-end gap-3 mb-8">
|
||||
<Text
|
||||
variant="h1"
|
||||
className="text-4xl font-black text-foreground tracking-tighter"
|
||||
>
|
||||
{amountValue.toLocaleString()}
|
||||
</Text>
|
||||
<Text className="text-2xl font-black text-primary mb-1">
|
||||
{payment.currency || "USD"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Core Info Grid */}
|
||||
<View className="flex-row gap-3 mb-6">
|
||||
<Card className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
|
||||
<Building2 size={18} color="#ea580c" className="mb-3" />
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
|
||||
>
|
||||
Merchant
|
||||
</Text>
|
||||
<Text
|
||||
className="text-foreground font-black text-sm"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{extracted.merchantName ||
|
||||
payment.merchantName ||
|
||||
"Unknown Merchant"}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
|
||||
<Network size={18} color="#ea580c" className="mb-3" />
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
|
||||
>
|
||||
Provider
|
||||
</Text>
|
||||
<Text
|
||||
className="text-foreground font-black text-sm"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{extracted.provider ||
|
||||
payment.paymentMethod ||
|
||||
"Direct Payment"}
|
||||
</Text>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sender / Payer Box */}
|
||||
<View className="px-5 mb-8">
|
||||
<View className="bg-card/50 rounded-[6px] p-6 border border-border/40 shadow-sm shadow-black/5">
|
||||
<View className="flex-row items-center gap-4 mb-5">
|
||||
<View className="h-12 w-12 rounded-full bg-secondary/10 items-center justify-center border border-secondary/20">
|
||||
<User color={isDark ? "#f1f5f9" : "#0f172a"} size={22} />
|
||||
</View>
|
||||
<View>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[9px] uppercase font-black tracking-[2px] mb-0.5"
|
||||
>
|
||||
Transaction Origin
|
||||
</Text>
|
||||
<Text className="text-foreground font-black text-lg">
|
||||
{payment.senderName ||
|
||||
(payment.user
|
||||
? `${payment.user.firstName} ${payment.user.lastName}`
|
||||
: "Business Account")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row flex-wrap gap-5 pt-5 border-t border-border/40">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Hash size={14} color="#64748b" />
|
||||
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
|
||||
{payment.transactionId || "INTERNAL-TXN"}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Clock size={14} color="#64748b" />
|
||||
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
|
||||
{paymentDate.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
{payment.notes && (
|
||||
<View className="px-5 mb-10">
|
||||
<View className="flex-row items-center gap-2 mb-3">
|
||||
<Info size={14} color="#64748b" />
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-black tracking-widest"
|
||||
>
|
||||
Transaction Notes
|
||||
</Text>
|
||||
</View>
|
||||
<View className="bg-muted/5 border border-border/40 rounded-[6px] p-5">
|
||||
<Text className="text-foreground font-medium italic opacity-70 leading-6">
|
||||
" {payment.notes} "
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Premium Actions */}
|
||||
<View className="px-5 gap-3">
|
||||
<View className="flex-row gap-3">
|
||||
{scanned?.imageUrl && (
|
||||
<Button
|
||||
className="flex-1 h-14 rounded-[6px] bg-secondary shadow-lg shadow-black/10"
|
||||
onPress={() =>
|
||||
Linking.openURL(
|
||||
`${BASE_URL}${scanned.imageUrl.startsWith("/") ? scanned.imageUrl.substring(1) : scanned.imageUrl}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye
|
||||
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||
size={18}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
|
||||
View Receipt
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
{!payment.invoiceId && !isFailed && (
|
||||
<Button
|
||||
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
|
||||
onPress={handleMatch}
|
||||
disabled={matching}
|
||||
>
|
||||
{matching ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<>
|
||||
<Link2 size={18} color="white" strokeWidth={2.5} />
|
||||
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
|
||||
Match Invoice
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-14 rounded-[6px] border border-red-500/5 mb-10"
|
||||
onPress={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<ActivityIndicator color="#ef4444" />
|
||||
) : (
|
||||
<>
|
||||
<Trash2 color="#ef4444" size={18} />
|
||||
<Text className="ml-2 text-red-500 font-bold uppercase tracking-widest text-xs">
|
||||
Terminate Record
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,59 +1,46 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Linking,
|
||||
useColorScheme,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
ArrowLeft,
|
||||
DraftingCompass,
|
||||
FileText,
|
||||
Calendar,
|
||||
Share2,
|
||||
Download,
|
||||
Trash2,
|
||||
Package,
|
||||
Clock,
|
||||
Send,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
User,
|
||||
CreditCard,
|
||||
Hash,
|
||||
AlertCircle,
|
||||
} from "@/lib/icons";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||
import { StandardHeader } from "@/components/StandardHeader";
|
||||
import { api } from "@/lib/api";
|
||||
import { api, BASE_URL } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast-store";
|
||||
|
||||
const dummyData = {
|
||||
id: "dummy-1",
|
||||
proformaNumber: "PF-001",
|
||||
customerName: "John Doe",
|
||||
customerEmail: "john@example.com",
|
||||
customerPhone: "+1234567890",
|
||||
amount: { value: 1000, currency: "USD" },
|
||||
currency: "USD",
|
||||
issueDate: "2026-03-10T11:51:36.134Z",
|
||||
dueDate: "2026-03-10T11:51:36.134Z",
|
||||
description: "Dummy proforma",
|
||||
notes: "Test notes",
|
||||
taxAmount: { value: 100, currency: "USD" },
|
||||
discountAmount: { value: 50, currency: "USD" },
|
||||
pdfPath: "dummy.pdf",
|
||||
userId: "user-1",
|
||||
items: [
|
||||
{
|
||||
id: "item-1",
|
||||
description: "Test item",
|
||||
quantity: 1,
|
||||
unitPrice: { value: 1000, currency: "USD" },
|
||||
total: { value: 1000, currency: "USD" }
|
||||
}
|
||||
],
|
||||
createdAt: "2026-03-10T11:51:36.134Z",
|
||||
updatedAt: "2026-03-10T11:51:36.134Z"
|
||||
};
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
|
||||
export default function ProformaDetailScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const router = useRouter();
|
||||
const { id } = useLocalSearchParams();
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [proforma, setProforma] = useState<any>(null);
|
||||
|
|
@ -65,17 +52,60 @@ export default function ProformaDetailScreen() {
|
|||
const fetchProforma = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.proforma.getById({ params: { id: id as string } });
|
||||
// Ensure id is a string if useLocalSearchParams returns an array
|
||||
const proformaId = Array.isArray(id) ? id[0] : id;
|
||||
if (!proformaId) throw new Error("No ID provided");
|
||||
|
||||
const data = await api.proforma.getById({ params: { id: proformaId } });
|
||||
setProforma(data);
|
||||
} catch (error: any) {
|
||||
console.error("[ProformaDetail] Error:", error);
|
||||
toast.error("Error", "Failed to load proforma details");
|
||||
setProforma(dummyData); // Use dummy data for testing
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetPdf = async () => {
|
||||
try {
|
||||
const { token } = useAuthStore.getState();
|
||||
const pdfUrl = `${BASE_URL}proforma/${id}/pdf?token=${token}`;
|
||||
await Linking.openURL(pdfUrl);
|
||||
} catch (error) {
|
||||
console.error("[ProformaDetail] PDF Error:", error);
|
||||
toast.error("Error", "Failed to open PDF");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
Alert.alert(
|
||||
"Delete Proforma",
|
||||
"Are you sure you want to delete this proforma? This action cannot be undone.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const proformaId = Array.isArray(id) ? id[0] : id;
|
||||
await api.proforma.delete({
|
||||
params: { id: proformaId as string },
|
||||
});
|
||||
toast.success("Success", "Proforma deleted successfully");
|
||||
nav.back();
|
||||
} catch (error) {
|
||||
console.error("[ProformaDetail] Delete Error:", error);
|
||||
toast.error("Error", "Failed to delete proforma");
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
|
|
@ -94,234 +124,320 @@ export default function ProformaDetailScreen() {
|
|||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Proforma" showBack />
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text variant="muted">Proforma not found</Text>
|
||||
<AlertCircle size={48} color="#ef4444" className="mb-4" />
|
||||
<Text variant="h4" className="mb-1">
|
||||
Proforma Not Found
|
||||
</Text>
|
||||
<Text variant="muted">
|
||||
The requested document could not be retrieved.
|
||||
</Text>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const subtotal =
|
||||
proforma.items?.reduce(
|
||||
(acc: number, item: any) => acc + (Number(item.total) || 0),
|
||||
0,
|
||||
) || 0;
|
||||
const amountValue = Number(
|
||||
typeof proforma.amount === "object"
|
||||
? proforma.amount.value
|
||||
: proforma.amount,
|
||||
);
|
||||
|
||||
const taxAmountValue = Number(
|
||||
typeof proforma.taxAmount === "object"
|
||||
? proforma.taxAmount?.value
|
||||
: proforma.taxAmount || 0,
|
||||
);
|
||||
|
||||
const discountValue = Number(
|
||||
typeof proforma.discountAmount === "object"
|
||||
? proforma.discountAmount?.value
|
||||
: proforma.discountAmount || 0,
|
||||
);
|
||||
|
||||
const subtotalValue = amountValue - taxAmountValue + discountValue;
|
||||
const items = proforma.items || [];
|
||||
|
||||
const statusColors = {
|
||||
PAID: {
|
||||
bg: "bg-emerald-500/10",
|
||||
text: "text-emerald-500",
|
||||
dot: "bg-emerald-500",
|
||||
},
|
||||
PENDING: {
|
||||
bg: "bg-amber-500/10",
|
||||
text: "text-amber-500",
|
||||
dot: "bg-amber-500",
|
||||
},
|
||||
DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" },
|
||||
DEFAULT: {
|
||||
bg: "bg-slate-500/10",
|
||||
text: "text-slate-500",
|
||||
dot: "bg-slate-500",
|
||||
},
|
||||
};
|
||||
const status = (proforma.status || "PENDING").toUpperCase();
|
||||
const colors =
|
||||
statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
{/* Header */}
|
||||
<StandardHeader title="Proforma" showBack />
|
||||
<StandardHeader
|
||||
title={"Proforma Detail"}
|
||||
showBack
|
||||
// rightAction="edit"
|
||||
// onRightActionPress={() => nav.go("proforma/edit", { id: proforma.id })}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Proforma Info Card */}
|
||||
<Card className="bg-card rounded-[12px] mb-4 border border-border">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-3 mb-3">
|
||||
<View className="bg-primary/10 p-2 rounded-[8px]">
|
||||
<DraftingCompass color="#ea580c" size={16} strokeWidth={2.5} />
|
||||
</View>
|
||||
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
|
||||
Proforma Details
|
||||
{/* Modern Hero Area */}
|
||||
<View className="px-5 pt-4">
|
||||
<View
|
||||
className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}
|
||||
>
|
||||
<View className={`w-2 h-2 rounded-full ${colors.dot}`} />
|
||||
<Text
|
||||
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}
|
||||
>
|
||||
{status}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-xs font-bold uppercase tracking-wider mb-1"
|
||||
>
|
||||
Proforma Estimated Total
|
||||
</Text>
|
||||
<View className="flex-row items-end gap-2 mb-6">
|
||||
<Text variant="h1" className="text-4xl font-black text-foreground">
|
||||
{amountValue.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</Text>
|
||||
<Text className="text-xl font-bold text-primary mb-2">
|
||||
{proforma.currency || "ETB"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Quick Stats Grid */}
|
||||
<View className="flex-row gap-3 mb-6">
|
||||
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
|
||||
<Calendar size={16} color="#ea580c" className="mb-2" />
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
|
||||
>
|
||||
Issue Date
|
||||
</Text>
|
||||
<Text className="text-foreground font-bold text-sm">
|
||||
{new Date(
|
||||
proforma.issueDate || proforma.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
|
||||
<Clock size={16} color="#ef4444" className="mb-2" />
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
|
||||
>
|
||||
Due Date
|
||||
</Text>
|
||||
<Text className="text-foreground font-bold text-sm">
|
||||
{new Date(proforma.dueDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Proforma Number</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.proformaNumber}</Text>
|
||||
{/* Client Box */}
|
||||
<View className="px-5 mb-6">
|
||||
<View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10">
|
||||
<View className="flex-row items-center gap-3 mb-4">
|
||||
<View className="h-10 w-10 rounded-full bg-primary/20 items-center justify-center">
|
||||
<User color="#ea580c" size={20} />
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Issued Date</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.issueDate).toLocaleDateString()}</Text>
|
||||
<View>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold"
|
||||
>
|
||||
Quotation For
|
||||
</Text>
|
||||
<Text variant="p" className="text-foreground font-bold text-lg">
|
||||
{proforma.customerName || "Interested Client"}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Due Date</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.dueDate).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Currency</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.currency}</Text>
|
||||
</View>
|
||||
{proforma.description && (
|
||||
<View className="mt-2">
|
||||
<Text variant="muted" className="text-xs font-medium mb-1">Description</Text>
|
||||
<Text className="text-foreground text-sm">{proforma.description}</Text>
|
||||
</View>
|
||||
<View className="flex-row flex-wrap gap-4 pt-4 border-t border-primary/10">
|
||||
{proforma.customerEmail && (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<CreditCard size={12} color="#64748b" />
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{proforma.customerEmail}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Customer Info Card */}
|
||||
<Card className="bg-card rounded-[12px] mb-4 border border-border">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-3 mb-3">
|
||||
<View className="bg-primary/10 p-2 rounded-[8px]">
|
||||
<CheckCircle2 color="#ea580c" size={16} strokeWidth={2.5} />
|
||||
</View>
|
||||
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
|
||||
Customer Information
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Name</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.customerName}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Email</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.customerEmail || "N/A"}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Phone</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.customerPhone || "N/A"}</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Hash size={12} color="#64748b" />
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
#{proforma.id.split("-")[0]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
|
||||
|
||||
{/* Line Items Card */}
|
||||
<Card className="bg-card rounded-[6px] mb-4">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-bold uppercase tracking-widest text-[10px] opacity-60"
|
||||
>
|
||||
Line Items
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{proforma.items?.map((item: any, i: number) => (
|
||||
{/* Detailed Items Table */}
|
||||
<View className="px-5 mb-6">
|
||||
<Text variant="h4" className="font-bold mb-4 px-1">
|
||||
Estimate Summary
|
||||
</Text>
|
||||
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
|
||||
{items.map((item: any, idx: number) => (
|
||||
<View
|
||||
key={item.id || i}
|
||||
className={`flex-row justify-between py-3 ${i < proforma.items.length - 1 ? "border-b border-border/40" : ""}`}
|
||||
key={idx}
|
||||
className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}
|
||||
>
|
||||
<View className="flex-1 pr-4">
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold text-sm"
|
||||
>
|
||||
<View className="flex-row justify-between items-start mb-1">
|
||||
<Text className="text-foreground font-bold flex-1 mr-4">
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text variant="muted" className="text-[10px] mt-0.5">
|
||||
{item.quantity} × {proforma.currency}{" "}
|
||||
{Number(item.unitPrice).toLocaleString()}
|
||||
<Text className="text-foreground font-black">
|
||||
{Number(
|
||||
item.total?.value || item.total || 0,
|
||||
).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||
{proforma.currency} {Number(item.total).toLocaleString()}
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{item.quantity} units x{" "}
|
||||
{Number(
|
||||
item.unitPrice?.value || item.unitPrice || 0,
|
||||
).toLocaleString()}{" "}
|
||||
{proforma.currency}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View className="mt-3 pt-3 border-t border-border/40 gap-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold text-sm"
|
||||
>
|
||||
Subtotal
|
||||
</Text>
|
||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||
{proforma.currency} {subtotal.toLocaleString()}
|
||||
</Text>
|
||||
{items.length === 0 && (
|
||||
<View className="p-8 items-center bg-muted/20">
|
||||
<Package size={32} color="#cbd5e1" className="mb-2" />
|
||||
<Text variant="muted">Empty line items list</Text>
|
||||
</View>
|
||||
{Number(proforma.taxAmount) > 0 && (
|
||||
<View className="flex-row justify-between">
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold text-sm"
|
||||
>
|
||||
Tax
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-bold text-sm"
|
||||
>
|
||||
{proforma.currency}{" "}
|
||||
{Number(proforma.taxAmount).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{Number(proforma.discountAmount) > 0 && (
|
||||
<View className="flex-row justify-between">
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-red-500 font-semibold text-sm"
|
||||
>
|
||||
Discount
|
||||
</Text>
|
||||
<Text variant="p" className="text-red-500 font-bold text-sm">
|
||||
-{proforma.currency}{" "}
|
||||
{Number(proforma.discountAmount).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="flex-row justify-between items-center mt-1">
|
||||
<Text variant="p" className="text-foreground font-bold">
|
||||
Total Amount
|
||||
</Text>
|
||||
<Text
|
||||
variant="h4"
|
||||
className="text-foreground font-bold tracking-tight"
|
||||
>
|
||||
{proforma.currency} {Number(proforma.amount).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
{/* Notes Section (New) */}
|
||||
{proforma.notes && (
|
||||
<Card className="bg-card rounded-[6px] mb-4">
|
||||
<View className="p-4">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-bold uppercase tracking-widest text-[10px] opacity-60 mb-2"
|
||||
>
|
||||
Additional Notes
|
||||
{/* Billing Breakdown */}
|
||||
<View className="px-5 mb-6">
|
||||
<Card className="bg-card rounded-[6px] p-5 shadow-sm shadow-black/5 border-border/60">
|
||||
<View className="flex-row justify-between mb-4">
|
||||
<Text className="text-muted-foreground font-medium">
|
||||
Net Price
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-medium text-xs leading-5"
|
||||
>
|
||||
{proforma.notes}
|
||||
<Text className="text-foreground font-bold">
|
||||
{subtotalValue.toLocaleString()} {proforma.currency}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{taxAmountValue > 0 && (
|
||||
<View className="flex-row justify-between mb-4">
|
||||
<Text className="text-muted-foreground font-medium">
|
||||
Estimated Tax
|
||||
</Text>
|
||||
<Text className="text-emerald-500 font-bold">
|
||||
+{taxAmountValue.toLocaleString()} {proforma.currency}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{discountValue > 0 && (
|
||||
<View className="flex-row justify-between mb-4">
|
||||
<Text className="text-muted-foreground font-medium">
|
||||
Applicable Discount
|
||||
</Text>
|
||||
<Text className="text-rose-500 font-bold">
|
||||
-{discountValue.toLocaleString()} {proforma.currency}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center">
|
||||
<View>
|
||||
<Text className="text-foreground font-black text-xl">
|
||||
Estimated Total
|
||||
</Text>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold tracking-tighter"
|
||||
>
|
||||
Valid as of today
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-primary font-black text-2xl">
|
||||
{amountValue.toLocaleString()} {proforma.currency}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
{proforma.notes && (
|
||||
<View className="px-5 mb-10">
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] uppercase font-bold mb-2"
|
||||
>
|
||||
Internal Notes
|
||||
</Text>
|
||||
<Text className="text-foreground font-medium italic opacity-80 leading-5">
|
||||
" {proforma.notes} "
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<View className="gap-3">
|
||||
<Button
|
||||
className="h-12 rounded-[10px] bg-transparent border border-border"
|
||||
onPress={() => router.push("/proforma/edit?id=" + proforma.id)}
|
||||
>
|
||||
<DraftingCompass color="#fff" size={16} strokeWidth={2.5} />
|
||||
<Text className="ml-2 text-foreground font-black text-[12px] uppercase tracking-widest">
|
||||
Edit
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className="h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
|
||||
onPress={() => {}}
|
||||
>
|
||||
<Send color="#ffffff" size={16} strokeWidth={2.5} />
|
||||
<Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
|
||||
Share SMS
|
||||
</Text>
|
||||
</Button>
|
||||
{/* Premium Actions */}
|
||||
<View className="px-5 gap-3">
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
|
||||
onPress={() => nav.go("proforma/edit", { id: proforma.id })}
|
||||
>
|
||||
<Share2 color="#ffffff" size={18} strokeWidth={2.5} />
|
||||
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
|
||||
Edit Detail
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-14 rounded-[6px] bg-card border border-border"
|
||||
onPress={handleGetPdf}
|
||||
>
|
||||
<Download
|
||||
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||
size={18}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
|
||||
Export PDF
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -34,6 +35,26 @@ import { getPlaceholderColor } from "@/lib/colors";
|
|||
|
||||
type Item = { id: number; description: string; qty: string; price: string };
|
||||
|
||||
const dummyData = {
|
||||
proformaNumber: "PF-2024-001",
|
||||
customerName: "Acme Corp",
|
||||
customerEmail: "contact@acme.com",
|
||||
customerPhone: "+1234567890",
|
||||
currency: "USD",
|
||||
description: "Web development services",
|
||||
notes: "Payment due within 30 days",
|
||||
taxAmount: 15.0,
|
||||
taxAmountValue: 15.0,
|
||||
discountAmount: 10.0,
|
||||
discountAmountValue: 10.0,
|
||||
issueDate: new Date().toISOString(),
|
||||
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
items: [
|
||||
{ description: "Homepage Design", quantity: 1, unitPrice: 500 },
|
||||
{ description: "Mobile App Refactoring", quantity: 1, unitPrice: 1200 },
|
||||
],
|
||||
};
|
||||
|
||||
const S = StyleSheet.create({
|
||||
input: {
|
||||
height: 44,
|
||||
|
|
@ -55,12 +76,12 @@ const S = StyleSheet.create({
|
|||
});
|
||||
|
||||
function useInputColors() {
|
||||
const { colorScheme } = useColorScheme(); // Fix usage
|
||||
const dark = colorScheme === "dark";
|
||||
const { colorScheme } = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
return {
|
||||
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||
text: dark ? "#f1f5f9" : "#0f172a",
|
||||
bg: isDark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||
border: isDark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||
text: isDark ? "#f1f5f9" : "#0f172a",
|
||||
placeholder: "rgba(100,116,139,0.45)",
|
||||
};
|
||||
}
|
||||
|
|
@ -83,7 +104,6 @@ function Field({
|
|||
flex?: number;
|
||||
}) {
|
||||
const c = useInputColors();
|
||||
const isDark = colorScheme.get() === "dark";
|
||||
return (
|
||||
<View style={flex != null ? { flex } : undefined}>
|
||||
<Text
|
||||
|
|
@ -160,8 +180,20 @@ export default function EditProformaScreen() {
|
|||
setCurrency(data.currency || "USD");
|
||||
setDescription(data.description || "");
|
||||
setNotes(data.notes || "");
|
||||
setTaxAmount(String(data.taxAmount?.value || data.taxAmount || ""));
|
||||
setDiscountAmount(String(data.discountAmount?.value || data.discountAmount || ""));
|
||||
setTaxAmount(
|
||||
String(
|
||||
typeof data.taxAmount === "object"
|
||||
? data.taxAmount?.value
|
||||
: data.taxAmount || "",
|
||||
),
|
||||
);
|
||||
setDiscountAmount(
|
||||
String(
|
||||
typeof data.discountAmount === "object"
|
||||
? data.discountAmount?.value
|
||||
: data.discountAmount || "",
|
||||
),
|
||||
);
|
||||
setIssueDate(new Date(data.issueDate));
|
||||
setDueDate(new Date(data.dueDate));
|
||||
setItems(
|
||||
|
|
@ -170,7 +202,7 @@ export default function EditProformaScreen() {
|
|||
description: item.description || "",
|
||||
qty: String(item.quantity || ""),
|
||||
price: String(item.unitPrice?.value || item.unitPrice || ""),
|
||||
})) || [{ id: 1, description: "", qty: "", price: "" }]
|
||||
})) || [{ id: 1, description: "", qty: "", price: "" }],
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Error", "Failed to load proforma, using test data");
|
||||
|
|
@ -182,8 +214,14 @@ export default function EditProformaScreen() {
|
|||
setCurrency(dummyData.currency);
|
||||
setDescription(dummyData.description);
|
||||
setNotes(dummyData.notes);
|
||||
setTaxAmount(String(dummyData.taxAmount?.value || dummyData.taxAmount || ""));
|
||||
setDiscountAmount(String(dummyData.discountAmount?.value || dummyData.discountAmount || ""));
|
||||
setTaxAmount(
|
||||
String(dummyData.taxAmount?.value || dummyData.taxAmount || ""),
|
||||
);
|
||||
setDiscountAmount(
|
||||
String(
|
||||
dummyData.discountAmount?.value || dummyData.discountAmount || "",
|
||||
),
|
||||
);
|
||||
setIssueDate(new Date(dummyData.issueDate));
|
||||
setDueDate(new Date(dummyData.dueDate));
|
||||
setItems(
|
||||
|
|
@ -192,7 +230,7 @@ export default function EditProformaScreen() {
|
|||
description: item.description || "",
|
||||
qty: String(item.quantity || ""),
|
||||
price: String(item.unitPrice?.value || item.unitPrice || ""),
|
||||
})) || [{ id: 1, description: "", qty: "", price: "" }]
|
||||
})) || [{ id: 1, description: "", qty: "", price: "" }],
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -211,9 +249,7 @@ export default function EditProformaScreen() {
|
|||
};
|
||||
|
||||
const updateItem = (id: number, field: keyof Item, value: string) => {
|
||||
setItems(
|
||||
items.map((i) => (i.id === id ? { ...i, [field]: value } : i))
|
||||
);
|
||||
setItems(items.map((i) => (i.id === id ? { ...i, [field]: value } : i)));
|
||||
};
|
||||
|
||||
const calculateSubtotal = () => {
|
||||
|
|
@ -284,7 +320,10 @@ export default function EditProformaScreen() {
|
|||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title={isEdit ? "Edit Proforma" : "Create Proforma"} showBack />
|
||||
<StandardHeader
|
||||
title={isEdit ? "Edit Proforma" : "Create Proforma"}
|
||||
showBack
|
||||
/>
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<ActivityIndicator color="#ea580c" size="large" />
|
||||
</View>
|
||||
|
|
@ -297,9 +336,10 @@ export default function EditProformaScreen() {
|
|||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<StandardHeader title={isEdit ? "Edit Proforma" : "Create Proforma"} showBack />
|
||||
|
||||
<StandardHeader
|
||||
title={isEdit ? "Edit Proforma" : "Create Proforma"}
|
||||
showBack
|
||||
/>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
|
||||
|
|
@ -414,7 +454,9 @@ export default function EditProformaScreen() {
|
|||
onPress={addItem}
|
||||
>
|
||||
<Plus color="#ffffff" size={14} />
|
||||
<Text className="ml-1 text-white text-xs font-bold">Add Item</Text>
|
||||
<Text className="ml-1 text-white text-xs font-bold">
|
||||
Add Item
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
|
|
@ -517,7 +559,6 @@ export default function EditProformaScreen() {
|
|||
</View>
|
||||
</ShadowWrapper>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Action */}
|
||||
<View className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t border-border">
|
||||
<Button
|
||||
|
|
@ -537,7 +578,6 @@ export default function EditProformaScreen() {
|
|||
)}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Modals */}
|
||||
<PickerModal
|
||||
visible={currencyModal}
|
||||
|
|
@ -557,28 +597,33 @@ export default function EditProformaScreen() {
|
|||
/>
|
||||
))}
|
||||
</PickerModal>
|
||||
|
||||
// @ts-ignore
|
||||
<CalendarGrid
|
||||
open={issueModal}
|
||||
current={issueDate.toISOString().substring(0,10)}
|
||||
onDateSelect={(dateStr: string) => {
|
||||
setIssueDate(new Date(dateStr));
|
||||
setIssueModal(false);
|
||||
}}
|
||||
<PickerModal
|
||||
visible={issueModal}
|
||||
title="Select Issue Date"
|
||||
onClose={() => setIssueModal(false)}
|
||||
/>
|
||||
>
|
||||
<CalendarGrid
|
||||
onSelect={(dateStr: string) => {
|
||||
setIssueDate(new Date(dateStr));
|
||||
setIssueModal(false);
|
||||
}}
|
||||
selectedDate={issueDate.toISOString().substring(0, 10)}
|
||||
/>
|
||||
</PickerModal>
|
||||
|
||||
// @ts-ignore
|
||||
<CalendarGrid
|
||||
open={dueModal}
|
||||
current={dueDate.toISOString().substring(0,10)}
|
||||
onDateSelect={(dateStr: string) => {
|
||||
setDueDate(new Date(dateStr));
|
||||
setDueModal(false);
|
||||
}}
|
||||
<PickerModal
|
||||
visible={dueModal}
|
||||
title="Select Due Date"
|
||||
onClose={() => setDueModal(false)}
|
||||
/>
|
||||
>
|
||||
<CalendarGrid
|
||||
onSelect={(dateStr: string) => {
|
||||
setDueDate(new Date(dateStr));
|
||||
setDueModal(false);
|
||||
}}
|
||||
selectedDate={dueDate.toISOString().substring(0, 10)}
|
||||
/>
|
||||
</PickerModal>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -1,21 +1,83 @@
|
|||
import { Middleware } from "@simple-api/core";
|
||||
import { useAuthStore } from "./auth-store";
|
||||
import { toast } from "./toast-store";
|
||||
|
||||
/**
|
||||
* Decode base64url string (used for JWT payloads)
|
||||
* React Native does not have a global Buffer, so we use a simple decoder.
|
||||
*/
|
||||
function decodeBase64(str: string) {
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||
let out = "";
|
||||
str = str.replace(/[-_]/g, (m) => ({ "-": "+", _: "/" })[m] || m);
|
||||
while (str.length % 4) str += "=";
|
||||
|
||||
for (let i = 0; i < str.length; i += 4) {
|
||||
const a = chars.indexOf(str.charAt(i));
|
||||
const b = chars.indexOf(str.charAt(i + 1));
|
||||
const c = chars.indexOf(str.charAt(i + 2));
|
||||
const d = chars.indexOf(str.charAt(i + 3));
|
||||
|
||||
out += String.fromCharCode((a << 2) | (b >> 4));
|
||||
if (c !== 64) out += String.fromCharCode(((b & 15) << 4) | (c >> 2));
|
||||
if (d !== 64) out += String.fromCharCode(((c & 3) << 6) | d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract payload from a JWT token
|
||||
*/
|
||||
function decodeJwtPayload(token: string) {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const json = decodeBase64(parts[1]);
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is expired or near expiry
|
||||
*/
|
||||
function isTokenExpired(token: string | null, bufferSeconds = 30) {
|
||||
if (!token) return true;
|
||||
const payload = decodeJwtPayload(token);
|
||||
if (!payload || !payload.exp) return true;
|
||||
const expiresAtMs = payload.exp * 1000;
|
||||
return Date.now() + bufferSeconds * 1000 >= expiresAtMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to inject the authentication token into requests.
|
||||
* Skips login, register, and refresh endpoints.
|
||||
* Now proactively refreshes token if it's about to expire.
|
||||
*/
|
||||
export const authMiddleware: Middleware = async ({ config, options }, next) => {
|
||||
const { token } = useAuthStore.getState();
|
||||
let { token } = useAuthStore.getState();
|
||||
|
||||
// Don't send Authorization header for sensitive auth-related endpoints,
|
||||
// EXCEPT for logout which needs to identify the session.
|
||||
const isAuthPath =
|
||||
config.path === "auth/login" ||
|
||||
config.path === "auth/register" ||
|
||||
config.path === "auth/refresh";
|
||||
|
||||
if (token && !isAuthPath) {
|
||||
// Proactive Expiration Check
|
||||
if (isTokenExpired(token)) {
|
||||
console.log(
|
||||
`[AuthProactive] Token near expiry or invalid. Refreshing proactively...`,
|
||||
);
|
||||
const newToken = await refreshTokens({
|
||||
path: config.path,
|
||||
type: "PROACTIVE_REFRESH",
|
||||
});
|
||||
if (newToken) {
|
||||
token = newToken;
|
||||
}
|
||||
}
|
||||
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
|
|
@ -25,8 +87,94 @@ export const authMiddleware: Middleware = async ({ config, options }, next) => {
|
|||
return await next(options);
|
||||
};
|
||||
|
||||
// Global promise to handle concurrent refreshes
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
/**
|
||||
* Proactively refreshes the access token using the current refresh token.
|
||||
* Coordinates multiple concurrent calls to ensure only one network request is made.
|
||||
*/
|
||||
export async function refreshTokens(
|
||||
config?: any,
|
||||
force = false,
|
||||
): Promise<string | null> {
|
||||
const { refreshToken, token, setTokens, logout, isAuthenticated } =
|
||||
useAuthStore.getState();
|
||||
|
||||
if (!isAuthenticated || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If not forced, check if we actually need a refresh
|
||||
if (!force && !isTokenExpired(token)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Coordination: If a refresh is already in progress, wait for it
|
||||
if (refreshPromise) {
|
||||
return await refreshPromise;
|
||||
}
|
||||
|
||||
// No refresh in progress, start one
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const refreshUrl = `https://api.yaltopiaticket.com/auth/refresh`;
|
||||
|
||||
console.log("[AuthRefresh] Starting network refresh...");
|
||||
|
||||
const response = await fetch(refreshUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
console.error(
|
||||
`[AuthRefresh] 401 on refresh. Session definitively dead. Path: ${config?.path || "Heartbeat"}`,
|
||||
);
|
||||
toast.show({
|
||||
type: "error",
|
||||
title: "Session Expired",
|
||||
message:
|
||||
"You have been logged out because your session has expired. Please sign in again. 🛡️",
|
||||
duration: 9000,
|
||||
});
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `Refresh failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newAT = data.accessToken || data.access_token;
|
||||
const newRT = data.refreshToken || data.refresh_token;
|
||||
|
||||
if (!newAT) throw new Error("No access token in response");
|
||||
|
||||
setTokens(newAT, newRT);
|
||||
console.log("[AuthRefresh] Tokens successfully updated.");
|
||||
return newAT;
|
||||
} catch (err: any) {
|
||||
console.error("[AuthRefresh] Refresh failed:", err.message);
|
||||
return null;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return await refreshPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to handle token refreshment on 401 Unauthorized errors.
|
||||
* Includes a queue mechanism to handle concurrent 401s.
|
||||
*/
|
||||
export const refreshMiddleware: Middleware = async (
|
||||
{ config, options },
|
||||
|
|
@ -36,81 +184,35 @@ export const refreshMiddleware: Middleware = async (
|
|||
return await next(options);
|
||||
} catch (error: any) {
|
||||
const status = error.status || error.statusCode;
|
||||
const { refreshToken, setAuth, logout } = useAuthStore.getState();
|
||||
const { refreshToken } = useAuthStore.getState();
|
||||
|
||||
// Skip refresh logic for the login/refresh endpoints themselves
|
||||
const isAuthPath =
|
||||
config.path?.includes("auth/login") ||
|
||||
config.path?.includes("auth/refresh");
|
||||
|
||||
if (status === 401 && refreshToken && !isAuthPath) {
|
||||
console.log(
|
||||
`[API Refresh] 401 detected for ${config.path}. Attempting refresh...`,
|
||||
);
|
||||
// Force refresh on 401 even if we think it's fresh (since server says it's not)
|
||||
if (status === 401 && !isAuthPath) {
|
||||
if (refreshToken) {
|
||||
console.log(
|
||||
`[API Refresh] 401 detected for ${config.path}. Forcing refresh...`,
|
||||
);
|
||||
try {
|
||||
const accessToken = await refreshTokens(config, true);
|
||||
|
||||
try {
|
||||
// We call the refresh endpoint manually here to avoid circular dependencies with the 'api' object
|
||||
const refreshUrl = `${config.baseUrl}auth/refresh`;
|
||||
if (!accessToken) {
|
||||
throw new Error("Failed to obtain fresh access token");
|
||||
}
|
||||
|
||||
const response = await fetch(refreshUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refreshToken,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const refreshErr = new Error(
|
||||
errorData.message ||
|
||||
`Refresh failed with status ${response.status}`,
|
||||
) as any;
|
||||
refreshErr.status = response.status;
|
||||
throw refreshErr;
|
||||
// Retry the original request
|
||||
console.log(`[API Refresh] Retrying ${config.path} with new token.`);
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
};
|
||||
return await next(options);
|
||||
} catch (refreshError: any) {
|
||||
throw refreshError;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Backend might return snake_case (access_token) or camelCase (accessToken)
|
||||
// We handle both to be safe when using raw fetch
|
||||
const accessToken = data.accessToken || data.access_token;
|
||||
const newRefreshToken = data.refreshToken || data.refresh_token;
|
||||
const user = data.user;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error("No access token returned from refresh");
|
||||
}
|
||||
|
||||
setAuth(user, accessToken, newRefreshToken);
|
||||
|
||||
console.log("[API Refresh] Success. Retrying original request...");
|
||||
|
||||
// Update headers and retry
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
};
|
||||
|
||||
return await next(options);
|
||||
} catch (refreshError: any) {
|
||||
// Only logout if the refresh token itself is invalid (400, 401, 403)
|
||||
// If it's a network error, we should NOT logout the user.
|
||||
const refreshStatus = refreshError.status || refreshError.statusCode;
|
||||
const isAuthError = refreshStatus === 401;
|
||||
|
||||
if (isAuthError) {
|
||||
console.error("[API Refresh] Invalid refresh token. Logging out.");
|
||||
logout();
|
||||
} else {
|
||||
console.error(
|
||||
"[API Refresh] Network error or server issues during refresh. Staying logged in.",
|
||||
);
|
||||
}
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
11
lib/api.ts
11
lib/api.ts
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: "/",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
418
locales/en.json
418
locales/en.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"frameworks": ["react"],
|
||||
"localesDir": "./locales"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in New Issue
Block a user