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