Compare commits
3 Commits
main
...
ui-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
| be2bde41a2 | |||
| 7162fb87e8 | |||
| 1b41dbd97a |
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -39,3 +39,6 @@ yarn-error.*
|
||||||
# generated native folders
|
# generated native folders
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
|
|
||||||
0
.windsurf/workflows/login.md
Normal file
0
.windsurf/workflows/login.md
Normal file
55
app.json
55
app.json
|
|
@ -1 +1,54 @@
|
||||||
{"expo":{"name":"Yaltopia Tickets App","slug":"yaltopia-tickets-app","version":"1.0.0","orientation":"portrait","icon":"./assets/icon.png","userInterfaceStyle":"light","newArchEnabled":true,"splash":{"image":"./assets/splash-icon.png","resizeMode":"contain","backgroundColor":"#ffffff"},"ios":{"supportsTablet":true},"android":{"adaptiveIcon":{"foregroundImage":"./assets/adaptive-icon.png","backgroundColor":"#ffffff"},"edgeToEdgeEnabled":true,"predictiveBackGestureEnabled":false},"web":{"favicon":"./assets/favicon.png","bundler":"metro"},"scheme":"yaltopia-tickets"}}
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Yaltopia Tickets App",
|
||||||
|
"slug": "yaltopia-tickets-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.yaltopia.ticketapp"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.yaltopia.ticketapp",
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.READ_SMS",
|
||||||
|
"android.permission.RECEIVE_SMS",
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.RECORD_AUDIO"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow Yaltopia Tickets App to access your camera to scan invoices."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["@react-native-google-signin/google-signin"]
|
||||||
|
],
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png",
|
||||||
|
"bundler": "metro"
|
||||||
|
},
|
||||||
|
"scheme": "yaltopia-tickets",
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "9b79b7de-5639-41ef-a72c-8c226354cd2e"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,127 @@
|
||||||
import { Tabs } from 'expo-router';
|
import { Tabs, router } from "expo-router";
|
||||||
import { Home, ScanLine, FileText, Wallet, User } from '@/lib/icons';
|
import { Home, ScanLine, FileText, Wallet, Newspaper, Scan } from "@/lib/icons";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { Platform, View, Pressable } from "react-native";
|
||||||
|
|
||||||
const NAV_BG = '#2d2d2d';
|
const ACTIVE_TINT = "rgba(228, 98, 18, 1)";
|
||||||
const ACTIVE_TINT = '#ea580c';
|
const INACTIVE_TINT = "#94a3b8";
|
||||||
const INACTIVE_TINT = '#a1a1aa';
|
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const NAV_BG =
|
||||||
|
colorScheme === "dark" ? "rgba(31,31,31, 1)" : "rgba(255,255,255, 1)";
|
||||||
|
const BORDER_COLOR = isDark ? "#1e293b" : "#ffffff";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: { backgroundColor: NAV_BG },
|
headerShown: false,
|
||||||
headerTintColor: '#ffffff',
|
tabBarShowLabel: true,
|
||||||
headerTitleStyle: { fontWeight: '600', fontSize: 18 },
|
|
||||||
tabBarStyle: { backgroundColor: NAV_BG, paddingTop: 8 },
|
|
||||||
tabBarActiveTintColor: ACTIVE_TINT,
|
tabBarActiveTintColor: ACTIVE_TINT,
|
||||||
tabBarInactiveTintColor: INACTIVE_TINT,
|
tabBarInactiveTintColor: INACTIVE_TINT,
|
||||||
tabBarLabelStyle: { fontSize: 11 },
|
tabBarButton: ({ ref, ...navProps }) => (
|
||||||
tabBarShowLabel: true,
|
<Pressable {...navProps} android_ripple={null} />
|
||||||
|
),
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: "700",
|
||||||
|
marginBottom: Platform.OS === "ios" ? 0 : 4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: NAV_BG,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
elevation: isDark ? 0 : 6,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: -10 },
|
||||||
|
shadowOpacity: isDark ? 0 : 0.1,
|
||||||
|
shadowRadius: 20,
|
||||||
|
height: Platform.OS === "ios" ? 75 : 75,
|
||||||
|
paddingBottom: Platform.OS === "ios" ? 30 : 10,
|
||||||
|
paddingTop: 10,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 25,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
borderRadius: 32,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Home',
|
tabBarLabel: "Home",
|
||||||
tabBarLabel: 'Home',
|
tabBarIcon: ({ color, focused }) => (
|
||||||
tabBarIcon: ({ color, size }) => <Home color={color} size={size ?? 22} strokeWidth={2} />,
|
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}>
|
||||||
}}
|
<Home color={color} size={18} strokeWidth={focused ? 2.5 : 2} />
|
||||||
/>
|
</View>
|
||||||
<Tabs.Screen
|
),
|
||||||
name="scan"
|
|
||||||
options={{
|
|
||||||
title: 'Scan Invoice',
|
|
||||||
tabBarLabel: 'Scan',
|
|
||||||
tabBarIcon: ({ color, size }) => <ScanLine color={color} size={size ?? 22} strokeWidth={2} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="proforma"
|
|
||||||
options={{
|
|
||||||
title: 'Proforma',
|
|
||||||
tabBarLabel: 'Proforma',
|
|
||||||
tabBarIcon: ({ color, size }) => <FileText color={color} size={size ?? 22} strokeWidth={2} />,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="payments"
|
name="payments"
|
||||||
options={{
|
options={{
|
||||||
title: 'Payments',
|
tabBarLabel: "Payments",
|
||||||
tabBarLabel: 'Payments',
|
tabBarIcon: ({ color, focused }) => (
|
||||||
tabBarIcon: ({ color, size }) => <Wallet color={color} size={size ?? 22} strokeWidth={2} />,
|
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}>
|
||||||
|
<Wallet color={color} size={18} strokeWidth={focused ? 2.5 : 2} />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="profile"
|
name="scan"
|
||||||
options={{
|
options={{
|
||||||
title: 'Profile',
|
tabBarLabel: "SCAN",
|
||||||
tabBarLabel: 'Profile',
|
tabBarLabelStyle: {
|
||||||
tabBarIcon: ({ color, size }) => <User color={color} size={size ?? 22} strokeWidth={2} />,
|
fontSize: 9,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: INACTIVE_TINT,
|
||||||
|
},
|
||||||
|
tabBarIcon: ({ focused }) => (
|
||||||
|
<View className="-mt-12">
|
||||||
|
<View
|
||||||
|
className="h-16 w-16 rounded-full bg-primary items-center justify-center border-4"
|
||||||
|
style={{ borderColor: BORDER_COLOR }}
|
||||||
|
>
|
||||||
|
<ScanLine color="white" size={28} strokeWidth={3} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="proforma"
|
||||||
|
options={{
|
||||||
|
tabBarLabel: "Proforma",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}>
|
||||||
|
<FileText
|
||||||
|
color={color}
|
||||||
|
size={18}
|
||||||
|
strokeWidth={focused ? 2.5 : 2}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="news"
|
||||||
|
options={{
|
||||||
|
tabBarLabel: "News",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}>
|
||||||
|
<Newspaper
|
||||||
|
color={color}
|
||||||
|
size={18}
|
||||||
|
strokeWidth={focused ? 2.5 : 2}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -1,147 +1,354 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import React, { useState } from "react";
|
||||||
import { Text } from '@/components/ui/text';
|
import {
|
||||||
import { Button } from '@/components/ui/button';
|
View,
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
ScrollView,
|
||||||
import { EARNINGS_SUMMARY, MOCK_INVOICES, MOCK_USER } from '@/lib/mock-data';
|
Pressable,
|
||||||
import { router } from 'expo-router';
|
ActivityIndicator,
|
||||||
import { Camera, Send, ChevronRight, Wallet, DollarSign, Clock } from '@/lib/icons';
|
useColorScheme,
|
||||||
|
} from "react-native";
|
||||||
const PRIMARY = '#ea580c';
|
import { api } from "@/lib/api";
|
||||||
const statusColor: Record<string, string> = {
|
import { Text } from "@/components/ui/text";
|
||||||
Waiting: 'bg-amber-500/20 text-amber-700',
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
Paid: 'bg-emerald-500/20 text-emerald-700',
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
Draft: 'bg-gray-200 text-gray-700',
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
Unpaid: 'bg-red-500/20 text-red-700',
|
import { AppRoutes } from "@/lib/routes";
|
||||||
};
|
import {
|
||||||
|
Plus,
|
||||||
|
History as HistoryIcon,
|
||||||
|
Briefcase,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
FileText,
|
||||||
|
ScanLine,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
|
const [activeFilter, setActiveFilter] = useState("All");
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
total: 0,
|
||||||
|
paid: 0,
|
||||||
|
pending: 0,
|
||||||
|
overdue: 0,
|
||||||
|
totalRevenue: 0,
|
||||||
|
});
|
||||||
|
const [invoices, setInvoices] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchInvoices();
|
||||||
|
}, [activeFilter]);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.invoices.stats();
|
||||||
|
setStats(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[HomeScreen] Failed to fetch stats:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchInvoices = async () => {
|
||||||
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const statusParam =
|
||||||
|
activeFilter === "All" ? undefined : activeFilter.toUpperCase();
|
||||||
|
const response = await api.invoices.getAll({
|
||||||
|
query: {
|
||||||
|
limit: 5,
|
||||||
|
status: statusParam,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setInvoices(response.data || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[HomeScreen] Failed to fetch invoices:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScreenWrapper className="bg-background">
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
<ScrollView
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
showsVerticalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
contentContainerStyle={{
|
||||||
>
|
|
||||||
<View className="mb-5">
|
paddingTop: 10,
|
||||||
<Text className="text-2xl font-bold text-gray-900">Hi {MOCK_USER.name},</Text>
|
paddingBottom: 150,
|
||||||
<Text className="text-muted-foreground mt-1 text-base">Take a look at your last activity.</Text>
|
}}
|
||||||
</View>
|
>
|
||||||
|
<StandardHeader />
|
||||||
|
{/* Balance Card Section */}
|
||||||
|
<View className="px-[16px] pt-6">
|
||||||
|
<View className="mb-4">
|
||||||
|
|
||||||
|
<Card className="overflow-hidden rounded-[10px] border-0 bg-primary">
|
||||||
|
<View className="p-4 relative">
|
||||||
|
<View
|
||||||
|
className="absolute -top-10 -right-10 w-48 h-48 bg-white/10 rounded-full"
|
||||||
|
style={{ transform: [{ scale: 1.5 }] }}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card className="mb-5 overflow-hidden rounded-2xl border-0 shadow-sm">
|
<Text className="text-white/60 text-[14px] font-semibold">
|
||||||
<View className="bg-primary/10 px-5 py-5">
|
Available Balance
|
||||||
<Text className="text-muted-foreground text-sm">Earnings balance</Text>
|
</Text>
|
||||||
<Text className="mt-1 text-3xl font-bold text-gray-900">${EARNINGS_SUMMARY.balance.toLocaleString()}</Text>
|
<View className="mt-2 flex-row items-baseline">
|
||||||
|
<Text className="text-white text-2xl font-medium">$</Text>
|
||||||
|
<Text className="ml-1 text-4xl font-bold text-white">
|
||||||
|
{stats.total.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-4 flex-row gap-4">
|
||||||
|
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl">
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<View className="p-1.5 bg-white/20 rounded-lg">
|
||||||
|
<Clock color="white" size={12} strokeWidth={2.5} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-white text-[12px] font-semibold">
|
||||||
|
Pending
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-white font-bold text-xl mt-2">
|
||||||
|
${stats.pending.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl">
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<View className="p-1.5 bg-white/20 rounded-lg">
|
||||||
|
<DollarSign color="white" size={12} strokeWidth={2.5} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-white text-[12px] font-semibold">
|
||||||
|
Income
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-white font-bold text-xl mt-2">
|
||||||
|
${stats.totalRevenue.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row border-t border-border">
|
|
||||||
|
{/* Circular Quick Actions Section */}
|
||||||
|
<View className="mb-4 flex-row justify-around items-center px-2">
|
||||||
|
<QuickAction
|
||||||
|
icon={
|
||||||
|
<Briefcase
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Company"
|
||||||
|
onPress={() => nav.go("company")}
|
||||||
|
/>
|
||||||
|
<QuickAction
|
||||||
|
icon={
|
||||||
|
<ScanLine
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Scan SMS"
|
||||||
|
onPress={() => nav.go("sms-scan")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QuickAction
|
||||||
|
icon={
|
||||||
|
<Plus
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Create Proforma"
|
||||||
|
onPress={() => nav.go("proforma/create")}
|
||||||
|
/>
|
||||||
|
<QuickAction
|
||||||
|
icon={
|
||||||
|
<HistoryIcon
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="History"
|
||||||
|
onPress={() => nav.go("history")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Recent Activity Header */}
|
||||||
|
<View className="mb-4 flex-row justify-between items-center">
|
||||||
|
<Text variant="h4" className="text-foreground tracking-tight">
|
||||||
|
Recent Activity
|
||||||
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
className="flex-1 flex-row items-center gap-3 px-5 py-4"
|
onPress={() => nav.go("history")}
|
||||||
onPress={() => router.push('/(tabs)/payments')}
|
className="px-4 py-2 rounded-full"
|
||||||
>
|
>
|
||||||
<View className="rounded-xl bg-primary/15 p-2">
|
<Text className="text-primary font-bold text-xs">View all</Text>
|
||||||
<Clock color={PRIMARY} size={20} strokeWidth={2} />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="text-muted-foreground text-xs">Waiting for pay</Text>
|
|
||||||
<Text className="font-semibold text-gray-900">${EARNINGS_SUMMARY.waitingAmount.toLocaleString()}</Text>
|
|
||||||
<Text className="text-muted-foreground text-xs">{EARNINGS_SUMMARY.waitingCount} Waiting invoice</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
<View className="w-px bg-border" />
|
|
||||||
<Pressable className="flex-1 flex-row items-center gap-3 px-5 py-4">
|
|
||||||
<View className="rounded-xl bg-emerald-500/15 p-2">
|
|
||||||
<DollarSign color="#059669" size={20} strokeWidth={2} />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="text-muted-foreground text-xs">Paid this month</Text>
|
|
||||||
<Text className="font-semibold text-gray-900">${EARNINGS_SUMMARY.paidThisMonth.toLocaleString()}</Text>
|
|
||||||
<Text className="text-muted-foreground text-xs">{EARNINGS_SUMMARY.paidCount} Paid invoice</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<View className="mb-5 flex-row gap-3">
|
{/* Filters */}
|
||||||
<Button className="min-h-12 flex-1 rounded-xl bg-primary" onPress={() => router.push('/(tabs)/scan')}>
|
<View className="mb-6">
|
||||||
<Camera color="#ffffff" size={20} strokeWidth={2} />
|
<ScrollView
|
||||||
<Text className="ml-2 text-primary-foreground font-medium">Scan invoice</Text>
|
horizontal
|
||||||
</Button>
|
showsHorizontalScrollIndicator={false}
|
||||||
<Button
|
contentContainerStyle={{ gap: 8 }}
|
||||||
variant="outline"
|
>
|
||||||
className="min-h-12 flex-1 rounded-xl border-border"
|
{["All", "Draft", "Pending", "Paid", "Overdue", "Cancelled"].map(
|
||||||
onPress={() => router.push('/(tabs)/proforma')}
|
(filter) => (
|
||||||
>
|
<Pressable
|
||||||
<Send color={PRIMARY} size={20} strokeWidth={2} />
|
key={filter}
|
||||||
<Text className="ml-2 font-medium text-gray-700">Send proforma</Text>
|
onPress={() => setActiveFilter(filter)}
|
||||||
</Button>
|
className={`rounded-[4px] px-4 py-1.5 ${
|
||||||
</View>
|
activeFilter === filter
|
||||||
|
? "bg-primary"
|
||||||
|
: "bg-card border border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-xs font-bold ${
|
||||||
|
activeFilter === filter
|
||||||
|
? "text-white"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filter}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="-mx-1 mb-4">
|
{/* Transactions List */}
|
||||||
<View className="flex-row gap-2 px-1">
|
<View className="gap-2">
|
||||||
{['All', 'Draft', 'Waiting', 'Paid', 'Unpaid'].map((filter) => (
|
{loading ? (
|
||||||
<Pressable
|
<ActivityIndicator color="#ea580c" className="py-20" />
|
||||||
key={filter}
|
) : invoices.length > 0 ? (
|
||||||
className={`rounded-full px-4 py-2.5 ${filter === 'Waiting' ? 'bg-primary' : 'bg-white'} border border-border`}
|
invoices.map((inv) => (
|
||||||
>
|
<Pressable
|
||||||
<Text
|
key={inv.id}
|
||||||
className={
|
onPress={() => nav.go("invoices/[id]", { id: inv.id })}
|
||||||
filter === 'Waiting' ? 'text-primary-foreground text-sm font-medium' : 'text-muted-foreground text-sm'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{filter}
|
<ShadowWrapper level="xs">
|
||||||
</Text>
|
<Card className="overflow-hidden rounded-[6px] bg-card">
|
||||||
</Pressable>
|
<CardContent className="flex-row items-center py-3 px-2">
|
||||||
))}
|
<View className="bg-secondary/40 rounded-[6px] p-2 mr-2 border border-border/10">
|
||||||
|
<FileText
|
||||||
|
className="text-muted-foreground"
|
||||||
|
size={22}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 mt-[-20px]">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold"
|
||||||
|
>
|
||||||
|
{inv.customerName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="mt-1 text-[11px] font-medium opacity-70"
|
||||||
|
>
|
||||||
|
{new Date(inv.issueDate).toLocaleDateString()} ·
|
||||||
|
Proforma
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-end mt-[-20px]">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold"
|
||||||
|
>
|
||||||
|
${Number(inv.amount).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
className={`mt-1 rounded-[5px] px-3 py-1 border border-border ${
|
||||||
|
inv.status === "PAID"
|
||||||
|
? "bg-emerald-500/30 text-emerald-600"
|
||||||
|
: inv.status === "PENDING"
|
||||||
|
? "bg-amber-500/30 text-amber-600"
|
||||||
|
: inv.status === "DRAFT"
|
||||||
|
? "bg-secondary text-muted-foreground"
|
||||||
|
: "bg-red-500/30 text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text className="text-[9px] font-semibold uppercase tracking-widest">
|
||||||
|
{inv.status}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ShadowWrapper>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="py-10">
|
||||||
|
<EmptyState
|
||||||
|
title="No transactions yet"
|
||||||
|
description="Your recent activity will show up here once you create and send invoices."
|
||||||
|
hint="Create a proforma invoice to get started."
|
||||||
|
actionLabel="Create Proforma"
|
||||||
|
onActionPress={() => nav.go("proforma/create")}
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
<View className="mb-2 flex-row items-center gap-2">
|
);
|
||||||
<View className="h-px flex-1 bg-border" />
|
}
|
||||||
<Text className="text-muted-foreground text-xs font-medium">Today</Text>
|
|
||||||
<View className="h-px flex-1 bg-border" />
|
function QuickAction({
|
||||||
</View>
|
icon,
|
||||||
{MOCK_INVOICES.filter((i) => i.status === 'Waiting').map((inv) => (
|
label,
|
||||||
<Pressable key={inv.id} onPress={() => router.push(`/invoices/${inv.id}`)}>
|
onPress,
|
||||||
<Card className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
}: {
|
||||||
<CardContent className="flex-row items-center justify-between py-4 pl-4 pr-3">
|
icon: React.ReactNode;
|
||||||
<View className="flex-1">
|
label: string;
|
||||||
<Text className="font-semibold text-gray-900">{inv.recipient}</Text>
|
onPress?: () => void;
|
||||||
<Text className="text-muted-foreground mt-0.5 text-sm">Invoice #{inv.invoiceNumber} · Due {inv.dueDate}</Text>
|
}) {
|
||||||
</View>
|
return (
|
||||||
<View className="items-end gap-1">
|
<View className="pt-2 items-center w-[75px]">
|
||||||
<Text className="font-semibold text-gray-900">${inv.amount.toLocaleString()}</Text>
|
|
||||||
<View className={`rounded-full px-2.5 py-1 ${statusColor[inv.status]}`}>
|
<Pressable
|
||||||
<Text className="text-xs font-medium">{inv.status}</Text>
|
onPress={onPress}
|
||||||
</View>
|
className="h-12 w-12 rounded-full bg-card border border-border/20 items-center justify-center flex-shrink-0"
|
||||||
</View>
|
>
|
||||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
{icon}
|
||||||
</CardContent>
|
</Pressable>
|
||||||
</Card>
|
<Text
|
||||||
</Pressable>
|
variant="p"
|
||||||
))}
|
className="flex-1 text-foreground text-[12px] font-bold tracking-tight text-center leading-4"
|
||||||
|
>
|
||||||
<View className="mb-2 mt-6 flex-row items-center gap-2">
|
{label}
|
||||||
<View className="h-px flex-1 bg-border" />
|
</Text>
|
||||||
<Text className="text-muted-foreground text-xs font-medium">Yesterday</Text>
|
</View>
|
||||||
<View className="h-px flex-1 bg-border" />
|
|
||||||
</View>
|
|
||||||
{MOCK_INVOICES.filter((i) => i.status === 'Paid').map((inv) => (
|
|
||||||
<Pressable key={inv.id} onPress={() => router.push(`/invoices/${inv.id}`)}>
|
|
||||||
<Card className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
|
||||||
<CardContent className="flex-row items-center justify-between py-4 pl-4 pr-3">
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="font-semibold text-gray-900">{inv.recipient}</Text>
|
|
||||||
<Text className="text-muted-foreground mt-0.5 text-sm">Invoice #{inv.invoiceNumber} · {inv.dueDate}</Text>
|
|
||||||
</View>
|
|
||||||
<View className="items-end gap-1">
|
|
||||||
<Text className="font-semibold text-gray-900">${inv.amount.toLocaleString()}</Text>
|
|
||||||
<View className={`rounded-full px-2.5 py-1 ${statusColor[inv.status]}`}>
|
|
||||||
<Text className="text-xs font-medium">{inv.status}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
313
app/(tabs)/news.tsx
Normal file
313
app/(tabs)/news.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Dimensions,
|
||||||
|
RefreshControl,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Newspaper, ChevronRight, Clock } from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
|
||||||
|
import { api, newsApi } from "@/lib/api";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
|
||||||
|
const { width } = Dimensions.get("window");
|
||||||
|
const LATEST_CARD_WIDTH = width * 0.8;
|
||||||
|
|
||||||
|
interface NewsItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
category: "ANNOUNCEMENT" | "UPDATE" | "MAINTENANCE" | "NEWS";
|
||||||
|
priority: "LOW" | "MEDIUM" | "HIGH";
|
||||||
|
publishedAt: string;
|
||||||
|
viewCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const permissions = useAuthStore((s: { permissions: string[] }) => s.permissions);
|
||||||
|
|
||||||
|
// Safe accessor to handle initialization race conditions
|
||||||
|
const getNewsApi = () => {
|
||||||
|
if (newsApi) return newsApi;
|
||||||
|
return api.news;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Latest News State
|
||||||
|
const [latestNews, setLatestNews] = useState<NewsItem[]>([]);
|
||||||
|
const [loadingLatest, setLoadingLatest] = useState(true);
|
||||||
|
|
||||||
|
// All News State
|
||||||
|
const [allNews, setAllNews] = useState<NewsItem[]>([]);
|
||||||
|
const [loadingAll, setLoadingAll] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Check permissions (none for viewing news)
|
||||||
|
|
||||||
|
const fetchLatest = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingLatest(true);
|
||||||
|
const service = getNewsApi();
|
||||||
|
if (!service) throw new Error("News service unavailable");
|
||||||
|
const data = await service.getLatest({ query: { limit: 5 } });
|
||||||
|
setLatestNews(data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[News] Latest fetch error:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingLatest(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAll = async (pageNum: number, isRefresh = false) => {
|
||||||
|
try {
|
||||||
|
if (!isRefresh) {
|
||||||
|
pageNum === 1 ? setLoadingAll(true) : setLoadingMore(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getNewsApi();
|
||||||
|
if (!service) throw new Error("News service unavailable");
|
||||||
|
|
||||||
|
const response = await service.getAll({
|
||||||
|
query: { page: pageNum, limit: 10, isPublished: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const newData = response.data || [];
|
||||||
|
if (isRefresh) {
|
||||||
|
setAllNews(newData);
|
||||||
|
} else {
|
||||||
|
setAllNews((prev) => (pageNum === 1 ? newData : [...prev, ...newData]));
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMore(response?.meta?.hasNextPage ?? false);
|
||||||
|
setPage(pageNum);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[News] All fetch error:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingAll(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchLatest();
|
||||||
|
fetchAll(1, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLatest();
|
||||||
|
fetchAll(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (hasMore && !loadingMore && !loadingAll) {
|
||||||
|
fetchAll(page + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case "ANNOUNCEMENT":
|
||||||
|
return "bg-amber-500";
|
||||||
|
case "UPDATE":
|
||||||
|
return "bg-blue-500";
|
||||||
|
case "MAINTENANCE":
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-emerald-500";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const LatestItem = ({ item }: { item: NewsItem }) => (
|
||||||
|
<Pressable className="mr-4" key={item.id}>
|
||||||
|
<ShadowWrapper level="md">
|
||||||
|
<Card
|
||||||
|
className="overflow-hidden rounded-[20px] bg-card border-border/50"
|
||||||
|
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
|
||||||
|
>
|
||||||
|
<View className="p-5 flex-1 justify-between">
|
||||||
|
<View>
|
||||||
|
<View className="flex-row items-center gap-2 mb-2">
|
||||||
|
<View
|
||||||
|
className={`px-2 py-0.5 rounded-full ${getCategoryColor(item.category)}`}
|
||||||
|
>
|
||||||
|
<Text className="text-[8px] font-black text-white uppercase tracking-tighter">
|
||||||
|
{item.category}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="muted" className="text-[10px] font-bold">
|
||||||
|
{new Date(item.publishedAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="text-foreground font-black text-lg leading-tight"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text variant="muted" className="text-xs font-medium opacity-60">
|
||||||
|
Tap to read more
|
||||||
|
</Text>
|
||||||
|
<View className="bg-primary/10 p-1.5 rounded-full">
|
||||||
|
<ChevronRight color="#ea580c" size={14} strokeWidth={3} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</ShadowWrapper>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NewsItem = ({ item }: { item: NewsItem }) => (
|
||||||
|
<Pressable className="mb-4" key={item.id}>
|
||||||
|
<ShadowWrapper level="xs">
|
||||||
|
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
|
||||||
|
<View className="p-4">
|
||||||
|
<View className="flex-row items-center gap-2 mb-1.5">
|
||||||
|
<View
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${getCategoryColor(item.category)}`}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] font-black uppercase tracking-widest opacity-60"
|
||||||
|
>
|
||||||
|
{item.category}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="text-foreground font-bold text-sm mb-1"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[11px] leading-relaxed"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.content}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-3 mt-3">
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Clock color="#94a3b8" size={10} strokeWidth={2.5} />
|
||||||
|
<Text variant="muted" className="text-[10px] font-medium">
|
||||||
|
{new Date(item.publishedAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</ShadowWrapper>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 120 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor="#ea580c"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StandardHeader />
|
||||||
|
{/* Latest News Section */}
|
||||||
|
<View className="px-5 mt-4">
|
||||||
|
<Text variant="h4" className="text-foreground tracking-tight mb-4">
|
||||||
|
Latest News
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loadingLatest ? (
|
||||||
|
<ActivityIndicator color="#ea580c" className="py-10" />
|
||||||
|
) : latestNews.length > 0 ? (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToInterval={LATEST_CARD_WIDTH + 16}
|
||||||
|
className="overflow-visible"
|
||||||
|
>
|
||||||
|
{latestNews.map((item) => (
|
||||||
|
<LatestItem key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View className="py-4">
|
||||||
|
<EmptyState
|
||||||
|
title="No latest updates"
|
||||||
|
description="Announcements and important updates will appear here once published."
|
||||||
|
hint="Pull to refresh to check again."
|
||||||
|
previewLines={2}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* All News Section */}
|
||||||
|
<View className="px-5 mt-8">
|
||||||
|
<Text variant="h4" className="text-foreground tracking-tight mb-4">
|
||||||
|
All News
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loadingAll ? (
|
||||||
|
<ActivityIndicator color="#ea580c" className="py-20" />
|
||||||
|
) : allNews.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{allNews.map((item) => (
|
||||||
|
<NewsItem key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<Pressable
|
||||||
|
onPress={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="py-4 items-center"
|
||||||
|
>
|
||||||
|
{loadingMore ? (
|
||||||
|
<ActivityIndicator color="#ea580c" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-primary font-bold text-xs uppercase tracking-widest">
|
||||||
|
Load More
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View className="py-6">
|
||||||
|
<EmptyState
|
||||||
|
title="No news yet"
|
||||||
|
description="Company news, maintenance updates, and announcements will show up here."
|
||||||
|
hint="Pull to refresh to fetch the latest posts."
|
||||||
|
previewLines={4}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,76 +1,302 @@
|
||||||
import { View, ScrollView } from 'react-native';
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { router } from 'expo-router';
|
import {
|
||||||
import { Text } from '@/components/ui/text';
|
View,
|
||||||
import { Button } from '@/components/ui/button';
|
ScrollView,
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
Pressable,
|
||||||
import { MOCK_PAYMENTS } from '@/lib/mock-data';
|
ActivityIndicator,
|
||||||
import { ScanLine, Link2, CheckCircle2, Wallet, ChevronRight } from '@/lib/icons';
|
FlatList,
|
||||||
|
ListRenderItem,
|
||||||
|
} 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 { Card } from "@/components/ui/card";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
ScanLine,
|
||||||
|
CheckCircle2,
|
||||||
|
Wallet,
|
||||||
|
ChevronRight,
|
||||||
|
AlertTriangle,
|
||||||
|
Plus,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
const PRIMARY = "#ea580c";
|
||||||
|
|
||||||
|
interface Payment {
|
||||||
|
id: string;
|
||||||
|
transactionId: string;
|
||||||
|
amount:
|
||||||
|
| {
|
||||||
|
value: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
| number;
|
||||||
|
currency: string;
|
||||||
|
paymentDate: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
notes: string;
|
||||||
|
isFlagged: boolean;
|
||||||
|
flagReason: string;
|
||||||
|
flagNotes: string;
|
||||||
|
receiptPath: string;
|
||||||
|
userId: string;
|
||||||
|
invoiceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PaymentsScreen() {
|
export default function PaymentsScreen() {
|
||||||
const matched = MOCK_PAYMENTS.filter((p) => p.matched);
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const pending = MOCK_PAYMENTS.filter((p) => !p.matched);
|
const permissions = useAuthStore((s) => s.permissions);
|
||||||
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
return (
|
// Check permissions
|
||||||
<ScrollView
|
const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]);
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
<Text className="text-muted-foreground mb-5 text-base">
|
|
||||||
Match payment SMS (e.g. bank or Telebirr) to invoices for quick reconciliation.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Button className="mb-5 min-h-12 rounded-xl bg-primary">
|
const fetchPayments = useCallback(
|
||||||
<ScanLine color="#ffffff" size={20} strokeWidth={2} />
|
async (pageNum: number, isRefresh = false) => {
|
||||||
<Text className="ml-2 text-primary-foreground font-medium">Scan SMS now</Text>
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
</Button>
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
<View className="mb-3 flex-row items-center gap-2">
|
try {
|
||||||
<Link2 color="#71717a" size={18} strokeWidth={2} />
|
if (!isRefresh) {
|
||||||
<Text className="text-muted-foreground text-sm font-medium">Pending match</Text>
|
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
|
||||||
</View>
|
}
|
||||||
{pending.map((pay) => (
|
|
||||||
<Card key={pay.id} className="mb-2.5 overflow-hidden rounded-xl border-2 border-amber-500/30 bg-white">
|
const response = await api.payments.getAll({
|
||||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
query: { page: pageNum, limit: 10 },
|
||||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
});
|
||||||
<Wallet color={PRIMARY} size={22} strokeWidth={2} />
|
|
||||||
|
const newPayments = response.data;
|
||||||
|
if (isRefresh) {
|
||||||
|
setPayments(newPayments);
|
||||||
|
} else {
|
||||||
|
setPayments((prev) =>
|
||||||
|
pageNum === 1 ? newPayments : [...prev, ...newPayments],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMore(response.meta.hasNextPage);
|
||||||
|
setPage(pageNum);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[Payments] Fetch error:", err);
|
||||||
|
toast.error("Error", "Failed to fetch payments.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPayments(1);
|
||||||
|
}, [fetchPayments]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchPayments(1, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (hasMore && !loadingMore && !loading) {
|
||||||
|
fetchPayments(page + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const categorized = {
|
||||||
|
flagged: payments.filter((p) => p.isFlagged),
|
||||||
|
pending: payments.filter((p) => !p.invoiceId && !p.isFlagged),
|
||||||
|
reconciled: payments.filter((p) => p.invoiceId && !p.isFlagged),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPaymentItem = (
|
||||||
|
pay: Payment,
|
||||||
|
type: "reconciled" | "pending" | "flagged",
|
||||||
|
) => {
|
||||||
|
const isReconciled = type === "reconciled";
|
||||||
|
const isFlagged = type === "flagged";
|
||||||
|
|
||||||
|
// Support both object and direct number amount from API
|
||||||
|
const amountValue =
|
||||||
|
typeof pay.amount === "object" ? pay.amount.value : pay.amount;
|
||||||
|
const dateStr = new Date(pay.paymentDate).toLocaleDateString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={pay.id}
|
||||||
|
onPress={() => nav.go("payments/[id]", { id: pay.id })}
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={`rounded-[10px] bg-card overflow-hidden ${isReconciled ? "opacity-80" : ""}`}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center p-3">
|
||||||
|
<View
|
||||||
|
className={`mr-2 rounded-[6px] p-2 border ${
|
||||||
|
isFlagged
|
||||||
|
? "bg-red-500/10 border-red-500/5"
|
||||||
|
: isReconciled
|
||||||
|
? "bg-emerald-500/10 border-emerald-500/5"
|
||||||
|
: "bg-primary/10 border-primary/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isFlagged ? (
|
||||||
|
<AlertTriangle color="#ef4444" size={18} strokeWidth={2.5} />
|
||||||
|
) : isReconciled ? (
|
||||||
|
<CheckCircle2 color="#10b981" size={18} strokeWidth={2.5} />
|
||||||
|
) : (
|
||||||
|
<Wallet color={PRIMARY} size={18} strokeWidth={2.5} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="font-semibold text-gray-900">${pay.amount.toLocaleString()}</Text>
|
<Text variant="p" className="text-foreground font-bold">
|
||||||
<Text className="text-muted-foreground text-sm">{pay.source} · {pay.date}</Text>
|
{pay.currency || "$"}
|
||||||
</View>
|
{amountValue?.toLocaleString()}
|
||||||
<Button variant="outline" size="sm" className="rounded-lg" onPress={() => router.push(`/payments/${pay.id}`)}>
|
</Text>
|
||||||
<Text className="font-medium">Match</Text>
|
<Text variant="muted" className="text-xs">
|
||||||
</Button>
|
{pay.paymentMethod} · {dateStr}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<View className="mb-3 mt-6 flex-row items-center gap-2">
|
|
||||||
<CheckCircle2 color="#059669" size={18} strokeWidth={2} />
|
|
||||||
<Text className="text-muted-foreground text-sm font-medium">Reconciled</Text>
|
|
||||||
</View>
|
|
||||||
{matched.map((pay) => (
|
|
||||||
<Card key={pay.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
|
||||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
|
||||||
<View className="mr-3 rounded-xl bg-emerald-500/15 p-2">
|
|
||||||
<CheckCircle2 color="#059669" size={22} strokeWidth={2} />
|
|
||||||
</View>
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="font-semibold text-gray-900">${pay.amount.toLocaleString()}</Text>
|
|
||||||
<Text className="text-muted-foreground text-sm">
|
|
||||||
{pay.source} · {pay.date} {pay.reference && `· ${pay.reference}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="rounded-full bg-emerald-500/20 px-2.5 py-1">
|
{isFlagged ? (
|
||||||
<Text className="text-xs font-medium text-emerald-700">Matched</Text>
|
<View className="bg-red-500/10 px-3 py-1 rounded-[6px]">
|
||||||
</View>
|
<Text className="text-red-700 text-[10px] font-semibold">
|
||||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
Flagged
|
||||||
</CardContent>
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : !isReconciled ? (
|
||||||
|
<View className="bg-amber-500/10 px-4 py-2 rounded-[6px]">
|
||||||
|
<Text className="text-amber-700 text-[10px] font-semibold">
|
||||||
|
Match
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={18} strokeWidth={2} color="#000" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
</Pressable>
|
||||||
</ScrollView>
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && page === 1) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader />
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color={PRIMARY} />
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ paddingBottom: 150 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScroll={({ nativeEvent }) => {
|
||||||
|
const isCloseToBottom =
|
||||||
|
nativeEvent.layoutMeasurement.height +
|
||||||
|
nativeEvent.contentOffset.y >=
|
||||||
|
nativeEvent.contentSize.height - 20;
|
||||||
|
if (isCloseToBottom) loadMore();
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={400}
|
||||||
|
>
|
||||||
|
<StandardHeader />
|
||||||
|
<View className="px-[16px] pt-6">
|
||||||
|
<Button
|
||||||
|
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
|
||||||
|
onPress={() => nav.go("payment-requests/create")}
|
||||||
|
>
|
||||||
|
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
||||||
|
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
|
||||||
|
Create Payment Request
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Flagged Section */}
|
||||||
|
{categorized.flagged.length > 0 && (
|
||||||
|
<>
|
||||||
|
<View className="mb-4 flex-row items-center gap-3">
|
||||||
|
<Text variant="h4" className="text-red-600">
|
||||||
|
Flagged Payments
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="gap-2 mb-6">
|
||||||
|
{categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Section */}
|
||||||
|
<View className="mb-4 flex-row items-center gap-3">
|
||||||
|
<Text variant="h4" className="text-foreground">
|
||||||
|
Pending Match
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="gap-2 mb-6">
|
||||||
|
{categorized.pending.length > 0 ? (
|
||||||
|
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
|
||||||
|
) : (
|
||||||
|
<View className="py-1">
|
||||||
|
<EmptyState
|
||||||
|
title="No pending payments"
|
||||||
|
description="Payments that haven't been matched to invoices yet will appear here."
|
||||||
|
hint="Upload receipts or scan SMS to add payments."
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Reconciled Section */}
|
||||||
|
<View className="mb-4 flex-row items-center gap-3">
|
||||||
|
<Text variant="h4" className="text-foreground">
|
||||||
|
Reconciled
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="gap-2">
|
||||||
|
{categorized.reconciled.length > 0 ? (
|
||||||
|
categorized.reconciled.map((p) =>
|
||||||
|
renderPaymentItem(p, "reconciled"),
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View className="py-4">
|
||||||
|
<EmptyState
|
||||||
|
title="No reconciled payments"
|
||||||
|
description="Payments matched to invoices will show up here once reconciled."
|
||||||
|
hint="Match pending payments to invoices for reconciliation."
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loadingMore && (
|
||||||
|
<View className="py-4">
|
||||||
|
<ActivityIndicator color={PRIMARY} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import { Text } from '@/components/ui/text';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { MOCK_USER } from '@/lib/mock-data';
|
|
||||||
import { User, Mail, Globe, Bell, ChevronRight, Info, LogOut, LogIn, FileText, FolderOpen, Settings } from '@/lib/icons';
|
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
<View className="mb-6 items-center">
|
|
||||||
<View className="mb-3 h-20 w-20 items-center justify-center rounded-full bg-primary">
|
|
||||||
<Text className="text-3xl font-bold text-primary-foreground">{MOCK_USER.name[0]}</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-xl font-semibold text-gray-900">{MOCK_USER.name}</Text>
|
|
||||||
<Text className="text-muted-foreground mt-1 text-sm">{MOCK_USER.email}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-base">Account</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="gap-0">
|
|
||||||
<View className="flex-row items-center justify-between border-b border-border py-3">
|
|
||||||
<View className="flex-row items-center gap-3">
|
|
||||||
<Mail color="#71717a" size={20} strokeWidth={2} />
|
|
||||||
<Text className="text-muted-foreground">Email</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-gray-900">{MOCK_USER.email}</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row items-center justify-between border-b border-border py-3">
|
|
||||||
<View className="flex-row items-center gap-3">
|
|
||||||
<Globe color="#71717a" size={20} strokeWidth={2} />
|
|
||||||
<Text className="text-muted-foreground">Language</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-gray-900">English</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.push('/notifications')}
|
|
||||||
className="flex-row items-center justify-between border-b border-border py-3"
|
|
||||||
>
|
|
||||||
<View className="flex-row items-center gap-3">
|
|
||||||
<Bell color="#71717a" size={20} strokeWidth={2} />
|
|
||||||
<Text className="text-muted-foreground">Notifications</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row items-center gap-1">
|
|
||||||
<Text className="text-primary font-medium">Manage</Text>
|
|
||||||
<ChevronRight color={PRIMARY} size={18} strokeWidth={2} />
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.push('/reports')}
|
|
||||||
className="flex-row items-center justify-between border-b border-border py-3"
|
|
||||||
>
|
|
||||||
<View className="flex-row items-center gap-3">
|
|
||||||
<FileText color="#71717a" size={20} strokeWidth={2} />
|
|
||||||
<Text className="text-muted-foreground">Reports</Text>
|
|
||||||
</View>
|
|
||||||
<ChevronRight color="#a1a1aa" size={18} strokeWidth={2} />
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.push('/documents')}
|
|
||||||
className="flex-row items-center justify-between border-b border-border py-3"
|
|
||||||
>
|
|
||||||
<View className="flex-row items-center gap-3">
|
|
||||||
<FolderOpen color="#71717a" size={20} strokeWidth={2} />
|
|
||||||
<Text className="text-muted-foreground">Documents</Text>
|
|
||||||
</View>
|
|
||||||
<ChevronRight color="#a1a1aa" size={18} strokeWidth={2} />
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.push('/settings')}
|
|
||||||
className="flex-row items-center justify-between py-3"
|
|
||||||
>
|
|
||||||
<View className="flex-row items-center gap-3">
|
|
||||||
<Settings color="#71717a" size={20} strokeWidth={2} />
|
|
||||||
<Text className="text-muted-foreground">Settings</Text>
|
|
||||||
</View>
|
|
||||||
<ChevronRight color="#a1a1aa" size={18} strokeWidth={2} />
|
|
||||||
</Pressable>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<Info color="#71717a" size={18} strokeWidth={2} />
|
|
||||||
<CardTitle className="text-base">About</CardTitle>
|
|
||||||
</View>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Text className="text-muted-foreground text-sm leading-5">
|
|
||||||
Yaltopia Tickets App — Scan. Send. Reconcile. Companion to the Yaltopia Tickets web app.
|
|
||||||
</Text>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="mt-2 min-h-12 rounded-xl border-border"
|
|
||||||
onPress={() => router.push('/login')}
|
|
||||||
>
|
|
||||||
<LogIn color={PRIMARY} size={20} strokeWidth={2} />
|
|
||||||
<Text className="ml-2 font-medium text-gray-700">Sign in (different account)</Text>
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" className="mt-3 min-h-12 rounded-xl">
|
|
||||||
<LogOut color="#ffffff" size={20} strokeWidth={2} />
|
|
||||||
<Text className="ml-2 font-medium">Log out</Text>
|
|
||||||
</Button>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +1,251 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Text } from '@/components/ui/text';
|
import {
|
||||||
import { Button } from '@/components/ui/button';
|
View,
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
Pressable,
|
||||||
import { MOCK_PROFORMA } from '@/lib/mock-data';
|
ActivityIndicator,
|
||||||
import { router } from 'expo-router';
|
FlatList,
|
||||||
import { Plus, Send, FileText, ChevronRight, Calendar } from '@/lib/icons';
|
ListRenderItem,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Plus, Send, FileText, Clock, ChevronRight } from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
interface ProformaItem {
|
||||||
|
id: string;
|
||||||
|
proformaNumber: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
customerPhone: string;
|
||||||
|
amount: any;
|
||||||
|
currency: string;
|
||||||
|
issueDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
description: string;
|
||||||
|
notes: string;
|
||||||
|
taxAmount: any;
|
||||||
|
discountAmount: any;
|
||||||
|
pdfPath: string;
|
||||||
|
userId: string;
|
||||||
|
items: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyData: ProformaItem = {
|
||||||
|
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 ProformaScreen() {
|
export default function ProformaScreen() {
|
||||||
return (
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
<ScrollView
|
const permissions = useAuthStore((s) => s.permissions);
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
const [proformas, setProformas] = useState<ProformaItem[]>([]);
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
const [loading, setLoading] = useState(true);
|
||||||
showsVerticalScrollIndicator={false}
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
>
|
const [page, setPage] = useState(1);
|
||||||
<Text className="text-muted-foreground mb-5 text-base">
|
const [hasMore, setHasMore] = useState(true);
|
||||||
Create or select proforma requests and share with contacts via email or SMS.
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Button className="mb-5 min-h-12 rounded-xl bg-primary" onPress={() => {}}>
|
// Check permissions
|
||||||
<Plus color="#ffffff" size={20} strokeWidth={2} />
|
const canCreateProformas = hasPermission(permissions, PERMISSION_MAP["proforma:create"]);
|
||||||
<Text className="ml-2 text-primary-foreground font-medium">Create new proforma</Text>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<View className="mb-3 flex-row items-center gap-2">
|
const fetchProformas = useCallback(
|
||||||
<FileText color="#71717a" size={18} strokeWidth={2} />
|
async (pageNum: number, isRefresh = false) => {
|
||||||
<Text className="text-muted-foreground text-sm font-medium">Your proforma requests</Text>
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
</View>
|
if (!isAuthenticated) return;
|
||||||
{MOCK_PROFORMA.map((pf) => (
|
|
||||||
<Pressable key={pf.id} onPress={() => router.push(`/proforma/${pf.id}`)}>
|
try {
|
||||||
<Card className="mb-3 overflow-hidden rounded-xl border border-border bg-white">
|
if (!isRefresh) {
|
||||||
<CardHeader className="pb-2">
|
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
|
||||||
<CardTitle className="text-base">{pf.title}</CardTitle>
|
}
|
||||||
<CardDescription className="mt-0.5">{pf.description}</CardDescription>
|
|
||||||
<View className="mt-2 flex-row items-center gap-1.5">
|
const response = await api.proforma.getAll({
|
||||||
<Calendar color="#71717a" size={14} strokeWidth={2} />
|
query: { page: pageNum, limit: 10 },
|
||||||
<Text className="text-muted-foreground text-xs">Deadline {pf.deadline} · {pf.itemCount} items</Text>
|
});
|
||||||
|
|
||||||
|
let newProformas = response.data;
|
||||||
|
|
||||||
|
|
||||||
|
const newData = newProformas;
|
||||||
|
if (isRefresh) {
|
||||||
|
setProformas(newData);
|
||||||
|
} else {
|
||||||
|
setProformas((prev) =>
|
||||||
|
pageNum === 1 ? newData : [...prev, ...newData],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setHasMore(response.meta.hasNextPage);
|
||||||
|
setPage(pageNum);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[Proforma] Fetch error:", err);
|
||||||
|
setHasMore(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProformas(1);
|
||||||
|
}, [fetchProformas]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchProformas(1, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (hasMore && !loadingMore && !loading) {
|
||||||
|
fetchProformas(page + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProformaItem: ListRenderItem<ProformaItem> = ({ item }) => {
|
||||||
|
const amountVal =
|
||||||
|
typeof item.amount === "object" ? item.amount.value : item.amount;
|
||||||
|
const issuedStr = item.issueDate
|
||||||
|
? new Date(item.issueDate).toLocaleDateString()
|
||||||
|
: "";
|
||||||
|
const dueStr = item.dueDate ? new Date(item.dueDate).toLocaleDateString() : "";
|
||||||
|
const itemsCount = Array.isArray(item.items) ? item.items.length : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-[16px]">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.go("proforma/[id]", { id: item.id })}
|
||||||
|
className="mb-3"
|
||||||
|
>
|
||||||
|
<Card className="rounded-[12px] bg-card overflow-hidden border border-border/40">
|
||||||
|
<View className="p-4">
|
||||||
|
<View className="flex-row items-start">
|
||||||
|
<View className="bg-primary/10 p-2.5 rounded-[12px] border border-primary/10 mr-3">
|
||||||
|
<FileText color="#ea580c" size={18} strokeWidth={2.5} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<View className="flex-1 pr-2">
|
||||||
|
<Text className="text-foreground font-semibold" numberOfLines={1}>
|
||||||
|
{item.proformaNumber || "Proforma"}
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5" numberOfLines={1}>
|
||||||
|
{item.customerName || "Customer"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="items-end">
|
||||||
|
<Text className="text-foreground font-bold text-base">
|
||||||
|
{item.currency || "$"}
|
||||||
|
{amountVal?.toLocaleString?.() ?? amountVal ?? "0"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-2 flex-row items-center justify-between">
|
||||||
|
<Text variant="muted" className="text-[10px] font-medium">
|
||||||
|
Issued: {issuedStr} | Due: {dueStr} | {itemsCount} item{itemsCount !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</CardHeader>
|
</View>
|
||||||
<CardFooter className="flex-row items-center justify-between border-t border-border pt-3">
|
|
||||||
<Text className="text-muted-foreground text-sm">Sent to {pf.sentCount} contacts</Text>
|
|
||||||
<View className="flex-row items-center gap-1.5">
|
|
||||||
<Send color={PRIMARY} size={16} strokeWidth={2} />
|
|
||||||
<Text className="text-primary font-medium text-sm">Send to contacts</Text>
|
|
||||||
<ChevronRight color="#a1a1aa" size={18} strokeWidth={2} />
|
|
||||||
</View>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
</View>
|
||||||
</ScrollView>
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<FlatList
|
||||||
|
data={proformas}
|
||||||
|
renderItem={renderProformaItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={{ paddingBottom: 150 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
<StandardHeader />
|
||||||
|
<View className="px-[16px] pt-6">
|
||||||
|
{/* {canCreateProformas && ( */}
|
||||||
|
<Button
|
||||||
|
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
|
||||||
|
onPress={() => nav.go("proforma/create")}
|
||||||
|
>
|
||||||
|
<Plus color="white" size={20} strokeWidth={3} />
|
||||||
|
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
|
||||||
|
Create New Proforma
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
{/* )} */}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
loadingMore ? (
|
||||||
|
<ActivityIndicator color="#ea580c" className="py-4" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
!loading ? (
|
||||||
|
<View className="px-[16px] py-6">
|
||||||
|
<EmptyState
|
||||||
|
title="No proformas yet"
|
||||||
|
description="Create your first proforma to get started with invoicing."
|
||||||
|
hint="Tap the button above to create a new proforma."
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="py-20">
|
||||||
|
<ActivityIndicator size="large" color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,208 @@
|
||||||
import { View, ScrollView } from 'react-native';
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Text } from '@/components/ui/text';
|
import {
|
||||||
import { Button } from '@/components/ui/button';
|
View,
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
Pressable,
|
||||||
import { Camera, FileText, ChevronRight } from '@/lib/icons';
|
Platform,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Zap, Camera as CameraIcon, ScanLine } from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { BASE_URL } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
const NAV_BG = "#ffffff";
|
||||||
|
|
||||||
export default function ScanScreen() {
|
export default function ScanScreen() {
|
||||||
return (
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
<ScrollView
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
const [torch, setTorch] = useState(false);
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
const [scanning, setScanning] = useState(false);
|
||||||
showsVerticalScrollIndicator={false}
|
const cameraRef = useRef<CameraView>(null);
|
||||||
>
|
const navigation = useNavigation();
|
||||||
<Text className="text-muted-foreground mb-5 text-base">
|
const token = useAuthStore((s) => s.token);
|
||||||
Capture paper or digital invoices with your camera. We'll extract vendor, amount, date, and line items.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Card className="mb-5 overflow-hidden rounded-2xl border-2 border-dashed border-border bg-white">
|
useEffect(() => {
|
||||||
<CardContent className="items-center justify-center py-14">
|
navigation.setOptions({ tabBarStyle: { display: "none" } });
|
||||||
<View className="mb-5 h-24 w-24 items-center justify-center rounded-full bg-primary/10">
|
return () => {
|
||||||
<Camera color={PRIMARY} size={40} strokeWidth={2} />
|
navigation.setOptions({
|
||||||
</View>
|
tabBarStyle: {
|
||||||
<Text className="mb-2 text-center text-lg font-semibold text-gray-900">Scan invoice</Text>
|
display: "flex",
|
||||||
<Text className="text-muted-foreground mb-6 text-center text-sm">
|
backgroundColor: NAV_BG,
|
||||||
Tap below to open camera and capture an invoice
|
borderTopWidth: 0,
|
||||||
|
elevation: 10,
|
||||||
|
height: 75,
|
||||||
|
paddingBottom: Platform.OS === "ios" ? 30 : 10,
|
||||||
|
paddingTop: 10,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 25,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
borderRadius: 32,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
const handleScan = async () => {
|
||||||
|
if (!cameraRef.current || scanning) return;
|
||||||
|
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
// 1. Capture the photo
|
||||||
|
const photo = await cameraRef.current.takePictureAsync({
|
||||||
|
quality: 0.85,
|
||||||
|
base64: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!photo?.uri) throw new Error("Failed to capture photo.");
|
||||||
|
|
||||||
|
toast.info("Scanning...", "Uploading invoice image for AI extraction.");
|
||||||
|
|
||||||
|
// 2. Build multipart form data with the image file
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", {
|
||||||
|
uri: photo.uri,
|
||||||
|
name: "invoice.jpg",
|
||||||
|
type: "image/jpeg",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// 3. POST to /api/v1/scan/invoice
|
||||||
|
const response = await fetch(`${BASE_URL}scan/invoice`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
// Do NOT set Content-Type here — fetch sets it automatically with the boundary for multipart
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.message || "Scan failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("[Scan] Extracted invoice data:", data);
|
||||||
|
|
||||||
|
toast.success("Scan Complete!", "Invoice data extracted successfully.");
|
||||||
|
|
||||||
|
// Navigate to create invoice screen
|
||||||
|
nav.go("proforma/create");
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[Scan] Error:", err);
|
||||||
|
toast.error(
|
||||||
|
"Scan Failed",
|
||||||
|
err.message || "Could not process the invoice.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return <View className="flex-1 bg-black" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background items-center justify-center">
|
||||||
|
<View className="bg-primary/10 p-6 rounded-[24px] mb-6">
|
||||||
|
<CameraIcon className="text-primary" size={48} strokeWidth={1.5} />
|
||||||
|
</View>
|
||||||
|
<Text variant="h2" className="text-center mb-2">
|
||||||
|
Camera Access
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-center mb-10 leading-6 px-10">
|
||||||
|
We need your permission to use the camera to scan invoices and
|
||||||
|
receipts automatically.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
className="w-3/4 h-14 rounded-[12px] bg-primary px-10"
|
||||||
|
onPress={requestPermission}
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold uppercase tracking-widest">
|
||||||
|
Enable Camera
|
||||||
</Text>
|
</Text>
|
||||||
<Button className="min-h-12 rounded-xl bg-primary px-8">
|
</Button>
|
||||||
<Camera color="#ffffff" size={20} strokeWidth={2} />
|
<Pressable onPress={() => nav.back()} className="mt-6">
|
||||||
<Text className="ml-2 text-primary-foreground font-medium">Open camera</Text>
|
<Text className="text-muted-foreground font-bold">Go Back</Text>
|
||||||
</Button>
|
</Pressable>
|
||||||
</CardContent>
|
</ScreenWrapper>
|
||||||
</Card>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<View className="mb-3 flex-row items-center gap-2">
|
return (
|
||||||
<FileText color="#71717a" size={18} strokeWidth={2} />
|
<View className="flex-1 bg-black">
|
||||||
<Text className="text-muted-foreground text-sm font-medium">Recent scans</Text>
|
<CameraView
|
||||||
</View>
|
ref={cameraRef}
|
||||||
<Card className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
style={{ flex: 1 }}
|
||||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
facing="back"
|
||||||
<View className="flex-1">
|
enableTorch={torch}
|
||||||
<Text className="font-medium text-gray-900">Acme Corp - Invoice #101</Text>
|
>
|
||||||
<Text className="text-muted-foreground mt-0.5 text-sm">Scanned Sep 12, 2022 · $1,240</Text>
|
<View className="flex-1 justify-between p-10 pt-16">
|
||||||
|
{/* Top bar */}
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setTorch(!torch)}
|
||||||
|
className={`h-12 w-12 rounded-full items-center justify-center border border-white/20 ${torch ? "bg-primary" : "bg-black/40"}`}
|
||||||
|
>
|
||||||
|
<Zap
|
||||||
|
color="white"
|
||||||
|
size={20}
|
||||||
|
fill={torch ? "white" : "transparent"}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
|
||||||
|
>
|
||||||
|
<X color="white" size={24} />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<View className="rounded-full bg-amber-500/20 px-2.5 py-1">
|
|
||||||
<Text className="text-xs font-medium text-amber-700">Pending</Text>
|
{/* Scan Frame */}
|
||||||
|
<View className="items-center mt-10">
|
||||||
|
<View className="w-[300px] h-[500px] border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
|
||||||
|
<View className="w-[280px] h-[480px] border border-white/10 rounded-2xl" />
|
||||||
|
</View>
|
||||||
|
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
|
||||||
|
Align Invoice Within Frame
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
|
||||||
</CardContent>
|
{/* Capture Button */}
|
||||||
</Card>
|
<View className="items-center pb-10 gap-4">
|
||||||
<Card className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
<Pressable
|
||||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
onPress={handleScan}
|
||||||
<View className="flex-1">
|
disabled={scanning}
|
||||||
<Text className="font-medium text-gray-900">Tech Supplies Ltd - Invoice #88</Text>
|
className="h-20 w-20 rounded-full bg-primary items-center justify-center border-4 border-white/30"
|
||||||
<Text className="text-muted-foreground mt-0.5 text-sm">Scanned Sep 11, 2022 · $890</Text>
|
>
|
||||||
|
{scanning ? (
|
||||||
|
<ActivityIndicator color="white" size="large" />
|
||||||
|
) : (
|
||||||
|
<ScanLine color="white" size={32} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-white/50 text-[10px] font-black uppercase tracking-widest">
|
||||||
|
{scanning ? "Extracting Data..." : "Tap to Scan"}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="rounded-full bg-emerald-500/20 px-2.5 py-1">
|
</View>
|
||||||
<Text className="text-xs font-medium text-emerald-700">Saved</Text>
|
</CameraView>
|
||||||
</View>
|
</View>
|
||||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
240
app/_layout.tsx
240
app/_layout.tsx
|
|
@ -1,38 +1,218 @@
|
||||||
import '../global.css';
|
import React, { useEffect, useState } from "react";
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from "expo-router";
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { PortalHost } from '@rn-primitives/portal';
|
import { PortalHost } from "@rn-primitives/portal";
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { Toast } from "@/components/Toast";
|
||||||
import { View } from 'react-native';
|
import "@/global.css";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { View, ActivityIndicator, InteractionManager } from "react-native";
|
||||||
|
import { useRestoreTheme, NAV_THEME } from "@/lib/theme";
|
||||||
|
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { NavigationContainer, NavigationIndependentTree, ThemeProvider } from "@react-navigation/native";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { authGuard, guestGuard } from "@/lib/auth-guards";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { useFonts } from 'expo-font';
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
import { useSegments } from "expo-router";
|
||||||
|
|
||||||
|
function BackupGuard() {
|
||||||
|
const segments = useSegments();
|
||||||
|
const isAuthed = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// Intentionally disabled: redirecting here can happen before the root layout
|
||||||
|
// navigator is ready and cause "Attempted to navigate before mounting".
|
||||||
|
// Sirou guards handle redirects.
|
||||||
|
}, [segments, isAuthed, isMounted]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SirouBridge() {
|
||||||
|
const sirou = useSirouRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
// Create EXACT name from segments: (tabs), index => (tabs)/index
|
||||||
|
// Use "root" if segments are empty (initial layout)
|
||||||
|
const routeName = segments.length > 0 ? segments.join("/") : "root";
|
||||||
|
|
||||||
|
console.log(`[SirouBridge] checking route: "${routeName}"`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await (sirou as any).checkGuards(routeName);
|
||||||
|
if (!result.allowed && result.redirect) {
|
||||||
|
console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`);
|
||||||
|
// Use Sirou navigation safely
|
||||||
|
InteractionManager.runAfterInteractions(() => {
|
||||||
|
sirou.go(result.redirect);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(
|
||||||
|
`[SirouBridge] guard crash for "${routeName}":`,
|
||||||
|
e.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, [segments, sirou, isMounted, isAuthenticated]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
useRestoreTheme();
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
const [fontsLoaded] = useFonts({
|
||||||
|
'DMSans-Regular': require('../assets/fonts/DMSans-Regular.ttf'),
|
||||||
|
'DMSans-Bold': require('../assets/fonts/DMSans-Bold.ttf'),
|
||||||
|
'DMSans-Medium': require('../assets/fonts/DMSans-Medium.ttf'),
|
||||||
|
'DMSans-SemiBold': require('../assets/fonts/DMSans-SemiBold.ttf'),
|
||||||
|
'DMSans-Light': require('../assets/fonts/DMSans-Light.ttf'),
|
||||||
|
'DMSans-ExtraLight': require('../assets/fonts/DMSans-ExtraLight.ttf'),
|
||||||
|
'DMSans-Thin': require('../assets/fonts/DMSans-Thin.ttf'),
|
||||||
|
'DMSans-Black': require('../assets/fonts/DMSans-Black.ttf'),
|
||||||
|
'DMSans-ExtraBold': require('../assets/fonts/DMSans-ExtraBold.ttf'),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
|
||||||
|
const initializeAuth = async () => {
|
||||||
|
if (useAuthStore.persist.hasHydrated()) {
|
||||||
|
setHasHydrated(true);
|
||||||
|
} else {
|
||||||
|
const unsub = useAuthStore.persist.onFinishHydration(() => {
|
||||||
|
setHasHydrated(true);
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isMounted || !hasHydrated || !fontsLoaded) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="large" color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<View className="flex-1 bg-background">
|
<NavigationIndependentTree>
|
||||||
<StatusBar style="light" />
|
<NavigationContainer>
|
||||||
<Stack
|
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
||||||
screenOptions={{
|
<ThemeProvider
|
||||||
headerStyle: { backgroundColor: '#2d2d2d' },
|
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
|
||||||
headerTintColor: '#ffffff',
|
>
|
||||||
headerTitleStyle: { fontWeight: '600' },
|
<View className="flex-1 bg-background">
|
||||||
}}
|
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
||||||
>
|
<Stack
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
screenOptions={{
|
||||||
<Stack.Screen name="proforma/[id]" options={{ title: 'Proforma request' }} />
|
headerShown: false,
|
||||||
<Stack.Screen name="payments/[id]" options={{ title: 'Payment' }} />
|
}}
|
||||||
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
|
>
|
||||||
<Stack.Screen name="notifications/settings" options={{ title: 'Notification settings' }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="login" options={{ title: 'Sign in', headerShown: false }} />
|
<Stack.Screen
|
||||||
<Stack.Screen name="register" options={{ title: 'Create account', headerShown: false }} />
|
name="sms-scan"
|
||||||
<Stack.Screen name="invoices/[id]" options={{ title: 'Invoice' }} />
|
options={{ headerShown: false }}
|
||||||
<Stack.Screen name="reports" options={{ title: 'Reports' }} />
|
/>
|
||||||
<Stack.Screen name="documents" options={{ title: 'Documents' }} />
|
<Stack.Screen
|
||||||
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
|
name="proforma/[id]"
|
||||||
</Stack>
|
options={{ title: "Proforma request" }}
|
||||||
<PortalHost />
|
/>
|
||||||
</View>
|
<Stack.Screen
|
||||||
|
name="payments/[id]"
|
||||||
|
options={{ title: "Payment" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="notifications/index"
|
||||||
|
options={{ title: "Notifications" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="notifications/settings"
|
||||||
|
options={{ title: "Notification settings" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="help" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="history" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="company" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="company-details"
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="login"
|
||||||
|
options={{ title: "Sign in", headerShown: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="register"
|
||||||
|
options={{ title: "Create account", headerShown: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="invoices/[id]"
|
||||||
|
options={{ title: "Invoice" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="reports/index"
|
||||||
|
options={{ title: "Reports" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="documents/index"
|
||||||
|
options={{ title: "Documents" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
||||||
|
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="edit-profile"
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<SirouBridge />
|
||||||
|
<BackupGuard />
|
||||||
|
<PortalHost />
|
||||||
|
<Toast />
|
||||||
|
</View>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SirouRouterProvider>
|
||||||
|
</NavigationContainer>
|
||||||
|
</NavigationIndependentTree>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
220
app/company-details.tsx
Normal file
220
app/company-details.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { View, ActivityIndicator, ScrollView, Image } from "react-native";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function CompanyDetailsScreen() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [company, setCompany] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await api.company.get();
|
||||||
|
setCompany(res?.data ?? res);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader title="Company details" showBack />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
{company?.logoPath && (
|
||||||
|
<View className="items-center mb-6">
|
||||||
|
<View className="h-20 w-20 rounded-full overflow-hidden bg-muted">
|
||||||
|
<Image
|
||||||
|
source={{ uri: company.logoPath }}
|
||||||
|
className="h-full w-full"
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<View className="mb-3">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Company Name
|
||||||
|
</Text>
|
||||||
|
<Text variant="h4" className="text-foreground mt-1">
|
||||||
|
{company?.name ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{company?.tin && (
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
TIN
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company.tin}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold mb-3">
|
||||||
|
Contact Information
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-3">
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Phone
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.phone ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Email
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.email ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{company?.website && (
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Website
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company.website}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold mb-3">
|
||||||
|
Address
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-3">
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Street Address
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.address ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
City
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.city ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
State
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.state ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Zip Code
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.zipCode ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Country
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.country ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* System Info */}
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold mb-3">
|
||||||
|
System Information
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-3">
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
User ID
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1 font-mono text-sm">
|
||||||
|
{company?.userId ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Created
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.createdAt ? new Date(company.createdAt).toLocaleString() : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Last Updated
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.updatedAt ? new Date(company.updatedAt).toLocaleString() : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
app/company.tsx
Normal file
169
app/company.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
useColorScheme,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import {
|
||||||
|
UserPlus,
|
||||||
|
Search,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
ChevronRight,
|
||||||
|
Briefcase,
|
||||||
|
Info,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
|
||||||
|
export default function CompanyScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [workers, setWorkers] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const fetchWorkers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.users.getAll();
|
||||||
|
setWorkers(response.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CompanyScreen] Error fetching workers:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWorkers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchWorkers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredWorkers = workers.filter((worker) => {
|
||||||
|
const name = `${worker.firstName} ${worker.lastName}`.toLowerCase();
|
||||||
|
const email = (worker.email || "").toLowerCase();
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return name.includes(query) || email.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Company" showBack rightAction="companyInfo" />
|
||||||
|
|
||||||
|
<View className="flex-1 px-5 pt-4">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<ShadowWrapper level="xs">
|
||||||
|
<View className="flex-row items-center bg-card rounded-xl px-4 border border-border h-12 mb-6">
|
||||||
|
<Search size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground"
|
||||||
|
placeholder="Search workers..."
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Worker List Header */}
|
||||||
|
<View className="flex-row justify-between items-center mb-4">
|
||||||
|
<Text variant="h4" className="text-foreground tracking-tight">
|
||||||
|
Workers ({filteredWorkers.length})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<ActivityIndicator color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor="#ea580c"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ paddingBottom: 100 }}
|
||||||
|
>
|
||||||
|
{filteredWorkers.length > 0 ? (
|
||||||
|
filteredWorkers.map((worker) => (
|
||||||
|
<ShadowWrapper key={worker.id} level="xs">
|
||||||
|
<Card className="mb-3 overflow-hidden rounded-[12px] bg-card border-0">
|
||||||
|
<CardContent className="flex-row items-center p-4">
|
||||||
|
<View className="h-12 w-12 rounded-full bg-secondary/50 items-center justify-center mr-4">
|
||||||
|
<Text className="text-primary font-bold text-lg">
|
||||||
|
{worker.firstName?.[0]}
|
||||||
|
{worker.lastName?.[0]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-bold text-base">
|
||||||
|
{worker.firstName} {worker.lastName}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center mt-1">
|
||||||
|
<Text className="text-muted-foreground text-xs bg-secondary px-2 py-0.5 rounded-md uppercase font-bold tracking-widest text-[10px]">
|
||||||
|
{worker.role || "WORKER"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ChevronRight
|
||||||
|
size={18}
|
||||||
|
color={isDark ? "#334155" : "#cbd5e1"}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ShadowWrapper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="py-20 items-center">
|
||||||
|
<Briefcase
|
||||||
|
size={48}
|
||||||
|
color={isDark ? "#1e293b" : "#f1f5f9"}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<Text variant="muted" className="mt-4">
|
||||||
|
No workers found
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Floating Action Button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.go("user/create")}
|
||||||
|
className="absolute bottom-8 right-8 h-14 w-14 bg-primary rounded-full items-center justify-center shadow-lg shadow-primary/40"
|
||||||
|
>
|
||||||
|
<UserPlus size={24} color="white" strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import { View, ScrollView, Pressable } from "react-native";
|
||||||
import { router } from 'expo-router';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { FileText, ChevronRight, FolderOpen, Upload } from '@/lib/icons';
|
import { Button } from "@/components/ui/button";
|
||||||
import { MOCK_DOCUMENTS } from '@/lib/mock-data';
|
import { FileText, ChevronRight, FolderOpen, Upload } from "@/lib/icons";
|
||||||
|
import { MOCK_DOCUMENTS } from "@/lib/mock-data";
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
const PRIMARY = "#ea580c";
|
||||||
|
|
||||||
export default function DocumentsScreen() {
|
export default function DocumentsScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
className="flex-1 bg-[#f5f5f5]"
|
||||||
|
|
@ -23,21 +25,32 @@ export default function DocumentsScreen() {
|
||||||
Uploaded invoices, scans, and attachments. Synced with your account.
|
Uploaded invoices, scans, and attachments. Synced with your account.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Button variant="outline" className="mb-5 min-h-12 rounded-xl border-border" onPress={() => {}}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mb-5 min-h-12 rounded-xl border-border"
|
||||||
|
onPress={() => {}}
|
||||||
|
>
|
||||||
<Upload color={PRIMARY} size={20} strokeWidth={2} />
|
<Upload color={PRIMARY} size={20} strokeWidth={2} />
|
||||||
<Text className="ml-2 font-medium text-gray-700">Upload document</Text>
|
<Text className="ml-2 font-medium text-gray-700">Upload document</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{MOCK_DOCUMENTS.map((d) => (
|
{MOCK_DOCUMENTS.map((d) => (
|
||||||
<Card key={d.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
<Card
|
||||||
|
key={d.id}
|
||||||
|
className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"
|
||||||
|
>
|
||||||
<Pressable>
|
<Pressable>
|
||||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
||||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
||||||
<FileText color={PRIMARY} size={22} strokeWidth={2} />
|
<FileText color={PRIMARY} size={22} strokeWidth={2} />
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="font-medium text-gray-900" numberOfLines={1}>{d.name}</Text>
|
<Text className="font-medium text-gray-900" numberOfLines={1}>
|
||||||
<Text className="text-muted-foreground mt-0.5 text-sm">{d.size} · {d.uploadedAt}</Text>
|
{d.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mt-0.5 text-sm">
|
||||||
|
{d.size} · {d.uploadedAt}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<ChevronRight color="#71717a" size={20} strokeWidth={2} />
|
<ChevronRight color="#71717a" size={20} strokeWidth={2} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -45,7 +58,11 @@ export default function DocumentsScreen() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4 rounded-xl border-border"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
>
|
||||||
<Text className="font-medium">Back</Text>
|
<Text className="font-medium">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
162
app/edit-profile.tsx
Normal file
162
app/edit-profile.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
} 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 { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { ArrowLeft, User, Check, X } from "@/lib/icons";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useToast } from "@/lib/toast-store";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
export default function EditProfileScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { user, updateUser } = useAuthStore();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [firstName, setFirstName] = useState(user?.firstName || "");
|
||||||
|
const [lastName, setLastName] = useState(user?.lastName || "");
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!firstName.trim() || !lastName.trim()) {
|
||||||
|
showToast("First and last name are required", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.users.updateProfile({
|
||||||
|
body: {
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local store with the returned user data
|
||||||
|
updateUser(response);
|
||||||
|
|
||||||
|
showToast("Profile updated successfully", "success");
|
||||||
|
nav.back();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[EditProfile] Update failed:", e);
|
||||||
|
showToast(e.message || "Failed to update profile", "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
>
|
||||||
|
<ArrowLeft color={isDark ? "#fff" : "#0f172a"} size={20} />
|
||||||
|
</Pressable>
|
||||||
|
<Text variant="h4" className="text-foreground font-semibold">
|
||||||
|
Edit Profile
|
||||||
|
</Text>
|
||||||
|
<View className="w-10" /> {/* Spacer */}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 32,
|
||||||
|
paddingBottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="gap-6">
|
||||||
|
{/* First Name */}
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold mb-2 ml-1 text-foreground/70"
|
||||||
|
>
|
||||||
|
First Name
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-14">
|
||||||
|
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground text-base h-12"
|
||||||
|
placeholder="Enter first name"
|
||||||
|
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
value={firstName}
|
||||||
|
onChangeText={setFirstName}
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{firstName.trim().length > 0 && (
|
||||||
|
<Check size={16} color="#10b981" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Last Name */}
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold mb-2 ml-1 text-foreground/70"
|
||||||
|
>
|
||||||
|
Last Name
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-14">
|
||||||
|
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground text-base h-12"
|
||||||
|
placeholder="Enter last name"
|
||||||
|
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
value={lastName}
|
||||||
|
onChangeText={setLastName}
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{ textAlignVertical: "center" }}
|
||||||
|
/>
|
||||||
|
{lastName.trim().length > 0 && (
|
||||||
|
<Check size={16} color="#10b981" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-8 gap-3">
|
||||||
|
<Button
|
||||||
|
className="h-10 bg-primary rounded-[6px] shadow-lg shadow-primary/30"
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-white font-bold text-sm">
|
||||||
|
Save Changes
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
className="h-10 border border-border items-center justify-center"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground font-semibold">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
app/help.tsx
Normal file
59
app/help.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
const FAQ = [
|
||||||
|
{
|
||||||
|
q: "How do I change the theme?",
|
||||||
|
a: "Go to Profile > Appearance and choose Light, Dark, or System.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Where can I find my invoices and proformas?",
|
||||||
|
a: "Use the tabs on the bottom navigation to browse Invoices/Payments and Proformas.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Why am I seeing an API error?",
|
||||||
|
a: "If your backend is rate-limiting or the database schema is missing columns, the app may show errors. Contact your admin or check the server logs.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HelpScreen() {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader title="Help & Support" showBack />
|
||||||
|
|
||||||
|
<View className="px-5 pt-4 pb-10 gap-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text variant="h4" className="text-foreground">
|
||||||
|
FAQ
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="mt-1">
|
||||||
|
Quick answers to common questions.
|
||||||
|
</Text>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{FAQ.map((item) => (
|
||||||
|
<Card key={item.q} className="border border-border">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text className="text-foreground font-semibold">{item.q}</Text>
|
||||||
|
<Text className="text-muted-foreground mt-2">{item.a}</Text>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text className="text-foreground font-semibold">Need more help?</Text>
|
||||||
|
<Text className="text-muted-foreground mt-2">
|
||||||
|
Placeholder — add contact info (email/phone/WhatsApp) or a support chat link here.
|
||||||
|
</Text>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
app/history.tsx
Normal file
194
app/history.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
ChevronRight,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Clock,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function HistoryScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [stats, setStats] = useState({ totalRevenue: 0, pending: 0 });
|
||||||
|
const [invoices, setInvoices] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [statsRes, invoicesRes] = await Promise.all([
|
||||||
|
api.invoices.stats(),
|
||||||
|
api.invoices.getAll({ query: { limit: 100 } }),
|
||||||
|
]);
|
||||||
|
setStats(statsRes);
|
||||||
|
setInvoices(invoicesRes.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[HistoryScreen] Error fetching history:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Activity History" showBack />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View className="flex-row gap-4 mb-10">
|
||||||
|
<ShadowWrapper className="flex-1">
|
||||||
|
<View className="bg-card rounded-[10px] p-4 border border-border/5">
|
||||||
|
<View className="h-10 w-10 bg-emerald-500/10 rounded-[8px] items-center justify-center mb-3">
|
||||||
|
<TrendingUp color="#10b981" size={20} strokeWidth={2.5} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="font-bold text-[10px] uppercase tracking-widest opacity-60"
|
||||||
|
>
|
||||||
|
Total Inflow
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" className="text-foreground font-black mt-1">
|
||||||
|
${stats.totalRevenue.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<ShadowWrapper className="flex-1">
|
||||||
|
<View className="bg-card rounded-[10px] p-4 border border-border/5">
|
||||||
|
<View className="h-10 w-10 bg-amber-500/10 rounded-[8px] items-center justify-center mb-3">
|
||||||
|
<TrendingDown color="#f59e0b" size={20} strokeWidth={2.5} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="font-bold text-[10px] uppercase tracking-widest opacity-60"
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" className="text-foreground font-black mt-1">
|
||||||
|
${stats.pending.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="h4" className="text-foreground mb-4 tracking-tight">
|
||||||
|
All Activity
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="py-20 items-center">
|
||||||
|
<ActivityIndicator color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="gap-2">
|
||||||
|
{invoices.length > 0 ? (
|
||||||
|
invoices.map((inv) => (
|
||||||
|
<Pressable
|
||||||
|
key={inv.id}
|
||||||
|
onPress={() => nav.go("invoices/[id]", { id: inv.id })}
|
||||||
|
>
|
||||||
|
<ShadowWrapper level="xs">
|
||||||
|
<Card className="rounded-[8px] bg-card overflow-hidden border-0">
|
||||||
|
<CardContent className="flex-row items-center py-4 px-3">
|
||||||
|
<View className="bg-secondary/40 h-10 w-10 rounded-[8px] items-center justify-center mr-3 border border-border/10">
|
||||||
|
<FileText
|
||||||
|
size={20}
|
||||||
|
color="#ea580c"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-bold"
|
||||||
|
>
|
||||||
|
{inv.customerName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[11px] font-medium opacity-60"
|
||||||
|
>
|
||||||
|
{new Date(inv.issueDate).toLocaleDateString()} ·
|
||||||
|
Proforma
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-end">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-black"
|
||||||
|
>
|
||||||
|
${Number(inv.amount).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
className={`mt-1 rounded-[5px] px-2.5 py-1 border border-border/20 ${
|
||||||
|
inv.status === "PAID"
|
||||||
|
? "bg-emerald-500/10"
|
||||||
|
: inv.status === "PENDING"
|
||||||
|
? "bg-amber-500/10"
|
||||||
|
: inv.status === "DRAFT"
|
||||||
|
? "bg-secondary/50"
|
||||||
|
: "bg-red-500/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-[8px] font-black uppercase tracking-widest ${
|
||||||
|
inv.status === "PAID"
|
||||||
|
? "text-emerald-600"
|
||||||
|
: inv.status === "PENDING"
|
||||||
|
? "text-amber-600"
|
||||||
|
: inv.status === "DRAFT"
|
||||||
|
? "text-muted-foreground"
|
||||||
|
: "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{inv.status}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
color="#94a3b8"
|
||||||
|
className="ml-2"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ShadowWrapper>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="py-6">
|
||||||
|
<EmptyState
|
||||||
|
title="No activity yet"
|
||||||
|
description="Payments and invoices you create will show up here so you can track everything in one place."
|
||||||
|
hint="Create a proforma invoice to generate your first activity."
|
||||||
|
actionLabel="Create Proforma"
|
||||||
|
onActionPress={() => nav.go("proforma/create")}
|
||||||
|
previewLines={4}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,100 +1,281 @@
|
||||||
import { View, ScrollView } from 'react-native';
|
import React, { useState, useEffect } from "react";
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Button } from '@/components/ui/button';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
import { FileText, Calendar, User, Share2, Download, ChevronRight } from '@/lib/icons';
|
import { Text } from "@/components/ui/text";
|
||||||
import { MOCK_INVOICES } from '@/lib/mock-data';
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
const PRIMARY = '#ea580c';
|
import {
|
||||||
const MOCK_ITEMS = [
|
FileText,
|
||||||
{ description: 'Marketing Landing Page Package', qty: 1, unitPrice: 1000, total: 1000 },
|
Calendar,
|
||||||
{ description: 'Instagram Post Initial Design', qty: 4, unitPrice: 100, total: 400 },
|
Share2,
|
||||||
];
|
Download,
|
||||||
|
ArrowLeft,
|
||||||
|
ExternalLink,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
export default function InvoiceDetailScreen() {
|
export default function InvoiceDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const invoice = MOCK_INVOICES.find((i) => i.id === id);
|
const { id } = useLocalSearchParams();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [invoice, setInvoice] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInvoice();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchInvoice = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await api.invoices.getById({ params: { id: id as string } });
|
||||||
|
setInvoice(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[InvoiceDetail] Error:", error);
|
||||||
|
toast.error("Error", "Failed to load invoice details");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Invoice Details" showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<ActivityIndicator color="#ea580c" size="large" />
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Invoice Details" showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<Text variant="muted">Invoice not found</Text>
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScreenWrapper className="bg-background">
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
<StandardHeader title="Invoice Details" showBack />
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
<Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<View className="flex-row items-center justify-between">
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<FileText color={PRIMARY} size={22} strokeWidth={2} />
|
|
||||||
<Text className="font-semibold text-gray-900">Invoice #{invoice?.invoiceNumber ?? id}</Text>
|
|
||||||
</View>
|
|
||||||
<View className="rounded-full bg-amber-500/20 px-2.5 py-1">
|
|
||||||
<Text className="text-xs font-medium text-amber-700">{invoice?.status ?? 'Waiting'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text className="text-muted-foreground mt-2 text-sm">Amount due</Text>
|
|
||||||
<Text className="mt-1 text-2xl font-bold text-gray-900">${invoice?.amount.toLocaleString() ?? '—'}</Text>
|
|
||||||
<View className="mt-3 flex-row gap-4">
|
|
||||||
<View className="flex-row items-center gap-1.5">
|
|
||||||
<Calendar color="#71717a" size={16} strokeWidth={2} />
|
|
||||||
<Text className="text-muted-foreground text-sm">Due {invoice?.dueDate ?? '—'}</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row items-center gap-1.5">
|
|
||||||
<Calendar color="#71717a" size={16} strokeWidth={2} />
|
|
||||||
<Text className="text-muted-foreground text-sm">Issued {invoice?.createdAt ?? '—'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
|
<ScrollView
|
||||||
<CardHeader className="pb-2">
|
className="flex-1"
|
||||||
<View className="flex-row items-center gap-2">
|
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||||
<User color="#71717a" size={18} strokeWidth={2} />
|
showsVerticalScrollIndicator={false}
|
||||||
<CardTitle className="text-base">Bill to</CardTitle>
|
>
|
||||||
</View>
|
{/* Status Hero Card */}
|
||||||
</CardHeader>
|
<Card className="mb-4 overflow-hidden rounded-[6px] border-0 bg-primary">
|
||||||
<CardContent>
|
<View className="p-5">
|
||||||
<Text className="font-medium text-gray-900">{invoice?.recipient ?? '—'}</Text>
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
<Text className="text-muted-foreground text-sm">{invoice?.recipientEmail ?? '—'}</Text>
|
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
||||||
</CardContent>
|
<FileText color="white" size={16} strokeWidth={2.5} />
|
||||||
</Card>
|
</View>
|
||||||
|
<View
|
||||||
<Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
|
className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`}
|
||||||
<CardHeader className="pb-2">
|
>
|
||||||
<CardTitle className="text-base">Items</CardTitle>
|
<Text
|
||||||
</CardHeader>
|
className={`text-[10px] font-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
|
||||||
<CardContent className="gap-2">
|
>
|
||||||
{MOCK_ITEMS.map((item, i) => (
|
{invoice.status || "Pending"}
|
||||||
<View key={i} className="flex-row justify-between border-b border-border py-2 last:border-0">
|
</Text>
|
||||||
<Text className="text-gray-700">{item.description} × {item.qty}</Text>
|
</View>
|
||||||
<Text className="font-medium text-gray-900">${item.total.toLocaleString()}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
|
||||||
<View className="mt-2 border-t border-border pt-3">
|
<Text variant="small" className="text-white/70 mb-0.5">
|
||||||
<View className="flex-row justify-between">
|
Total Amount
|
||||||
<Text className="font-semibold text-gray-900">Total</Text>
|
</Text>
|
||||||
<Text className="font-semibold text-gray-900">${invoice?.amount.toLocaleString() ?? '1,540'}</Text>
|
<Text variant="h3" className="text-white font-bold mb-3">
|
||||||
|
${Number(invoice.amount).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<Calendar color="rgba(255,255,255,0.9)" size={12} />
|
||||||
|
<Text className="text-white/90 text-xs font-semibold">
|
||||||
|
Due {new Date(invoice.dueDate).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="h-3 w-[1px] bg-white/60" />
|
||||||
|
<Text className="text-white/90 text-xs font-semibold">
|
||||||
|
#{invoice.invoiceNumber || id}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<View className="flex-row gap-3">
|
{/* Recipient & Category — inline info strip */}
|
||||||
<Button variant="outline" className="min-h-12 flex-1 rounded-xl border-border" onPress={() => {}}>
|
<Card className="bg-card rounded-[6px] mb-4">
|
||||||
<Share2 color={PRIMARY} size={20} strokeWidth={2} />
|
<View className="flex-row px-4 py-2">
|
||||||
<Text className="ml-2 font-medium text-gray-700">Share</Text>
|
<View className="flex-1 flex-row items-center">
|
||||||
</Button>
|
<View className="flex-col">
|
||||||
<Button variant="outline" className="min-h-12 flex-1 rounded-xl border-border" onPress={() => {}}>
|
<Text className="text-foreground text-xs opacity-60">
|
||||||
<Download color={PRIMARY} size={20} strokeWidth={2} />
|
Recipient
|
||||||
<Text className="ml-2 font-medium text-gray-700">PDF</Text>
|
</Text>
|
||||||
</Button>
|
<Text
|
||||||
</View>
|
variant="p"
|
||||||
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
|
className="text-foreground font-semibold"
|
||||||
<ChevronRight className="rotate-180" color="#71717a" size={20} strokeWidth={2} />
|
numberOfLines={1}
|
||||||
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
|
>
|
||||||
</Button>
|
{invoice.customerName || "—"}
|
||||||
</ScrollView>
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="w-[1px] bg-border/70 mx-3" />
|
||||||
|
<View className="flex-1 flex-row items-center">
|
||||||
|
<View className="flex-col">
|
||||||
|
<Text className="text-foreground text-xs opacity-60">
|
||||||
|
Category
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
General
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Items / Billing Summary */}
|
||||||
|
<Card className="mb-4 bg-card rounded-[6px]">
|
||||||
|
<View className="p-4">
|
||||||
|
<View className="flex-row items-center gap-2 mb-2">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-bold opacity-60 uppercase text-[10px] tracking-widest"
|
||||||
|
>
|
||||||
|
Billing Summary
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between py-3 border-b border-border/70">
|
||||||
|
<View className="flex-1 pr-4">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold text-sm"
|
||||||
|
>
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||||
|
$
|
||||||
|
{(
|
||||||
|
Number(invoice.amount) - (Number(invoice.taxAmount) || 0)
|
||||||
|
).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{Number(invoice.taxAmount) > 0 && (
|
||||||
|
<View className="flex-row justify-between py-3 border-b border-border/70">
|
||||||
|
<View className="flex-1 pr-4">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold text-sm"
|
||||||
|
>
|
||||||
|
Tax
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||||
|
+ ${Number(invoice.taxAmount).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="mt-3 pt-3 flex-row justify-between items-center border-t border-border/70">
|
||||||
|
<Text variant="muted" className="font-semibold text-sm">
|
||||||
|
Total Balance
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="h3"
|
||||||
|
className="text-foreground font-semibold text-xl tracking-tight"
|
||||||
|
>
|
||||||
|
${Number(invoice.amount).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notes Section (New) */}
|
||||||
|
{invoice.notes && (
|
||||||
|
<Card className="mb-4 bg-card rounded-[6px]">
|
||||||
|
<View className="p-4">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-bold opacity-60 uppercase text-[10px] tracking-widest mb-2"
|
||||||
|
>
|
||||||
|
Additional Notes
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-medium text-xs leading-5"
|
||||||
|
>
|
||||||
|
{invoice.notes}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline Section (New) */}
|
||||||
|
<View className="mt-2 mb-6 px-4 py-3 bg-secondary/20 rounded-[8px] border border-border/30">
|
||||||
|
<View className="flex-row justify-between mb-1.5">
|
||||||
|
<Text className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">
|
||||||
|
Created
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[10px] text-foreground font-bold">
|
||||||
|
{new Date(invoice.createdAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">
|
||||||
|
Last Updated
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[10px] text-foreground font-bold">
|
||||||
|
{new Date(invoice.updatedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<Button
|
||||||
|
className=" flex-1 mb-4 h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
|
||||||
|
onPress={() => {}}
|
||||||
|
>
|
||||||
|
<Share2 color="#ffffff" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="ml-2 text-white text-[12px] font-black uppercase tracking-widest">
|
||||||
|
Share SMS
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<Button
|
||||||
|
className=" flex-1 mb-4 h-12 rounded-[10px] bg-card border border-border"
|
||||||
|
onPress={() => {}}
|
||||||
|
>
|
||||||
|
<Download color="#0f172a" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="ml-2 text-foreground text-[12px] font-black uppercase tracking-widest">
|
||||||
|
Get PDF
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</ShadowWrapper>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
318
app/login.tsx
318
app/login.tsx
|
|
@ -1,40 +1,288 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import React, { useState } from "react";
|
||||||
import { router } from 'expo-router';
|
import {
|
||||||
import { Text } from '@/components/ui/text';
|
View,
|
||||||
import { Button } from '@/components/ui/button';
|
ScrollView,
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
Pressable,
|
||||||
import { Mail, ArrowLeft } from '@/lib/icons';
|
TextInput,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Image,
|
||||||
|
} 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 { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User, Globe } from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { api, BASE_URL, rbacApi } from "@/lib/api";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { useLanguageStore, AppLanguage } from "@/lib/language-store";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import { LanguageModal } from "@/components/LanguageModal";
|
||||||
|
import {
|
||||||
|
GoogleSignin,
|
||||||
|
statusCodes,
|
||||||
|
} from "@react-native-google-signin/google-signin";
|
||||||
|
|
||||||
|
GoogleSignin.configure({
|
||||||
|
webClientId:
|
||||||
|
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
|
||||||
|
iosClientId:
|
||||||
|
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", // Placeholder: replace with your actual iOS Client ID from Google Cloud Console
|
||||||
|
offlineAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
const { language, setLanguage } = useLanguageStore();
|
||||||
|
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const [identifier, setIdentifier] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!identifier || !password) {
|
||||||
|
toast.error(
|
||||||
|
"Required Fields",
|
||||||
|
"Please enter both identifier and password",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const isEmail = identifier.includes("@");
|
||||||
|
const payload = isEmail
|
||||||
|
? { email: identifier, password }
|
||||||
|
: { phone: identifier, password };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Using the new api.auth.login which is powered by simple-api
|
||||||
|
const response = await api.auth.login({ body: payload });
|
||||||
|
|
||||||
|
// Store user, access token, refresh token, and permissions
|
||||||
|
// // Fetch roles to get permissions
|
||||||
|
// const rolesResponse = await rbacApi.roles();
|
||||||
|
// const userRole = response.user.role;
|
||||||
|
// const roleData = rolesResponse.find((r: any) => r.role === userRole);
|
||||||
|
// const permissions = roleData ? roleData.permissions : [];
|
||||||
|
const permissions: string[] = [];
|
||||||
|
|
||||||
|
// Store user, access token, refresh token, and permissions
|
||||||
|
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
|
||||||
|
toast.success("Welcome Back!", "You have successfully logged in.");
|
||||||
|
|
||||||
|
// Explicitly navigate to home
|
||||||
|
nav.go("(tabs)");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("Login Failed", err.message || "Invalid credentials");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await GoogleSignin.hasPlayServices();
|
||||||
|
const userInfo = await GoogleSignin.signIn();
|
||||||
|
|
||||||
|
// In newer versions of the library, the response is in data
|
||||||
|
// If using idToken, ensure you configured webClientId
|
||||||
|
const idToken = userInfo.data?.idToken || (userInfo as any).idToken;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
throw new Error("Failed to obtain Google ID Token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send idToken to our new consolidated endpoint
|
||||||
|
const response = await api.auth.googleMobile({ body: { idToken } });
|
||||||
|
|
||||||
|
// 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[] = [];
|
||||||
|
|
||||||
|
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
|
||||||
|
toast.success("Welcome!", "Signed in with Google.");
|
||||||
|
nav.go("(tabs)");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
|
||||||
|
// User cancelled the login flow
|
||||||
|
} else if (error.code === statusCodes.IN_PROGRESS) {
|
||||||
|
toast.error("Login in progress", "Please wait...");
|
||||||
|
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
|
||||||
|
toast.error("Play Services", "Google Play Services not available");
|
||||||
|
} else {
|
||||||
|
console.error("[Login] Google Error:", error);
|
||||||
|
toast.error(
|
||||||
|
"Google Login Failed",
|
||||||
|
error.message || "An error occurred",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScreenWrapper>
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
<KeyboardAvoidingView
|
||||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
showsVerticalScrollIndicator={false}
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
|
<ScrollView
|
||||||
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
|
className="flex-1"
|
||||||
<CardHeader>
|
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 10 }}
|
||||||
<CardTitle className="text-lg">Sign in</CardTitle>
|
keyboardShouldPersistTaps="handled"
|
||||||
<CardDescription className="mt-1">Use the same account as the web app.</CardDescription>
|
>
|
||||||
</CardHeader>
|
<View className="flex-row justify-end mb-4">
|
||||||
<CardContent className="gap-3">
|
<Pressable
|
||||||
<Button className="min-h-12 rounded-xl bg-primary">
|
onPress={() => setLanguageModalVisible(true)}
|
||||||
<Mail color="#ffffff" size={20} strokeWidth={2} />
|
className="p-2 rounded-full bg-card border border-border"
|
||||||
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
|
>
|
||||||
</Button>
|
<Globe color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
|
||||||
<Button variant="outline" className="min-h-12 rounded-xl border-border">
|
</Pressable>
|
||||||
<Text className="font-medium text-gray-700">Continue with Google</Text>
|
</View>
|
||||||
</Button>
|
|
||||||
</CardContent>
|
{/* Logo / Branding */}
|
||||||
</Card>
|
<View className="items-center mb-10">
|
||||||
<Pressable onPress={() => router.push('/register')} className="mt-4">
|
<Text variant="h2" className="mt-6 font-bold text-foreground">
|
||||||
<Text className="text-center text-primary font-medium">Create account</Text>
|
Login
|
||||||
</Pressable>
|
</Text>
|
||||||
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
|
<Text variant="muted" className="mt-2 text-center">
|
||||||
<ArrowLeft color="#71717a" size={20} strokeWidth={2} />
|
Sign in to manage your tickets & invoices
|
||||||
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
|
</Text>
|
||||||
</Button>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
|
{/* Form */}
|
||||||
|
<View className="gap-5">
|
||||||
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Email or Phone Number
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
|
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground"
|
||||||
|
placeholder="john@example.com or +251..."
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={identifier}
|
||||||
|
onChangeText={setIdentifier}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
|
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground"
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
/>
|
||||||
|
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
) : (
|
||||||
|
<Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="h-10 bg-primary rounded-[6px] shadow-lg shadow-primary/30 mt-2"
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="text-white font-bold text-base mr-2">
|
||||||
|
Sign In
|
||||||
|
</Text>
|
||||||
|
<ArrowRight color="white" size={18} strokeWidth={2.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Social / Other */}
|
||||||
|
<View className="mt-12">
|
||||||
|
<View className="flex-row items-center mb-8">
|
||||||
|
<View className="flex-1 h-[1px] bg-border" />
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="mx-4 text-muted-foreground uppercase font-bold tracking-widest text-[10px]"
|
||||||
|
>
|
||||||
|
or
|
||||||
|
</Text>
|
||||||
|
<View className="flex-1 h-[1px] bg-border" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Pressable
|
||||||
|
onPress={handleGoogleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 h-10 border border-border rounded-[6px] items-center justify-center flex-row bg-card"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={isDark ? "white" : "black"} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/google-logo.png")}
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<Text className="ml-3 font-bold text-foreground text-base">
|
||||||
|
Continue with Google
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-10 items-center justify-center py-2"
|
||||||
|
onPress={() => nav.go("register")}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Text className="text-primary">Create one</Text>
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
<LanguageModal
|
||||||
|
visible={languageModalVisible}
|
||||||
|
current={language}
|
||||||
|
onSelect={(lang) => setLanguage(lang)}
|
||||||
|
onClose={() => setLanguageModalVisible(false)}
|
||||||
|
/>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,134 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { router } from 'expo-router';
|
import { View, ActivityIndicator, FlatList, RefreshControl } from "react-native";
|
||||||
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 { Bell, Settings, ChevronRight } from '@/lib/icons';
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
|
||||||
const MOCK_NOTIFICATIONS = [
|
type NotificationItem = {
|
||||||
{ id: '1', title: 'Invoice reminder', body: 'Invoice #2 to Robin Murray is due in 2 days.', time: '2h ago', read: false },
|
id: string;
|
||||||
{ id: '2', title: 'Payment received', body: 'Payment of $500 received for Invoice #4.', time: '1d ago', read: true },
|
title?: string;
|
||||||
{ id: '3', title: 'Proforma submission', body: 'Vendor A submitted a quote for Marketing Landing Page.', time: '2d ago', read: true },
|
body?: string;
|
||||||
];
|
message?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
read?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function NotificationsScreen() {
|
export default function NotificationsScreen() {
|
||||||
return (
|
const [items, setItems] = useState<NotificationItem[]>([]);
|
||||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
const [loading, setLoading] = useState(true);
|
||||||
<View className="mb-4 flex-row items-center justify-between">
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
<View className="flex-row items-center gap-2">
|
const [page, setPage] = useState(1);
|
||||||
<Bell color="#18181b" size={22} strokeWidth={2} />
|
const [hasMore, setHasMore] = useState(true);
|
||||||
<Text className="text-xl font-semibold text-gray-900">Notifications</Text>
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
</View>
|
|
||||||
<Pressable className="flex-row items-center gap-1" onPress={() => router.push('/notifications/settings')}>
|
|
||||||
<Settings color="#ea580c" size={18} strokeWidth={2} />
|
|
||||||
<Text className="text-primary font-medium">Settings</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{MOCK_NOTIFICATIONS.map((n) => (
|
const fetchNotifications = useCallback(
|
||||||
<Card key={n.id} className={`mb-2 ${!n.read ? 'border-primary/30' : ''}`}>
|
async (pageNum: number, mode: "initial" | "refresh" | "more") => {
|
||||||
<CardContent className="py-3">
|
try {
|
||||||
<Text className="font-semibold text-gray-900">{n.title}</Text>
|
if (mode === "initial") setLoading(true);
|
||||||
<Text className="text-muted-foreground mt-1 text-sm">{n.body}</Text>
|
if (mode === "refresh") setRefreshing(true);
|
||||||
<Text className="text-muted-foreground mt-1 text-xs">{n.time}</Text>
|
if (mode === "more") setLoadingMore(true);
|
||||||
</CardContent>
|
|
||||||
</Card>
|
const res = await (api as any).notifications.getAll({
|
||||||
))}
|
query: { page: pageNum, limit: 20 },
|
||||||
</ScrollView>
|
});
|
||||||
|
|
||||||
|
const next = (res?.data ?? []) as NotificationItem[];
|
||||||
|
if (mode === "more") {
|
||||||
|
setItems((prev) => [...prev, ...next]);
|
||||||
|
} else {
|
||||||
|
setItems(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMore(Boolean(res?.meta?.hasNextPage));
|
||||||
|
setPage(pageNum);
|
||||||
|
} catch (e) {
|
||||||
|
setHasMore(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications(1, "initial");
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
const onRefresh = () => fetchNotifications(1, "refresh");
|
||||||
|
const onEndReached = () => {
|
||||||
|
if (!loading && !loadingMore && hasMore) fetchNotifications(page + 1, "more");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: NotificationItem }) => {
|
||||||
|
const message = item.body ?? item.message ?? "";
|
||||||
|
const time = item.createdAt
|
||||||
|
? new Date(item.createdAt).toLocaleString()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-2">
|
||||||
|
<CardContent className="py-3">
|
||||||
|
<Text className="font-semibold text-foreground">
|
||||||
|
{item.title ?? "Notification"}
|
||||||
|
</Text>
|
||||||
|
{message ? (
|
||||||
|
<Text className="text-muted-foreground mt-1 text-sm">{message}</Text>
|
||||||
|
) : null}
|
||||||
|
{time ? (
|
||||||
|
<Text className="text-muted-foreground mt-1 text-xs">{time}</Text>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader
|
||||||
|
showBack
|
||||||
|
title="Notifications"
|
||||||
|
rightAction="notificationsSettings"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(i) => i.id}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onEndReachedThreshold={0.4}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="px-[16px] py-6">
|
||||||
|
<EmptyState
|
||||||
|
title="No notifications"
|
||||||
|
description="You don't have any notifications yet."
|
||||||
|
hint="Pull to refresh to check for new notifications."
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
loadingMore ? (
|
||||||
|
<View className="py-4">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,221 @@
|
||||||
import { View, ScrollView, Switch } from 'react-native';
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { router } from 'expo-router';
|
import {
|
||||||
import { useState } from 'react';
|
View,
|
||||||
import { Text } from '@/components/ui/text';
|
ScrollView,
|
||||||
import { Button } from '@/components/ui/button';
|
Switch,
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
ActivityIndicator,
|
||||||
|
TextInput,
|
||||||
|
useColorScheme,
|
||||||
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Bell, CalendarSearch, FileText, Newspaper, ChevronRight } from "@/lib/icons";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
|
||||||
|
type NotificationSettings = {
|
||||||
|
id: string;
|
||||||
|
invoiceReminders: boolean;
|
||||||
|
daysBeforeDueDate: number;
|
||||||
|
newsAlerts: boolean;
|
||||||
|
reportReady: boolean;
|
||||||
|
userId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function NotificationSettingsScreen() {
|
export default function NotificationSettingsScreen() {
|
||||||
const [invoiceReminders, setInvoiceReminders] = useState(true);
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const [daysBeforeDue, setDaysBeforeDue] = useState(2);
|
const colorScheme = useColorScheme();
|
||||||
const [newsAlerts, setNewsAlerts] = useState(true);
|
const isDark = colorScheme === "dark";
|
||||||
const [reportReady, setReportReady] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
||||||
|
|
||||||
|
const [invoiceReminders, setInvoiceReminders] = useState(false);
|
||||||
|
const [daysBeforeDueDate, setDaysBeforeDueDate] = useState("0");
|
||||||
|
const [newsAlerts, setNewsAlerts] = useState(false);
|
||||||
|
const [reportReady, setReportReady] = useState(false);
|
||||||
|
const [daysModalVisible, setDaysModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const daysOptions = [
|
||||||
|
{ label: "1 day", value: "1" },
|
||||||
|
{ label: "3 days", value: "3" },
|
||||||
|
{ label: "7 days", value: "7" },
|
||||||
|
{ label: "14 days", value: "14" },
|
||||||
|
{ label: "30 days", value: "30" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await (api as any).notifications.settings();
|
||||||
|
const data = res?.data ?? res;
|
||||||
|
setSettings(data);
|
||||||
|
setInvoiceReminders(Boolean(data?.invoiceReminders));
|
||||||
|
setNewsAlerts(Boolean(data?.newsAlerts));
|
||||||
|
setReportReady(Boolean(data?.reportReady));
|
||||||
|
setDaysBeforeDueDate(String(data?.daysBeforeDueDate ?? 0));
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, [loadSettings]);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.notifications.update({
|
||||||
|
body: {
|
||||||
|
invoiceReminders,
|
||||||
|
daysBeforeDueDate: parseInt(daysBeforeDueDate),
|
||||||
|
newsAlerts,
|
||||||
|
reportReady,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
nav.back();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
<ScreenWrapper className="bg-background">
|
||||||
<Card className="mb-4">
|
<StandardHeader showBack title="Notification settings" />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Notification settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="gap-4">
|
|
||||||
<View className="flex-row items-center justify-between">
|
|
||||||
<Text className="text-gray-900">Invoice reminders</Text>
|
|
||||||
<Switch value={invoiceReminders} onValueChange={setInvoiceReminders} />
|
|
||||||
</View>
|
|
||||||
<View className="flex-row items-center justify-between">
|
|
||||||
<Text className="text-gray-900">News & announcements</Text>
|
|
||||||
<Switch value={newsAlerts} onValueChange={setNewsAlerts} />
|
|
||||||
</View>
|
|
||||||
<View className="flex-row items-center justify-between">
|
|
||||||
<Text className="text-gray-900">Report ready</Text>
|
|
||||||
<Switch value={reportReady} onValueChange={setReportReady} />
|
|
||||||
</View>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button variant="outline" onPress={() => router.back()}>
|
{loading ? (
|
||||||
<Text className="font-medium">Back</Text>
|
<View className="flex-1 items-center justify-center">
|
||||||
</Button>
|
<ActivityIndicator />
|
||||||
</ScrollView>
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 110 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View className="mb-5">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold mb-2 px-1">
|
||||||
|
Preferences
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<View className="flex-row items-center px-4 py-3 border-b border-border/40">
|
||||||
|
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||||
|
<Bell size={17} color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-medium">
|
||||||
|
Invoice reminders
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5">
|
||||||
|
Get reminders before invoices are due
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={invoiceReminders}
|
||||||
|
onValueChange={setInvoiceReminders}
|
||||||
|
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
|
||||||
|
thumbColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-4 py-3 border-b border-border/40">
|
||||||
|
<View className="flex-row items-center mb-2">
|
||||||
|
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||||
|
<CalendarSearch size={17} color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-medium">
|
||||||
|
Days before due date
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5">
|
||||||
|
Currently: {daysBeforeDueDate} days
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => setDaysModalVisible(true)}>
|
||||||
|
<ChevronRight size={18} color="#ea580c" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center px-4 py-3 border-b border-border/40">
|
||||||
|
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||||
|
<Newspaper size={17} color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-medium">News alerts</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5">
|
||||||
|
Product updates and announcements
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={newsAlerts}
|
||||||
|
onValueChange={setNewsAlerts}
|
||||||
|
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
|
||||||
|
thumbColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center px-4 py-3">
|
||||||
|
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||||
|
<FileText size={17} color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-medium">Report ready</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5">
|
||||||
|
Notify when reports are generated
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={reportReady}
|
||||||
|
onValueChange={setReportReady}
|
||||||
|
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
|
||||||
|
thumbColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="absolute bottom-0 pb-10 left-0 right-0 p-4 bg-background border-t border-border">
|
||||||
|
<Button className="bg-primary" onPress={onSave} disabled={saving || loading}>
|
||||||
|
<Text className="text-white font-semibold">
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={daysModalVisible}
|
||||||
|
title="Select Days"
|
||||||
|
onClose={() => setDaysModalVisible(false)}
|
||||||
|
>
|
||||||
|
{daysOptions.map((option) => (
|
||||||
|
<SelectOption
|
||||||
|
key={option.value}
|
||||||
|
label={option.label}
|
||||||
|
value={option.value}
|
||||||
|
selected={option.value === daysBeforeDueDate}
|
||||||
|
onSelect={(value: string) => {
|
||||||
|
setDaysBeforeDueDate(value);
|
||||||
|
setDaysModalVisible(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
795
app/payment-requests/create.tsx
Normal file
795
app/payment-requests/create.tsx
Normal file
|
|
@ -0,0 +1,795 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { colorScheme, useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
CalendarSearch,
|
||||||
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
|
Send,
|
||||||
|
Trash2,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
|
||||||
|
type Item = { id: number; description: string; qty: string; price: string };
|
||||||
|
|
||||||
|
type Account = {
|
||||||
|
id: number;
|
||||||
|
bankName: string;
|
||||||
|
accountName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
currency: 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: scheme } = useColorScheme();
|
||||||
|
const dark = scheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
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();
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
|
|
||||||
|
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={getPlaceholderColor(isDark)}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType={numeric ? "numeric" : "default"}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
children,
|
||||||
|
noMargin,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
noMargin?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3"}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCIES = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
|
||||||
|
const STATUSES = ["DRAFT", "PENDING", "PAID", "CANCELLED"];
|
||||||
|
|
||||||
|
export default function CreatePaymentRequestScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [paymentRequestNumber, setPaymentRequestNumber] = useState("");
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [customerId, setCustomerId] = useState("");
|
||||||
|
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [currency, setCurrency] = useState("USD");
|
||||||
|
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const [taxAmount, setTaxAmount] = useState("0");
|
||||||
|
const [discountAmount, setDiscountAmount] = useState("0");
|
||||||
|
|
||||||
|
const [issueDate, setIssueDate] = useState(
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
|
);
|
||||||
|
const [dueDate, setDueDate] = useState("");
|
||||||
|
|
||||||
|
const [paymentId, setPaymentId] = useState("");
|
||||||
|
const [status, setStatus] = useState("DRAFT");
|
||||||
|
|
||||||
|
const [items, setItems] = useState<Item[]>([
|
||||||
|
{ id: 1, description: "", qty: "1", price: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
bankName: "",
|
||||||
|
accountName: "",
|
||||||
|
accountNumber: "",
|
||||||
|
currency: "ETB",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const c = useInputColors();
|
||||||
|
|
||||||
|
const [showCurrency, setShowCurrency] = useState(false);
|
||||||
|
const [showIssueDate, setShowIssueDate] = useState(false);
|
||||||
|
const [showDueDate, setShowDueDate] = useState(false);
|
||||||
|
const [showStatus, setShowStatus] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const random = Math.floor(1000 + Math.random() * 9000);
|
||||||
|
setPaymentRequestNumber(`PAYREQ-${year}-${random}`);
|
||||||
|
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 30);
|
||||||
|
setDueDate(d.toISOString().split("T")[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateItem = (id: number, field: keyof Item, value: string) =>
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((it) => (it.id === id ? { ...it, [field]: value } : it)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addItem = () =>
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: Date.now(), description: "", qty: "1", price: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeItem = (id: number) => {
|
||||||
|
if (items.length > 1) setItems((prev) => prev.filter((it) => it.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAccount = (id: number, field: keyof Account, value: string) =>
|
||||||
|
setAccounts((prev) =>
|
||||||
|
prev.map((acc) => (acc.id === id ? { ...acc, [field]: value } : acc)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addAccount = () =>
|
||||||
|
setAccounts((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
bankName: "",
|
||||||
|
accountName: "",
|
||||||
|
accountNumber: "",
|
||||||
|
currency: "ETB",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeAccount = (id: number) => {
|
||||||
|
if (accounts.length > 1)
|
||||||
|
setAccounts((prev) => prev.filter((acc) => acc.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal = useMemo(
|
||||||
|
() =>
|
||||||
|
items.reduce(
|
||||||
|
(sum, item) =>
|
||||||
|
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const computedTotal = useMemo(
|
||||||
|
() => subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0),
|
||||||
|
[subtotal, taxAmount, discountAmount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!customerName) {
|
||||||
|
toast.error("Validation Error", "Please enter a customer name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedPhone = customerPhone.startsWith("+")
|
||||||
|
? customerPhone
|
||||||
|
: customerPhone.length > 0
|
||||||
|
? `+251${customerPhone}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
paymentRequestNumber,
|
||||||
|
customerName,
|
||||||
|
customerEmail,
|
||||||
|
customerPhone: formattedPhone,
|
||||||
|
amount: amount ? Number(amount) : Number(computedTotal.toFixed(2)),
|
||||||
|
currency,
|
||||||
|
issueDate: new Date(issueDate).toISOString(),
|
||||||
|
dueDate: new Date(dueDate).toISOString(),
|
||||||
|
description: description || `Payment request for ${customerName}`,
|
||||||
|
notes,
|
||||||
|
taxAmount: parseFloat(taxAmount) || 0,
|
||||||
|
discountAmount: parseFloat(discountAmount) || 0,
|
||||||
|
status,
|
||||||
|
paymentId,
|
||||||
|
accounts: accounts.map((a) => ({
|
||||||
|
bankName: a.bankName,
|
||||||
|
accountName: a.accountName,
|
||||||
|
accountNumber: a.accountNumber,
|
||||||
|
currency: a.currency,
|
||||||
|
})),
|
||||||
|
items: items.map((i) => ({
|
||||||
|
description: i.description || "Item",
|
||||||
|
quantity: parseFloat(i.qty) || 0,
|
||||||
|
unitPrice: parseFloat(i.price) || 0,
|
||||||
|
total: Number(
|
||||||
|
((parseFloat(i.qty) || 0) * (parseFloat(i.price) || 0)).toFixed(2),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
customerId: customerId || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await api.paymentRequests.create({ body });
|
||||||
|
toast.success("Success", "Payment request created successfully!");
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[PaymentRequestCreate] Error:", err);
|
||||||
|
toast.error("Error", err?.message || "Failed to create payment request");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Create Payment Request" showBack />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 30 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Label>General Information</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
|
<Field
|
||||||
|
label="Payment Request Number"
|
||||||
|
value={paymentRequestNumber}
|
||||||
|
onChangeText={setPaymentRequestNumber}
|
||||||
|
placeholder="e.g. PAYREQ-2024-001"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="e.g. Payment request for services"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<Label>Customer Details</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
|
<Field
|
||||||
|
label="Customer Name"
|
||||||
|
value={customerName}
|
||||||
|
onChangeText={setCustomerName}
|
||||||
|
placeholder="e.g. Acme Corporation"
|
||||||
|
/>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Email"
|
||||||
|
value={customerEmail}
|
||||||
|
onChangeText={setCustomerEmail}
|
||||||
|
placeholder="billing@acme.com"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Phone"
|
||||||
|
value={customerPhone}
|
||||||
|
onChangeText={setCustomerPhone}
|
||||||
|
placeholder="+251..."
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Field
|
||||||
|
label="Customer ID"
|
||||||
|
value={customerId}
|
||||||
|
onChangeText={setCustomerId}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<Label>Schedule & Currency</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Issue Date
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowIssueDate(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-medium" style={{ color: c.text }}>
|
||||||
|
{issueDate}
|
||||||
|
</Text>
|
||||||
|
<CalendarSearch size={14} color="#ea580c" strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Due Date
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowDueDate(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-medium" style={{ color: c.text }}>
|
||||||
|
{dueDate || "Select Date"}
|
||||||
|
</Text>
|
||||||
|
<Calendar size={14} color="#ea580c" strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Currency
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowCurrency(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-bold" style={{ color: c.text }}>
|
||||||
|
{currency}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowStatus(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-bold" style={{ color: c.text }}>
|
||||||
|
{status}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Amount"
|
||||||
|
value={amount}
|
||||||
|
onChangeText={setAmount}
|
||||||
|
placeholder="1500"
|
||||||
|
numeric
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Payment ID"
|
||||||
|
value={paymentId}
|
||||||
|
onChangeText={setPaymentId}
|
||||||
|
placeholder="PAY-123456"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<Label noMargin>Items</Label>
|
||||||
|
<Pressable
|
||||||
|
onPress={addItem}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1 rounded-[6px] bg-primary/10 border border-primary/20"
|
||||||
|
>
|
||||||
|
<Plus color="#ea580c" size={10} strokeWidth={2.5} />
|
||||||
|
<Text className="text-primary text-[8px] font-bold uppercase tracking-widest">
|
||||||
|
Add Item
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-3 mb-5">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<ShadowWrapper key={item.id}>
|
||||||
|
<View className="bg-card rounded-[6px] p-4">
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] font-bold uppercase tracking-wide opacity-50"
|
||||||
|
>
|
||||||
|
Item {index + 1}
|
||||||
|
</Text>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<Pressable onPress={() => removeItem(item.id)} hitSlop={8}>
|
||||||
|
<Trash2 color="#ef4444" size={13} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
placeholder="e.g. Web Development Service"
|
||||||
|
value={item.description}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "description", v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3 mt-4">
|
||||||
|
<Field
|
||||||
|
label="Qty"
|
||||||
|
placeholder="1"
|
||||||
|
numeric
|
||||||
|
center
|
||||||
|
value={item.qty}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "qty", v)}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Unit Price"
|
||||||
|
placeholder="0.00"
|
||||||
|
numeric
|
||||||
|
value={item.price}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "price", v)}
|
||||||
|
flex={2}
|
||||||
|
/>
|
||||||
|
<View className="flex-1 items-end justify-end pb-1">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[9px] uppercase font-bold opacity-40"
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||||
|
{currency}
|
||||||
|
{(
|
||||||
|
(parseFloat(item.qty) || 0) *
|
||||||
|
(parseFloat(item.price) || 0)
|
||||||
|
).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<Label noMargin>Accounts</Label>
|
||||||
|
<Pressable
|
||||||
|
onPress={addAccount}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1 rounded-[6px] bg-primary/10 border border-primary/20"
|
||||||
|
>
|
||||||
|
<Plus color="#ea580c" size={10} strokeWidth={2.5} />
|
||||||
|
<Text className="text-primary text-[8px] font-bold uppercase tracking-widest">
|
||||||
|
Add Account
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-3 mb-5">
|
||||||
|
{accounts.map((acc, index) => (
|
||||||
|
<ShadowWrapper key={acc.id}>
|
||||||
|
<View className="bg-card rounded-[6px] p-4">
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] font-bold uppercase tracking-wide opacity-50"
|
||||||
|
>
|
||||||
|
Account {index + 1}
|
||||||
|
</Text>
|
||||||
|
{accounts.length > 1 && (
|
||||||
|
<Pressable onPress={() => removeAccount(acc.id)} hitSlop={8}>
|
||||||
|
<Trash2 color="#ef4444" size={13} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-4">
|
||||||
|
<Field
|
||||||
|
label="Bank Name"
|
||||||
|
value={acc.bankName}
|
||||||
|
onChangeText={(v) => updateAccount(acc.id, "bankName", v)}
|
||||||
|
placeholder="e.g. Yaltopia Bank"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Account Name"
|
||||||
|
value={acc.accountName}
|
||||||
|
onChangeText={(v) => updateAccount(acc.id, "accountName", v)}
|
||||||
|
placeholder="e.g. Yaltopia Tech PLC"
|
||||||
|
/>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Account Number"
|
||||||
|
value={acc.accountNumber}
|
||||||
|
onChangeText={(v) =>
|
||||||
|
updateAccount(acc.id, "accountNumber", v)
|
||||||
|
}
|
||||||
|
placeholder="123456789"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Currency"
|
||||||
|
value={acc.currency}
|
||||||
|
onChangeText={(v) => updateAccount(acc.id, "currency", v)}
|
||||||
|
placeholder="ETB"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Label>Totals & Taxes</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-3">
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text variant="p" className="text-foreground font-bold">
|
||||||
|
{currency} {subtotal.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Tax"
|
||||||
|
value={taxAmount}
|
||||||
|
onChangeText={setTaxAmount}
|
||||||
|
placeholder="0"
|
||||||
|
numeric
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Discount"
|
||||||
|
value={discountAmount}
|
||||||
|
onChangeText={setDiscountAmount}
|
||||||
|
placeholder="0"
|
||||||
|
numeric
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-6">
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
S.input,
|
||||||
|
{
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
borderColor: c.border,
|
||||||
|
color: c.text,
|
||||||
|
height: 80,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="e.g. Payment terms: Net 30"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<View className="border border-border/60 rounded-[12px] p-5 mb-6">
|
||||||
|
<View className="flex-row justify-between items-center mb-5">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="font-bold text-xs uppercase tracking-widest opacity-60"
|
||||||
|
>
|
||||||
|
Total Amount
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" className="text-primary font-black">
|
||||||
|
{currency}{" "}
|
||||||
|
{(amount ? Number(amount) : computedTotal).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1 h-10 rounded-[6px] border border-border"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text className="text-foreground font-bold text-xs uppercase tracking-tighter">
|
||||||
|
Discard
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="flex-1 h-10 rounded-[6px] bg-primary"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<ActivityIndicator color="white" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send color="white" size={14} strokeWidth={2.5} />
|
||||||
|
<Text className="text-white font-bold text-sm">
|
||||||
|
Create Request
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showCurrency}
|
||||||
|
onClose={() => setShowCurrency(false)}
|
||||||
|
title="Select Currency"
|
||||||
|
>
|
||||||
|
{CURRENCIES.map((curr) => (
|
||||||
|
<SelectOption
|
||||||
|
key={curr}
|
||||||
|
label={curr}
|
||||||
|
value={curr}
|
||||||
|
selected={currency === curr}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setCurrency(v);
|
||||||
|
setShowCurrency(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showStatus}
|
||||||
|
onClose={() => setShowStatus(false)}
|
||||||
|
title="Select Status"
|
||||||
|
>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<SelectOption
|
||||||
|
key={s}
|
||||||
|
label={s}
|
||||||
|
value={s}
|
||||||
|
selected={status === s}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setStatus(v);
|
||||||
|
setShowStatus(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showIssueDate}
|
||||||
|
onClose={() => setShowIssueDate(false)}
|
||||||
|
title="Select Issue Date"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={issueDate}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setIssueDate(v);
|
||||||
|
setShowIssueDate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showDueDate}
|
||||||
|
onClose={() => setShowDueDate(false)}
|
||||||
|
title="Select Due Date"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={dueDate}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setDueDate(v);
|
||||||
|
setShowDueDate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,44 +1,131 @@
|
||||||
import { View, ScrollView } from 'react-native';
|
import { View, ScrollView, Pressable } from "react-native";
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useSirouRouter, useSirouParams } from "@sirou/react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Stack } from "expo-router";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
|
||||||
export default function PaymentDetailScreen() {
|
export default function PaymentDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { id } = useSirouParams<AppRoutes, "payments/[id]">();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
<ScreenWrapper className="bg-background">
|
||||||
<Card className="mb-4">
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Payment #{id ?? '—'}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="gap-2">
|
|
||||||
<View className="flex-row justify-between py-2">
|
|
||||||
<Text className="text-muted-foreground">Amount</Text>
|
|
||||||
<Text className="font-semibold text-gray-900">$2,000.00</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between py-2">
|
|
||||||
<Text className="text-muted-foreground">Source</Text>
|
|
||||||
<Text className="text-gray-900">Telebirr</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between py-2">
|
|
||||||
<Text className="text-muted-foreground">Date</Text>
|
|
||||||
<Text className="text-gray-900">Sep 11, 2022</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between py-2">
|
|
||||||
<Text className="text-muted-foreground">Associated invoice</Text>
|
|
||||||
<Text className="text-amber-600">Not linked</Text>
|
|
||||||
</View>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button className="mb-3 bg-primary" onPress={() => {}}>
|
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||||
<Text className="text-primary-foreground font-medium">Associate to invoice</Text>
|
<Pressable
|
||||||
</Button>
|
onPress={() => nav.back()}
|
||||||
<Button variant="outline" onPress={() => router.back()}>
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
<Text className="font-medium">Back to payments</Text>
|
>
|
||||||
</Button>
|
<ArrowLeft color="#0f172a" size={20} />
|
||||||
</ScrollView>
|
</Pressable>
|
||||||
|
<Text variant="h4" className="text-foreground font-semibold">
|
||||||
|
Payment Match
|
||||||
|
</Text>
|
||||||
|
<View className="w-9" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<Card className=" overflow-hidden rounded-[6px] border-0 bg-primary">
|
||||||
|
<View className="p-5">
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
||||||
|
<Wallet color="white" size={18} strokeWidth={2.5} />
|
||||||
|
</View>
|
||||||
|
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
|
||||||
|
<Text className={`text-[10px] font-bold text-white`}>
|
||||||
|
Pending Match
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="small" className="text-white/70 mb-0.5">
|
||||||
|
Received Amount
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" className="text-white font-bold mb-3">
|
||||||
|
$2,000.00
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<Text className="text-white/90 text-xs font-semibold">
|
||||||
|
TXN-9982734
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="h-3 w-[1px] bg-white/60" />
|
||||||
|
<Text className="text-white/90 text-xs font-semibold">
|
||||||
|
Telebirr SMS
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Transaction Details */}
|
||||||
|
|
||||||
|
<Text variant="h4" className="text-foreground mt-4 mb-2">
|
||||||
|
Transaction Details
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Card className="bg-card rounded-[6px] mb-3">
|
||||||
|
<View className="p-4">
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Clock color="#000" size={13} />
|
||||||
|
<Text variant="muted" className="text-sm">
|
||||||
|
Received On
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="p" className="text-foreground text-sm">
|
||||||
|
Sep 11, 2022 · 14:30
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* SMS Message */}
|
||||||
|
<Card className="bg-card rounded-[6px] mb-6">
|
||||||
|
<View className="p-4">
|
||||||
|
<Text variant="muted" className="mb-3 font-semibold">
|
||||||
|
Original SMS
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground/70 font-medium leading-6 text-sm">
|
||||||
|
"Payment received from Elnatan Jansen for order #2322 via
|
||||||
|
Telebirr. Amount: $2,000. Ref: B88-22X7."
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
|
||||||
|
<Button className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30">
|
||||||
|
<Link2 color="white" size={18} strokeWidth={2.5} />
|
||||||
|
<Text className=" text-white text-xs font-semibold uppercase tracking-widest">
|
||||||
|
Associate to Invoice
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
app/privacy.tsx
Normal file
81
app/privacy.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { View, ScrollView } from "react-native";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export default function PrivacyScreen() {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader title="Privacy Policy" showBack />
|
||||||
|
|
||||||
|
<ScrollView className="flex-1 px-5 pt-4" showsVerticalScrollIndicator={false}>
|
||||||
|
<Text variant="h4" className="text-foreground mb-4">
|
||||||
|
Privacy Policy
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
Last updated: March 10, 2026
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
1. Introduction
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
This Privacy Policy describes how we collect, use, and share your personal information when you use our mobile application ("App").
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
2. Information We Collect
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We may collect information about you in various ways, including:
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• Personal information you provide directly to us
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• Information we collect automatically when you use the App
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
• Information from third-party services
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
3. How We Use Your Information
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We use the information we collect to:
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• Provide and maintain our services
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• Process transactions and send related information
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
• Communicate with you about our services
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
4. Information Sharing
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We do not sell, trade, or otherwise transfer your personal information to third parties without your consent, except as described in this policy.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
5. Data Security
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We implement appropriate security measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
6. Contact Us
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
If you have any questions about this Privacy Policy, please contact us at privacy@example.com.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
app/profile.tsx
Normal file
362
app/profile.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
Image,
|
||||||
|
Switch,
|
||||||
|
InteractionManager,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Settings,
|
||||||
|
ChevronRight,
|
||||||
|
CreditCard,
|
||||||
|
ShieldCheck,
|
||||||
|
FileText,
|
||||||
|
HelpCircle,
|
||||||
|
History,
|
||||||
|
Bell,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
Lock,
|
||||||
|
Globe,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { saveTheme, AppTheme } from "@/lib/theme";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { useLanguageStore, AppLanguage } from "@/lib/language-store";
|
||||||
|
import { LanguageModal } from "@/components/LanguageModal";
|
||||||
|
import { ThemeModal } from "@/components/ThemeModal";
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────
|
||||||
|
const AVATAR_FALLBACK_BASE =
|
||||||
|
"https://ui-avatars.com/api/?background=ea580c&color=fff&name=";
|
||||||
|
|
||||||
|
// ── Theme bottom sheet ────────────────────────────────────────────
|
||||||
|
const THEME_OPTIONS = [
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
{ value: "dark", label: "Dark" },
|
||||||
|
{ value: "system", label: "System Default" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ThemeOption = (typeof THEME_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
|
const LANGUAGE_OPTIONS = [
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "am", label: "Amharic" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type LanguageOption = (typeof LANGUAGE_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
|
// ── Shared menu components ────────────────────────────────────────
|
||||||
|
function MenuGroup({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="mb-5">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold mb-2 px-1">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[10px] overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
onPress,
|
||||||
|
right,
|
||||||
|
destructive = false,
|
||||||
|
isLast = false,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
destructive?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className={`flex-row items-center px-4 py-3 ${
|
||||||
|
!isLast ? "border-b border-border/40" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||||
|
{icon}
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 mt-[-10px]">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className={`font-medium ${destructive ? "text-red-500" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{sublabel ? (
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5">
|
||||||
|
{sublabel}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{right !== undefined ? (
|
||||||
|
right
|
||||||
|
) : (
|
||||||
|
<ChevronRight color={destructive ? "#ef4444" : "#94a3b8"} size={18} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Screen ────────────────────────────────────────────────────────
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const { setColorScheme, colorScheme } = useColorScheme();
|
||||||
|
const { language, setLanguage } = useLanguageStore();
|
||||||
|
const [notifications, setNotifications] = useState(true);
|
||||||
|
const [themeSheetVisible, setThemeSheetVisible] = useState(false);
|
||||||
|
const [languageSheetVisible, setLanguageSheetVisible] = useState(false);
|
||||||
|
|
||||||
|
const currentTheme: ThemeOption = (colorScheme as ThemeOption) ?? "system";
|
||||||
|
const currentLanguage: LanguageOption = (language as LanguageOption) ?? "en";
|
||||||
|
|
||||||
|
const handleThemeSelect = (val: AppTheme) => {
|
||||||
|
// NativeWind 4 handles system/light/dark
|
||||||
|
setColorScheme(val);
|
||||||
|
saveTheme(val); // persist across restarts
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
>
|
||||||
|
<ArrowLeft
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Text variant="h4" className="text-foreground font-semibold">
|
||||||
|
Profile
|
||||||
|
</Text>
|
||||||
|
{/* Edit Profile shortcut */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.go("edit-profile")}
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
>
|
||||||
|
<User
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<View className="items-center mb-8">
|
||||||
|
<View className="h-20 w-20 rounded-full overflow-hidden bg-muted mb-3">
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri:
|
||||||
|
user?.avatar ||
|
||||||
|
`${AVATAR_FALLBACK_BASE}${encodeURIComponent(`${user?.firstName} ${user?.lastName}`)}`,
|
||||||
|
}}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text variant="h4" className="text-foreground">
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-sm mt-0.5">
|
||||||
|
{user?.email}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<MenuGroup label="Account">
|
||||||
|
{/* <MenuItem
|
||||||
|
icon={
|
||||||
|
<CreditCard
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Subscription"
|
||||||
|
sublabel="Pro Plan — active"
|
||||||
|
onPress={() => {}}
|
||||||
|
/> */}
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<History
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Transaction History"
|
||||||
|
onPress={() => nav.go("history")}
|
||||||
|
isLast
|
||||||
|
/>
|
||||||
|
</MenuGroup>
|
||||||
|
|
||||||
|
{/* Preferences */}
|
||||||
|
<MenuGroup label="Preferences">
|
||||||
|
{/* <MenuItem
|
||||||
|
icon={
|
||||||
|
<Bell
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Push Notifications"
|
||||||
|
right={
|
||||||
|
<Switch
|
||||||
|
value={notifications}
|
||||||
|
onValueChange={setNotifications}
|
||||||
|
trackColor={{ true: "#ea580c" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/> */}
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<Settings
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Appearance"
|
||||||
|
sublabel={
|
||||||
|
THEME_OPTIONS.find((o) => o.value === currentTheme)?.label ??
|
||||||
|
"System Default"
|
||||||
|
}
|
||||||
|
onPress={() => setThemeSheetVisible(true)}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<Globe
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Language"
|
||||||
|
sublabel={
|
||||||
|
LANGUAGE_OPTIONS.find((o) => o.value === currentLanguage)?.label ??
|
||||||
|
"English"
|
||||||
|
}
|
||||||
|
onPress={() => setLanguageSheetVisible(true)}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<Lock
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Security"
|
||||||
|
sublabel="PIN & Biometrics"
|
||||||
|
onPress={() => {}}
|
||||||
|
isLast
|
||||||
|
/>
|
||||||
|
</MenuGroup>
|
||||||
|
|
||||||
|
{/* Support & Legal */}
|
||||||
|
<MenuGroup label="Support & Legal">
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<HelpCircle
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Help & Support"
|
||||||
|
onPress={() => nav.go("help")}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<ShieldCheck
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Privacy Policy"
|
||||||
|
onPress={() => nav.go("privacy")}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<FileText
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Terms of Use"
|
||||||
|
onPress={() => nav.go("terms")}
|
||||||
|
isLast
|
||||||
|
/>
|
||||||
|
</MenuGroup>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[10px] overflow-hidden">
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<LogOut
|
||||||
|
color="#ef4444"
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Log Out"
|
||||||
|
destructive
|
||||||
|
onPress={async () => {
|
||||||
|
await logout();
|
||||||
|
nav.go("login");
|
||||||
|
}}
|
||||||
|
right={null}
|
||||||
|
isLast
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Theme sheet */}
|
||||||
|
<ThemeModal
|
||||||
|
visible={themeSheetVisible}
|
||||||
|
current={currentTheme}
|
||||||
|
onSelect={(theme) => handleThemeSelect(theme)}
|
||||||
|
onClose={() => setThemeSheetVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LanguageModal
|
||||||
|
visible={languageSheetVisible}
|
||||||
|
current={currentLanguage}
|
||||||
|
onSelect={(lang) => setLanguage(lang)}
|
||||||
|
onClose={() => setLanguageSheetVisible(false)}
|
||||||
|
/>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,66 +1,329 @@
|
||||||
import { View, ScrollView } from 'react-native';
|
import React, { useState, useEffect } from "react";
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Button } from '@/components/ui/button';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
DraftingCompass,
|
||||||
|
Clock,
|
||||||
|
Send,
|
||||||
|
ExternalLink,
|
||||||
|
ChevronRight,
|
||||||
|
CheckCircle2,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
const MOCK_ITEMS = [
|
const dummyData = {
|
||||||
{ description: 'Marketing Landing Page Package', qty: 1, unitPrice: 1000, total: 1000 },
|
id: "dummy-1",
|
||||||
{ description: 'Instagram Post Initial Design', qty: 4, unitPrice: 100, total: 400 },
|
proformaNumber: "PF-001",
|
||||||
];
|
customerName: "John Doe",
|
||||||
const MOCK_SUBTOTAL = 1400;
|
customerEmail: "john@example.com",
|
||||||
const MOCK_TAX = 140;
|
customerPhone: "+1234567890",
|
||||||
const MOCK_TOTAL = 1540;
|
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 { id } = useLocalSearchParams<{ id: string }>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [proforma, setProforma] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProforma();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchProforma = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await api.proforma.getById({ params: { id: id as string } });
|
||||||
|
setProforma(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[ProformaDetail] Error:", error);
|
||||||
|
toast.error("Error", "Failed to load proforma details");
|
||||||
|
setProforma(dummyData); // Use dummy data for testing
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Proforma" showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<ActivityIndicator color="#ea580c" size="large" />
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proforma) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Proforma" showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<Text variant="muted">Proforma not found</Text>
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtotal =
|
||||||
|
proforma.items?.reduce(
|
||||||
|
(acc: number, item: any) => acc + (Number(item.total) || 0),
|
||||||
|
0,
|
||||||
|
) || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
<ScreenWrapper className="bg-background">
|
||||||
<Card className="mb-4">
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Proforma Request #{id ?? '—'}</CardTitle>
|
{/* Header */}
|
||||||
<CardDescription>Marketing Landing Page Package</CardDescription>
|
<StandardHeader title="Proforma" showBack />
|
||||||
<Text className="text-muted-foreground mt-1 text-sm">Deadline: Sep 20, 2022 · OPEN</Text>
|
|
||||||
</CardHeader>
|
<ScrollView
|
||||||
<CardContent className="gap-2">
|
className="flex-1"
|
||||||
{MOCK_ITEMS.map((item, i) => (
|
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||||
<View key={i} className="flex-row justify-between py-2">
|
showsVerticalScrollIndicator={false}
|
||||||
<Text className="text-gray-700">{item.description} × {item.qty}</Text>
|
>
|
||||||
<Text className="font-medium text-gray-900">${item.total.toLocaleString()}</Text>
|
{/* Proforma Info Card */}
|
||||||
|
<Card className="bg-card rounded-[12px] mb-4 border border-border">
|
||||||
|
<View className="p-4">
|
||||||
|
<View className="flex-row items-center gap-3 mb-3">
|
||||||
|
<View className="bg-primary/10 p-2 rounded-[8px]">
|
||||||
|
<DraftingCompass color="#ea580c" size={16} strokeWidth={2.5} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
|
||||||
|
Proforma Details
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
|
||||||
<View className="mt-2 border-t border-border pt-2">
|
<View className="gap-2">
|
||||||
<View className="flex-row justify-between">
|
<View className="flex-row justify-between">
|
||||||
<Text className="text-muted-foreground">Subtotal</Text>
|
<Text variant="muted" className="text-xs font-medium">Proforma Number</Text>
|
||||||
<Text className="text-gray-900">${MOCK_SUBTOTAL.toLocaleString()}</Text>
|
<Text className="text-foreground font-semibold text-sm">{proforma.proformaNumber}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row justify-between">
|
<View className="flex-row justify-between">
|
||||||
<Text className="text-muted-foreground">Tax (10%)</Text>
|
<Text variant="muted" className="text-xs font-medium">Issued Date</Text>
|
||||||
<Text className="text-gray-900">${MOCK_TAX.toLocaleString()}</Text>
|
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.issueDate).toLocaleDateString()}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row justify-between">
|
<View className="flex-row justify-between">
|
||||||
<Text className="font-semibold text-gray-900">Total</Text>
|
<Text variant="muted" className="text-xs font-medium">Due Date</Text>
|
||||||
<Text className="font-semibold text-gray-900">${MOCK_TOTAL.toLocaleString()}</Text>
|
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.dueDate).toLocaleDateString()}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">Currency</Text>
|
||||||
|
<Text className="text-foreground font-semibold text-sm">{proforma.currency}</Text>
|
||||||
|
</View>
|
||||||
|
{proforma.description && (
|
||||||
|
<View className="mt-2">
|
||||||
|
<Text variant="muted" className="text-xs font-medium mb-1">Description</Text>
|
||||||
|
<Text className="text-foreground text-sm">{proforma.description}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button className="mb-3 bg-primary" onPress={() => {}}>
|
{/* Customer Info Card */}
|
||||||
<Text className="text-primary-foreground font-medium">Send to contacts</Text>
|
<Card className="bg-card rounded-[12px] mb-4 border border-border">
|
||||||
</Button>
|
<View className="p-4">
|
||||||
<Button variant="outline" onPress={() => router.back()}>
|
<View className="flex-row items-center gap-3 mb-3">
|
||||||
<Text className="font-medium">Back to list</Text>
|
<View className="bg-primary/10 p-2 rounded-[8px]">
|
||||||
</Button>
|
<CheckCircle2 color="#ea580c" size={16} strokeWidth={2.5} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
|
||||||
|
Customer Information
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text className="text-muted-foreground mt-6 mb-2 text-sm">Submissions (mock)</Text>
|
<View className="gap-2">
|
||||||
<Card>
|
<View className="flex-row justify-between">
|
||||||
<CardContent className="py-3">
|
<Text variant="muted" className="text-xs font-medium">Name</Text>
|
||||||
<Text className="font-medium text-gray-900">Vendor A — $1,450</Text>
|
<Text className="text-foreground font-semibold text-sm">{proforma.customerName}</Text>
|
||||||
<Text className="text-muted-foreground text-sm">Submitted Sep 15, 2022</Text>
|
</View>
|
||||||
</CardContent>
|
<View className="flex-row justify-between">
|
||||||
</Card>
|
<Text variant="muted" className="text-xs font-medium">Email</Text>
|
||||||
</ScrollView>
|
<Text className="text-foreground font-semibold text-sm">{proforma.customerEmail || "N/A"}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">Phone</Text>
|
||||||
|
<Text className="text-foreground font-semibold text-sm">{proforma.customerPhone || "N/A"}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Line Items Card */}
|
||||||
|
<Card className="bg-card rounded-[6px] mb-4">
|
||||||
|
<View className="p-4">
|
||||||
|
<View className="flex-row items-center gap-2 mb-2">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-bold uppercase tracking-widest text-[10px] opacity-60"
|
||||||
|
>
|
||||||
|
Line Items
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{proforma.items?.map((item: any, i: number) => (
|
||||||
|
<View
|
||||||
|
key={item.id || i}
|
||||||
|
className={`flex-row justify-between py-3 ${i < proforma.items.length - 1 ? "border-b border-border/40" : ""}`}
|
||||||
|
>
|
||||||
|
<View className="flex-1 pr-4">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold text-sm"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View className="mt-3 pt-3 border-t border-border/40 gap-2">
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold text-sm"
|
||||||
|
>
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||||
|
{proforma.currency} {subtotal.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{Number(proforma.taxAmount) > 0 && (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold text-sm"
|
||||||
|
>
|
||||||
|
Tax
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-bold text-sm"
|
||||||
|
>
|
||||||
|
{proforma.currency}{" "}
|
||||||
|
{Number(proforma.taxAmount).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{Number(proforma.discountAmount) > 0 && (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-red-500 font-semibold text-sm"
|
||||||
|
>
|
||||||
|
Discount
|
||||||
|
</Text>
|
||||||
|
<Text variant="p" className="text-red-500 font-bold text-sm">
|
||||||
|
-{proforma.currency}{" "}
|
||||||
|
{Number(proforma.discountAmount).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="flex-row justify-between items-center mt-1">
|
||||||
|
<Text variant="p" className="text-foreground font-bold">
|
||||||
|
Total Amount
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="h4"
|
||||||
|
className="text-foreground font-bold tracking-tight"
|
||||||
|
>
|
||||||
|
{proforma.currency} {Number(proforma.amount).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notes Section (New) */}
|
||||||
|
{proforma.notes && (
|
||||||
|
<Card className="bg-card rounded-[6px] mb-4">
|
||||||
|
<View className="p-4">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-bold uppercase tracking-widest text-[10px] opacity-60 mb-2"
|
||||||
|
>
|
||||||
|
Additional Notes
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-medium text-xs leading-5"
|
||||||
|
>
|
||||||
|
{proforma.notes}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View className="gap-3">
|
||||||
|
<Button
|
||||||
|
className="h-12 rounded-[10px] bg-transparent border border-border"
|
||||||
|
onPress={() => router.push("/proforma/edit?id=" + proforma.id)}
|
||||||
|
>
|
||||||
|
<DraftingCompass color="#fff" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="ml-2 text-foreground font-black text-[12px] uppercase tracking-widest">
|
||||||
|
Edit
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
|
||||||
|
onPress={() => {}}
|
||||||
|
>
|
||||||
|
<Send color="#ffffff" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
|
||||||
|
Share SMS
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
622
app/proforma/create.tsx
Normal file
622
app/proforma/create.tsx
Normal file
|
|
@ -0,0 +1,622 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
Plus,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
CalendarSearch,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { colorScheme, useColorScheme } from "nativewind";
|
||||||
|
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";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
|
||||||
|
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 dark = colorScheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
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();
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
|
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={getPlaceholderColor(isDark)}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType={numeric ? "numeric" : "default"}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCIES = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
|
||||||
|
|
||||||
|
export default function CreateProformaScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Fields
|
||||||
|
const [proformaNumber, setProformaNumber] = useState("");
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [currency, setCurrency] = useState("USD");
|
||||||
|
const [taxAmount, setTaxAmount] = useState("0");
|
||||||
|
const [discountAmount, setDiscountAmount] = useState("0");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
const [issueDate, setIssueDate] = useState(
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
|
);
|
||||||
|
const [dueDate, setDueDate] = useState("");
|
||||||
|
|
||||||
|
const [items, setItems] = useState<Item[]>([
|
||||||
|
{ id: 1, description: "", qty: "1", price: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const c = useInputColors();
|
||||||
|
|
||||||
|
// Modal States
|
||||||
|
const [showCurrency, setShowCurrency] = useState(false);
|
||||||
|
const [showIssueDate, setShowIssueDate] = useState(false);
|
||||||
|
const [showDueDate, setShowDueDate] = useState(false);
|
||||||
|
|
||||||
|
// Auto-generate Proforma Number and set default dates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const random = Math.floor(1000 + Math.random() * 9000);
|
||||||
|
setProformaNumber(`PROF-${year}-${random}`);
|
||||||
|
|
||||||
|
// Default Due Date: 30 days from now
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 30);
|
||||||
|
setDueDate(d.toISOString().split("T")[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateField = (id: number, field: keyof Item, value: string) =>
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addItem = () =>
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: Date.now(), description: "", qty: "1", price: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeItem = (id: number) => {
|
||||||
|
if (items.length > 1)
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal = items.reduce(
|
||||||
|
(sum, item) =>
|
||||||
|
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const total =
|
||||||
|
subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!customerName) {
|
||||||
|
toast.error("Validation Error", "Please enter a customer name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Handle Phone Formatting (Auto-prepend +251 if needed)
|
||||||
|
const formattedPhone = customerPhone.startsWith("+")
|
||||||
|
? customerPhone
|
||||||
|
: customerPhone.length > 0
|
||||||
|
? `+251${customerPhone}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
proformaNumber,
|
||||||
|
customerName,
|
||||||
|
customerEmail,
|
||||||
|
customerPhone: formattedPhone,
|
||||||
|
amount: Number(total.toFixed(2)),
|
||||||
|
currency,
|
||||||
|
issueDate: new Date(issueDate).toISOString(),
|
||||||
|
dueDate: new Date(dueDate).toISOString(),
|
||||||
|
description: description || `Proforma for ${customerName}`,
|
||||||
|
notes,
|
||||||
|
taxAmount: parseFloat(taxAmount) || 0,
|
||||||
|
discountAmount: parseFloat(discountAmount) || 0,
|
||||||
|
items: items.map((i) => ({
|
||||||
|
description: i.description || "Item",
|
||||||
|
quantity: parseFloat(i.qty) || 0,
|
||||||
|
unitPrice: parseFloat(i.price) || 0,
|
||||||
|
total: Number(
|
||||||
|
((parseFloat(i.qty) || 0) * (parseFloat(i.price) || 0)).toFixed(2),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.proforma.create({ body: payload });
|
||||||
|
toast.success("Success", "Proforma created successfully!");
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[ProformaCreate] Error:", err);
|
||||||
|
toast.error("Error", err.message || "Failed to create proforma");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Create Proforma" showBack />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 30 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Header Info */}
|
||||||
|
<Label>General Information</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
|
<Field
|
||||||
|
label="Proforma Number"
|
||||||
|
value={proformaNumber}
|
||||||
|
onChangeText={setProformaNumber}
|
||||||
|
placeholder="e.g. PROF-2024-001"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Project Description"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="e.g. Web Development Services"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Recipient */}
|
||||||
|
<Label>Customer Details</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
|
<Field
|
||||||
|
label="Customer Name"
|
||||||
|
value={customerName}
|
||||||
|
onChangeText={setCustomerName}
|
||||||
|
placeholder="e.g. Acme Corp"
|
||||||
|
/>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Email"
|
||||||
|
value={customerEmail}
|
||||||
|
onChangeText={setCustomerEmail}
|
||||||
|
placeholder="billing@acme.com"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Phone"
|
||||||
|
value={customerPhone}
|
||||||
|
onChangeText={setCustomerPhone}
|
||||||
|
placeholder="+251..."
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Schedule */}
|
||||||
|
<Label>Schedule & Currency</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Issue Date
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowIssueDate(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: c.text }}
|
||||||
|
>
|
||||||
|
{issueDate}
|
||||||
|
</Text>
|
||||||
|
<CalendarSearch size={14} color="#ea580c" strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Due Date
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowDueDate(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: c.text }}
|
||||||
|
>
|
||||||
|
{dueDate || "Select Date"}
|
||||||
|
</Text>
|
||||||
|
<Calendar size={14} color="#ea580c" strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Currency
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowCurrency(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-bold" style={{ color: c.text }}>
|
||||||
|
{currency}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View className="flex-[2]" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<Label noMargin>Billable Items</Label>
|
||||||
|
<Pressable
|
||||||
|
onPress={addItem}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1 rounded-[6px] bg-primary/10 border border-primary/20"
|
||||||
|
>
|
||||||
|
<Plus color="#ea580c" size={10} strokeWidth={2.5} />
|
||||||
|
<Text className="text-primary text-[8px] font-bold uppercase tracking-widest">
|
||||||
|
Add Item
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-3 mb-5">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<ShadowWrapper key={item.id}>
|
||||||
|
<View className="bg-card rounded-[6px] p-4">
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] font-bold uppercase tracking-wide opacity-50"
|
||||||
|
>
|
||||||
|
Item {index + 1}
|
||||||
|
</Text>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<Pressable onPress={() => removeItem(item.id)} hitSlop={8}>
|
||||||
|
<Trash2 color="#ef4444" size={13} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
placeholder="e.g. UI Design"
|
||||||
|
value={item.description}
|
||||||
|
onChangeText={(v) => updateField(item.id, "description", v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3 mt-4">
|
||||||
|
<Field
|
||||||
|
label="Qty"
|
||||||
|
placeholder="1"
|
||||||
|
numeric
|
||||||
|
center
|
||||||
|
value={item.qty}
|
||||||
|
onChangeText={(v) => updateField(item.id, "qty", v)}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Price"
|
||||||
|
placeholder="0.00"
|
||||||
|
numeric
|
||||||
|
value={item.price}
|
||||||
|
onChangeText={(v) => updateField(item.id, "price", v)}
|
||||||
|
flex={2}
|
||||||
|
/>
|
||||||
|
<View className="flex-1 items-end justify-end pb-1">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[9px] uppercase font-bold opacity-40"
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-bold text-sm"
|
||||||
|
>
|
||||||
|
{currency}
|
||||||
|
{(
|
||||||
|
(parseFloat(item.qty) || 0) *
|
||||||
|
(parseFloat(item.price) || 0)
|
||||||
|
).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Label>Totals & Taxes</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-3">
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text variant="p" className="text-foreground font-bold">
|
||||||
|
{currency} {subtotal.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Tax"
|
||||||
|
value={taxAmount}
|
||||||
|
onChangeText={setTaxAmount}
|
||||||
|
placeholder="0"
|
||||||
|
numeric
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Discount"
|
||||||
|
value={discountAmount}
|
||||||
|
onChangeText={setDiscountAmount}
|
||||||
|
placeholder="0"
|
||||||
|
numeric
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-6">
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
S.input,
|
||||||
|
{
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
borderColor: c.border,
|
||||||
|
color: c.text,
|
||||||
|
height: 80,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="e.g. Payment due within 30 days"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View className="border border-border/60 rounded-[12px] p-5 mb-6">
|
||||||
|
<View className="flex-row justify-between items-center mb-5">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="font-bold text-xs uppercase tracking-widest opacity-60"
|
||||||
|
>
|
||||||
|
Total Amount
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" className="text-primary font-black">
|
||||||
|
{currency}{" "}
|
||||||
|
{total.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1 h-10 rounded-[6px] border border-border"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text className="text-foreground font-bold text-xs uppercase tracking-tighter">
|
||||||
|
Discard
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1 h-10 rounded-[6px] bg-primary"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send color="white" size={14} strokeWidth={2.5} />
|
||||||
|
<Text className="text-white font-bold text-sm ">
|
||||||
|
Create Proforma
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Currency Modal */}
|
||||||
|
<PickerModal
|
||||||
|
visible={showCurrency}
|
||||||
|
onClose={() => setShowCurrency(false)}
|
||||||
|
title="Select Currency"
|
||||||
|
>
|
||||||
|
{CURRENCIES.map((curr) => (
|
||||||
|
<SelectOption
|
||||||
|
key={curr}
|
||||||
|
label={curr}
|
||||||
|
value={curr}
|
||||||
|
selected={currency === curr}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setCurrency(v);
|
||||||
|
setShowCurrency(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
{/* Issue Date Modal */}
|
||||||
|
<PickerModal
|
||||||
|
visible={showIssueDate}
|
||||||
|
onClose={() => setShowIssueDate(false)}
|
||||||
|
title="Select Issue Date"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={issueDate}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setIssueDate(v);
|
||||||
|
setShowIssueDate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
{/* Due Date Modal */}
|
||||||
|
<PickerModal
|
||||||
|
visible={showDueDate}
|
||||||
|
onClose={() => setShowDueDate(false)}
|
||||||
|
title="Select Due Date"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={dueDate}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setDueDate(v);
|
||||||
|
setShowDueDate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
children,
|
||||||
|
noMargin,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
noMargin?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3"}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
584
app/proforma/edit.tsx
Normal file
584
app/proforma/edit.tsx
Normal file
|
|
@ -0,0 +1,584 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
Plus,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
CalendarSearch,
|
||||||
|
} 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";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
|
||||||
|
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(); // Fix usage
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
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();
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
|
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 EditProformaScreen() {
|
||||||
|
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 [proformaNumber, setProformaNumber] = useState("");
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [currency, setCurrency] = useState("USD");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
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 [issueModal, setIssueModal] = useState(false);
|
||||||
|
const [dueModal, setDueModal] = useState(false);
|
||||||
|
|
||||||
|
// Fetch existing data for edit
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit) {
|
||||||
|
fetchProforma();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchProforma = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await api.proforma.getById({ params: { id: id as string } });
|
||||||
|
// Prefill form
|
||||||
|
setProformaNumber(data.proformaNumber || "");
|
||||||
|
setCustomerName(data.customerName || "");
|
||||||
|
setCustomerEmail(data.customerEmail || "");
|
||||||
|
setCustomerPhone(data.customerPhone || "");
|
||||||
|
setCurrency(data.currency || "USD");
|
||||||
|
setDescription(data.description || "");
|
||||||
|
setNotes(data.notes || "");
|
||||||
|
setTaxAmount(String(data.taxAmount?.value || data.taxAmount || ""));
|
||||||
|
setDiscountAmount(String(data.discountAmount?.value || data.discountAmount || ""));
|
||||||
|
setIssueDate(new Date(data.issueDate));
|
||||||
|
setDueDate(new Date(data.dueDate));
|
||||||
|
setItems(
|
||||||
|
data.items?.map((item: any, idx: number) => ({
|
||||||
|
id: idx + 1,
|
||||||
|
description: item.description || "",
|
||||||
|
qty: String(item.quantity || ""),
|
||||||
|
price: String(item.unitPrice?.value || item.unitPrice || ""),
|
||||||
|
})) || [{ id: 1, description: "", qty: "", price: "" }]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error", "Failed to load proforma, using test data");
|
||||||
|
// For testing, set dummy data
|
||||||
|
setProformaNumber(dummyData.proformaNumber);
|
||||||
|
setCustomerName(dummyData.customerName);
|
||||||
|
setCustomerEmail(dummyData.customerEmail);
|
||||||
|
setCustomerPhone(dummyData.customerPhone);
|
||||||
|
setCurrency(dummyData.currency);
|
||||||
|
setDescription(dummyData.description);
|
||||||
|
setNotes(dummyData.notes);
|
||||||
|
setTaxAmount(String(dummyData.taxAmount?.value || dummyData.taxAmount || ""));
|
||||||
|
setDiscountAmount(String(dummyData.discountAmount?.value || dummyData.discountAmount || ""));
|
||||||
|
setIssueDate(new Date(dummyData.issueDate));
|
||||||
|
setDueDate(new Date(dummyData.dueDate));
|
||||||
|
setItems(
|
||||||
|
dummyData.items?.map((item: any, idx: number) => ({
|
||||||
|
id: idx + 1,
|
||||||
|
description: item.description || "",
|
||||||
|
qty: String(item.quantity || ""),
|
||||||
|
price: String(item.unitPrice?.value || item.unitPrice || ""),
|
||||||
|
})) || [{ id: 1, description: "", qty: "", price: "" }]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
const newId = Math.max(...items.map((i) => i.id)) + 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 (!proformaNumber || !customerName) {
|
||||||
|
toast.error("Error", "Please fill required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
proformaNumber,
|
||||||
|
customerName,
|
||||||
|
customerEmail,
|
||||||
|
customerPhone,
|
||||||
|
amount: calculateTotal(),
|
||||||
|
currency,
|
||||||
|
issueDate: issueDate.toISOString(),
|
||||||
|
dueDate: dueDate.toISOString(),
|
||||||
|
description,
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
await api.proforma.update({
|
||||||
|
params: { id: id as string },
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
toast.success("Success", "Proforma updated successfully");
|
||||||
|
} else {
|
||||||
|
await api.proforma.create({ body: payload });
|
||||||
|
toast.success("Success", "Proforma created successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.back();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("Error", error.message || "Failed to save proforma");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title={isEdit ? "Edit Proforma" : "Create Proforma"} showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<ActivityIndicator color="#ea580c" size="large" />
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies = ["USD", "EUR", "GBP", "CAD"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
|
||||||
|
<StandardHeader title={isEdit ? "Edit Proforma" : "Create Proforma"} showBack />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Proforma 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">
|
||||||
|
Proforma Details
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-4">
|
||||||
|
<Field
|
||||||
|
label="Proforma Number"
|
||||||
|
value={proformaNumber}
|
||||||
|
onChangeText={setProformaNumber}
|
||||||
|
placeholder="Enter proforma number"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="Brief description"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Currency */}
|
||||||
|
<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">
|
||||||
|
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>
|
||||||
|
</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 Proforma" : "Create Proforma"}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
<CalendarGrid
|
||||||
|
open={issueModal}
|
||||||
|
current={issueDate.toISOString().substring(0,10)}
|
||||||
|
onDateSelect={(dateStr: string) => {
|
||||||
|
setIssueDate(new Date(dateStr));
|
||||||
|
setIssueModal(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setIssueModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
<CalendarGrid
|
||||||
|
open={dueModal}
|
||||||
|
current={dueDate.toISOString().substring(0,10)}
|
||||||
|
onDateSelect={(dateStr: string) => {
|
||||||
|
setDueDate(new Date(dateStr));
|
||||||
|
setDueModal(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setDueModal(false)}
|
||||||
|
/>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
app/register.tsx
281
app/register.tsx
|
|
@ -1,40 +1,251 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import React, { useState } from "react";
|
||||||
import { router } from 'expo-router';
|
import {
|
||||||
import { Text } from '@/components/ui/text';
|
View,
|
||||||
import { Button } from '@/components/ui/button';
|
ScrollView,
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
Pressable,
|
||||||
import { Mail, ArrowLeft, UserPlus } from '@/lib/icons';
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
} 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 {
|
||||||
|
Mail,
|
||||||
|
Lock,
|
||||||
|
User,
|
||||||
|
Phone,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
TrianglePlanets,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Chrome,
|
||||||
|
Globe,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { useLanguageStore, AppLanguage } from "@/lib/language-store";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import { LanguageModal } from "@/components/LanguageModal";
|
||||||
|
|
||||||
export default function RegisterScreen() {
|
export default function RegisterScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
const { language, setLanguage } = useLanguageStore();
|
||||||
|
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
const { firstName, lastName, email, phone, password } = form;
|
||||||
|
if (!firstName || !lastName || !email || !phone || !password) {
|
||||||
|
toast.error("Required Fields", "Please fill in all fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepend +251 to the phone number for the API
|
||||||
|
const formattedPhone = `+251${phone}`;
|
||||||
|
|
||||||
|
const response = await api.auth.register({
|
||||||
|
body: {
|
||||||
|
...form,
|
||||||
|
phone: formattedPhone,
|
||||||
|
role: "VIEWER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Store user, access token, and refresh token
|
||||||
|
setAuth(response.user, response.accessToken, response.refreshToken);
|
||||||
|
toast.success("Account Created!", "Welcome to Yaltopia.");
|
||||||
|
|
||||||
|
nav.go("(tabs)");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(
|
||||||
|
"Registration Failed",
|
||||||
|
err.message || "Failed to create account",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateForm = (key: keyof typeof form, val: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: val }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScreenWrapper className="bg-background">
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
<KeyboardAvoidingView
|
||||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
showsVerticalScrollIndicator={false}
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
|
<ScrollView
|
||||||
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
|
className="flex-1"
|
||||||
<CardHeader>
|
contentContainerStyle={{ paddingHorizontal:16 , paddingBottom: 10 }}
|
||||||
<CardTitle className="text-lg">Create account</CardTitle>
|
keyboardShouldPersistTaps="handled"
|
||||||
<CardDescription className="mt-1">Register with the same account format as the web app.</CardDescription>
|
>
|
||||||
</CardHeader>
|
<View className="flex-row justify-end mt-4">
|
||||||
<CardContent className="gap-3">
|
<Pressable
|
||||||
<Button className="min-h-12 rounded-xl bg-primary">
|
onPress={() => setLanguageModalVisible(true)}
|
||||||
<UserPlus color="#ffffff" size={20} strokeWidth={2} />
|
className="p-2 rounded-full bg-card border border-border"
|
||||||
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
|
>
|
||||||
</Button>
|
<Globe color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
|
||||||
<Button variant="outline" className="min-h-12 rounded-xl border-border">
|
</Pressable>
|
||||||
<Text className="font-medium text-gray-700">Continue with Google</Text>
|
</View>
|
||||||
</Button>
|
|
||||||
</CardContent>
|
<View className="items-center mb-10">
|
||||||
</Card>
|
<Text
|
||||||
<Pressable onPress={() => router.push('/login')} className="mt-2">
|
variant="h2"
|
||||||
<Text className="text-center text-primary font-medium">Already have an account? Sign in</Text>
|
className="mt-6 font-bold text-foreground text-center"
|
||||||
</Pressable>
|
>
|
||||||
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
|
Create Account
|
||||||
<ArrowLeft color="#71717a" size={20} strokeWidth={2} />
|
</Text>
|
||||||
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
|
<Text variant="muted" className="mt-2 text-center">
|
||||||
</Button>
|
Join Yaltopia and start managing your business
|
||||||
</ScrollView>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-5">
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
First Name
|
||||||
|
</Text>
|
||||||
|
<View className="rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
|
<TextInput
|
||||||
|
className="text-foreground"
|
||||||
|
placeholder="John"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.firstName}
|
||||||
|
onChangeText={(v) => updateForm("firstName", v)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Last Name
|
||||||
|
</Text>
|
||||||
|
<View className="rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
|
<TextInput
|
||||||
|
className="text-foreground"
|
||||||
|
placeholder="Doe"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.lastName}
|
||||||
|
onChangeText={(v) => updateForm("lastName", v)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Email Address
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
|
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.email}
|
||||||
|
onChangeText={(v) => updateForm("email", v)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Phone Number
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
|
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<View className="flex-row items-center flex-1 ml-3">
|
||||||
|
<Text className="text-foreground text-sm font-medium">
|
||||||
|
+251{" "}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 text-foreground"
|
||||||
|
placeholder="911 234 567"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.phone}
|
||||||
|
onChangeText={(v) => updateForm("phone", v)}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
|
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground"
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.password}
|
||||||
|
onChangeText={(v) => updateForm("password", v)}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="h-10 bg-primary rounded-[10px] shadow-lg shadow-primary/30 mt-4"
|
||||||
|
onPress={handleRegister}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="text-white font-bold text-base mr-2">
|
||||||
|
Create Account
|
||||||
|
</Text>
|
||||||
|
<ArrowRight color="white" size={18} strokeWidth={2.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-10 items-center justify-center py-2"
|
||||||
|
onPress={() => nav.go("login")}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground">
|
||||||
|
Already have an account? <Text className="text-primary">Sign In</Text>
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
<LanguageModal
|
||||||
|
visible={languageModalVisible}
|
||||||
|
current={language}
|
||||||
|
onSelect={(lang) => setLanguage(lang)}
|
||||||
|
onClose={() => setLanguageModalVisible(false)}
|
||||||
|
/>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import { View, ScrollView, Pressable } from "react-native";
|
||||||
import { router } from 'expo-router';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Button } from "@/components/ui/button";
|
||||||
import { FileText, Download, ChevronRight, BarChart3 } from '@/lib/icons';
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { MOCK_REPORTS } from '@/lib/mock-data';
|
import { FileText, Download, ChevronRight, BarChart3 } from "@/lib/icons";
|
||||||
|
import { MOCK_REPORTS } from "@/lib/mock-data";
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
const PRIMARY = "#ea580c";
|
||||||
|
|
||||||
export default function ReportsScreen() {
|
export default function ReportsScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
className="flex-1 bg-[#f5f5f5]"
|
||||||
|
|
@ -24,7 +26,10 @@ export default function ReportsScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{MOCK_REPORTS.map((r) => (
|
{MOCK_REPORTS.map((r) => (
|
||||||
<Card key={r.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
<Card
|
||||||
|
key={r.id}
|
||||||
|
className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"
|
||||||
|
>
|
||||||
<Pressable>
|
<Pressable>
|
||||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
||||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
||||||
|
|
@ -32,8 +37,12 @@ export default function ReportsScreen() {
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="font-semibold text-gray-900">{r.title}</Text>
|
<Text className="font-semibold text-gray-900">{r.title}</Text>
|
||||||
<Text className="text-muted-foreground mt-0.5 text-sm">{r.period}</Text>
|
<Text className="text-muted-foreground mt-0.5 text-sm">
|
||||||
<Text className="text-muted-foreground mt-0.5 text-xs">Generated {r.generatedAt}</Text>
|
{r.period}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
Generated {r.generatedAt}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<Pressable className="rounded-lg bg-primary/10 p-2">
|
<Pressable className="rounded-lg bg-primary/10 p-2">
|
||||||
|
|
@ -46,7 +55,11 @@ export default function ReportsScreen() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4 rounded-xl border-border"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
>
|
||||||
<Text className="font-medium">Back</Text>
|
<Text className="font-medium">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import { View, ScrollView, Pressable } from "react-native";
|
||||||
import { router } from 'expo-router';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Button } from "@/components/ui/button";
|
||||||
import { Settings, Bell, Globe, ChevronRight, Info } from '@/lib/icons';
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Settings, Bell, Globe, ChevronRight, Info } from "@/lib/icons";
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
const PRIMARY = "#ea580c";
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
className="flex-1 bg-[#f5f5f5]"
|
||||||
|
|
@ -26,7 +28,7 @@ export default function SettingsScreen() {
|
||||||
<CardContent className="gap-0">
|
<CardContent className="gap-0">
|
||||||
<Pressable
|
<Pressable
|
||||||
className="flex-row items-center justify-between border-b border-border py-3"
|
className="flex-row items-center justify-between border-b border-border py-3"
|
||||||
onPress={() => router.push('/notifications/settings')}
|
onPress={() => nav.go("notifications/settings")}
|
||||||
>
|
>
|
||||||
<View className="flex-row items-center gap-3">
|
<View className="flex-row items-center gap-3">
|
||||||
<Bell color="#71717a" size={20} strokeWidth={2} />
|
<Bell color="#71717a" size={20} strokeWidth={2} />
|
||||||
|
|
@ -60,11 +62,16 @@ export default function SettingsScreen() {
|
||||||
|
|
||||||
<View className="rounded-xl border border-border bg-white p-4">
|
<View className="rounded-xl border border-border bg-white p-4">
|
||||||
<Text className="text-muted-foreground text-xs">
|
<Text className="text-muted-foreground text-xs">
|
||||||
API: Invoices, Proforma, Payments, Reports, Documents, Notifications — see swagger.json and README for integration.
|
API: Invoices, Proforma, Payments, Reports, Documents, Notifications —
|
||||||
|
see swagger.json and README for integration.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button variant="outline" className="mt-6 rounded-xl border-border" onPress={() => router.back()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-6 rounded-xl border-border"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
>
|
||||||
<Text className="font-medium">Back</Text>
|
<Text className="font-medium">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
366
app/sms-scan.tsx
Normal file
366
app/sms-scan.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
PermissionsAndroid,
|
||||||
|
Platform,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { ArrowLeft, MessageSquare, RefreshCw } from "@/lib/icons";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
|
||||||
|
// Installed via: npm install react-native-get-sms-android --legacy-peer-deps
|
||||||
|
// Android only — iOS does not permit reading SMS
|
||||||
|
let SmsAndroid: any = null;
|
||||||
|
try {
|
||||||
|
const smsModule = require("react-native-get-sms-android");
|
||||||
|
SmsAndroid = smsModule.default || smsModule;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[SMS] Module require failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keywords to match Ethiopian banking SMS messages
|
||||||
|
const BANK_KEYWORDS = ["CBE", "DashenBank", "Dashen", "127", "telebirr"];
|
||||||
|
|
||||||
|
interface SmsMessage {
|
||||||
|
_id: string;
|
||||||
|
address: string;
|
||||||
|
body: string;
|
||||||
|
date: number;
|
||||||
|
date_sent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedPayment {
|
||||||
|
smsId: string;
|
||||||
|
bank: string;
|
||||||
|
amount: string;
|
||||||
|
ref: string;
|
||||||
|
date: number;
|
||||||
|
body: string;
|
||||||
|
sender: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SmsScanScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
const [messages, setMessages] = useState<SmsMessage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [scanned, setScanned] = useState(false);
|
||||||
|
|
||||||
|
const scanSms = async () => {
|
||||||
|
if (Platform.OS !== "android") {
|
||||||
|
toast.error("Android Only", "SMS reading is only supported on Android.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SmsAndroid) {
|
||||||
|
toast.error(
|
||||||
|
"Native Module Error",
|
||||||
|
"SMS scanning requires a Development Build. Expo Go does not support this package.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request SMS permission
|
||||||
|
const granted = await PermissionsAndroid.request(
|
||||||
|
PermissionsAndroid.PERMISSIONS.READ_SMS,
|
||||||
|
{
|
||||||
|
title: "SMS Access Required",
|
||||||
|
message:
|
||||||
|
"Yaltopia needs access to read your banking SMS messages to match payments.",
|
||||||
|
buttonPositive: "Allow",
|
||||||
|
buttonNegative: "Deny",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
|
||||||
|
toast.error("Permission Denied", "SMS access was not granted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only look at messages from the past 20 minutes
|
||||||
|
const fiveMinutesAgo = Date.now() - 20 * 60 * 1000;
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
box: "inbox",
|
||||||
|
minDate: fiveMinutesAgo,
|
||||||
|
maxCount: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
SmsAndroid.list(
|
||||||
|
JSON.stringify(filter),
|
||||||
|
(fail: string) => {
|
||||||
|
console.error("[SMS] Failed to read:", fail);
|
||||||
|
toast.error("Read Failed", "Could not read SMS messages.");
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
(count: number, smsList: string) => {
|
||||||
|
const allMessages: SmsMessage[] = JSON.parse(smsList);
|
||||||
|
|
||||||
|
// Filter for banking messages only
|
||||||
|
const bankMessages = allMessages.filter((sms) => {
|
||||||
|
const body = sms.body?.toUpperCase() || "";
|
||||||
|
const address = sms.address?.toUpperCase() || "";
|
||||||
|
return BANK_KEYWORDS.some(
|
||||||
|
(kw) =>
|
||||||
|
body.includes(kw.toUpperCase()) ||
|
||||||
|
address.includes(kw.toUpperCase()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setMessages(bankMessages);
|
||||||
|
setScanned(true);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (bankMessages.length === 0) {
|
||||||
|
toast.info(
|
||||||
|
"No Matches",
|
||||||
|
"No banking SMS found in the last 5 minutes.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
"Found!",
|
||||||
|
`${bankMessages.length} banking message(s) detected.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[SMS] Error:", err);
|
||||||
|
toast.error("Error", err.message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseMessage = (sms: SmsMessage): ParsedPayment | null => {
|
||||||
|
const body = sms.body;
|
||||||
|
const addr = sms.address.toUpperCase();
|
||||||
|
const text = (body + addr).toUpperCase();
|
||||||
|
|
||||||
|
let bank = "Unknown";
|
||||||
|
let amount = "";
|
||||||
|
let ref = "";
|
||||||
|
|
||||||
|
// CBE Patterns
|
||||||
|
if (text.includes("CBE") || addr === "CBE") {
|
||||||
|
bank = "CBE";
|
||||||
|
// Pattern: "ETB 1,234.56" or "ETB 1,234"
|
||||||
|
const amtMatch = body.match(/ETB\s*([\d,.]+)/i);
|
||||||
|
if (amtMatch) amount = amtMatch[1];
|
||||||
|
|
||||||
|
// Pattern: "Ref: 123456789"
|
||||||
|
const refMatch = body.match(/Ref:?\s*(\w+)/i);
|
||||||
|
if (refMatch) ref = refMatch[1];
|
||||||
|
}
|
||||||
|
// Telebirr Patterns
|
||||||
|
else if (text.includes("TELEBIRR") || addr === "TELEBIRR") {
|
||||||
|
bank = "Telebirr";
|
||||||
|
// Pattern: "Birr 1,234.56"
|
||||||
|
const amtMatch = body.match(/Birr\s*([\d,.]+)/i);
|
||||||
|
if (amtMatch) amount = amtMatch[1];
|
||||||
|
|
||||||
|
// Pattern: "Trans ID: 12345678"
|
||||||
|
const refMatch = body.match(/Trans ID:?\s*(\w+)/i);
|
||||||
|
if (refMatch) ref = refMatch[1];
|
||||||
|
}
|
||||||
|
// Dashen Patterns
|
||||||
|
else if (text.includes("DASHEN") || addr === "DASHEN") {
|
||||||
|
bank = "Dashen";
|
||||||
|
// Pattern: "ETB 1,234.56"
|
||||||
|
const amtMatch = body.match(/ETB\s*([\d,.]+)/i);
|
||||||
|
if (amtMatch) amount = amtMatch[1];
|
||||||
|
|
||||||
|
// Pattern: "Reference No: 12345678"
|
||||||
|
const refMatch = body.match(/(?:Ref(?:erence)?(?:\s*No)?):?\s*(\w+)/i);
|
||||||
|
if (refMatch) ref = refMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bank === "Unknown") return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
smsId: sms._id,
|
||||||
|
bank,
|
||||||
|
amount,
|
||||||
|
ref,
|
||||||
|
date: sms.date,
|
||||||
|
body: sms.body,
|
||||||
|
sender: sms.address,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBankColor = (bank: string) => {
|
||||||
|
switch (bank) {
|
||||||
|
case "CBE":
|
||||||
|
return "#16a34a";
|
||||||
|
case "Telebirr":
|
||||||
|
return "#7c3aed";
|
||||||
|
case "Dashen":
|
||||||
|
return "#1d4ed8";
|
||||||
|
default:
|
||||||
|
return "#ea580c";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
>
|
||||||
|
<ArrowLeft color={isDark ? "#fff" : "#0f172a"} size={20} />
|
||||||
|
</Pressable>
|
||||||
|
<Text variant="h4" className="text-foreground font-semibold">
|
||||||
|
Scan SMS
|
||||||
|
</Text>
|
||||||
|
<View className="w-10" /> {/* Spacer */}
|
||||||
|
</View>
|
||||||
|
<View className="px-5 pt-6 pb-4">
|
||||||
|
<Text variant="h3" className="text-foreground font-bold">
|
||||||
|
Scan SMS
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="mt-1">
|
||||||
|
Finds banking messages from the last 5 minutes
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Scan Button */}
|
||||||
|
<View className="px-5 mb-4">
|
||||||
|
<Pressable
|
||||||
|
onPress={scanSms}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-12 rounded-[10px] bg-primary items-center justify-center flex-row gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw color="white" size={16} />
|
||||||
|
<Text className="text-white font-bold uppercase tracking-widest text-xs">
|
||||||
|
{scanned ? "Scan Again" : "Scan Now"}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 px-5"
|
||||||
|
contentContainerStyle={{ paddingBottom: 150 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{!scanned && !loading && (
|
||||||
|
<View className="flex-1 items-center justify-center py-20 gap-4">
|
||||||
|
<View className="bg-primary/10 p-6 rounded-[24px]">
|
||||||
|
<MessageSquare
|
||||||
|
size={40}
|
||||||
|
className="text-primary"
|
||||||
|
color="#ea580c"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text variant="muted" className="text-center px-10">
|
||||||
|
Tap "Scan Now" to search for CBE, Dashen Bank, and Telebirr
|
||||||
|
messages from the last 5 minutes.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanned && messages.length === 0 && (
|
||||||
|
<View className="flex-1 items-center justify-center py-20 gap-4">
|
||||||
|
<Text variant="muted" className="text-center">
|
||||||
|
No banking messages found in the last 5 minutes.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="gap-4">
|
||||||
|
{messages.map((sms) => {
|
||||||
|
const parsed = parseMessage(sms);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
const bankColor = getBankColor(parsed.bank);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={sms._id}
|
||||||
|
className="rounded-[16px] bg-card p-4 border border-border/40"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<View
|
||||||
|
className="px-3 py-1 rounded-full"
|
||||||
|
style={{ backgroundColor: bankColor + "15" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-bold uppercase tracking-wider"
|
||||||
|
style={{ color: bankColor }}
|
||||||
|
>
|
||||||
|
{parsed.bank}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="muted" className="text-xs">
|
||||||
|
{formatTime(sms.date)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Extracted Data */}
|
||||||
|
<View className="flex-row gap-4 mb-3">
|
||||||
|
{parsed.amount ? (
|
||||||
|
<View className="flex-1 bg-muted/30 p-2 rounded-[8px]">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] uppercase font-bold"
|
||||||
|
>
|
||||||
|
Amount
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground font-bold text-sm">
|
||||||
|
ETB {parsed.amount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{parsed.ref ? (
|
||||||
|
<View className="flex-1 bg-muted/30 p-2 rounded-[8px]">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] uppercase font-bold"
|
||||||
|
>
|
||||||
|
Reference
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-foreground font-bold text-sm"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{parsed.ref}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-foreground/70 text-xs leading-relaxed italic">
|
||||||
|
"{sms.body}"
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
app/terms.tsx
Normal file
86
app/terms.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { View, ScrollView } from "react-native";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export default function TermsScreen() {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader title="Terms of Service" showBack />
|
||||||
|
|
||||||
|
<ScrollView className="flex-1 px-5 pt-4" showsVerticalScrollIndicator={false}>
|
||||||
|
<Text variant="h4" className="text-foreground mb-4">
|
||||||
|
Terms of Service
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
Last updated: March 10, 2026
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
1. Acceptance of Terms
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
By accessing and using our mobile application, you accept and agree to be bound by the terms and provision of this agreement.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
2. Use of Service
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
Our service is provided "as is" and "as available" without warranties of any kind. You agree to use the service at your own risk.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
3. User Accounts
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
When you create an account with us, you must provide information that is accurate, complete, and current at all times.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
4. Prohibited Uses
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
You may not use our service:
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• For any unlawful purpose or to solicit others to perform unlawful acts
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• To violate any international, federal, provincial, or state regulations, rules, laws, or local ordinances
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
• To infringe upon or violate our intellectual property rights or the intellectual property rights of others
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
5. Termination
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We may terminate or suspend your account and bar access to the service immediately, without prior notice or liability, under our sole discretion.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
6. Limitation of Liability
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
In no event shall our company, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
7. Changes to Terms
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
8. Contact Information
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
If you have any questions about these Terms of Service, please contact us at terms@example.com.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
app/user/create.tsx
Normal file
258
app/user/create.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
useColorScheme,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Lock,
|
||||||
|
User,
|
||||||
|
Phone,
|
||||||
|
ArrowRight,
|
||||||
|
ShieldCheck,
|
||||||
|
ChevronDown,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
|
||||||
|
const ROLES = ["VIEWER", "EMPLOYEE", "ACCOUNTANT", "CUSTOMER_SERVICE"];
|
||||||
|
|
||||||
|
export default function CreateUserScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
password: "",
|
||||||
|
role: "VIEWER",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showRolePicker, setShowRolePicker] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const { firstName, lastName, email, phone, password, role } = form;
|
||||||
|
if (!firstName || !lastName || !email || !phone || !password) {
|
||||||
|
toast.error("Required Fields", "Please fill in all fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepend +251 if not present
|
||||||
|
const formattedPhone = phone.startsWith("+") ? phone : `+251${phone}`;
|
||||||
|
|
||||||
|
await api.users.create({
|
||||||
|
body: {
|
||||||
|
...form,
|
||||||
|
phone: formattedPhone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
"User Created!",
|
||||||
|
`${firstName} has been added to the system.`,
|
||||||
|
);
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(
|
||||||
|
"Creation Failed",
|
||||||
|
err.message || "Failed to create user account",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateForm = (key: keyof typeof form, val: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: val }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Add New User" showBack />
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 24, paddingBottom: 60 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View className="mb-8">
|
||||||
|
<Text variant="h3" className="font-bold text-foreground">
|
||||||
|
User Details
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="mt-1">
|
||||||
|
Configure credentials and system access
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-5">
|
||||||
|
{/* Identity Group */}
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
First Name
|
||||||
|
</Text>
|
||||||
|
<View className="rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
|
<TextInput
|
||||||
|
className="text-foreground"
|
||||||
|
placeholder="First Name"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.firstName}
|
||||||
|
onChangeText={(v) => updateForm("firstName", v)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Last Name
|
||||||
|
</Text>
|
||||||
|
<View className="rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
|
<TextInput
|
||||||
|
className="text-foreground"
|
||||||
|
placeholder="Last Name"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.lastName}
|
||||||
|
onChangeText={(v) => updateForm("lastName", v)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Email Address
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
|
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground"
|
||||||
|
placeholder="email@company.com"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.email}
|
||||||
|
onChangeText={(v) => updateForm("email", v)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Phone Number
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
|
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground"
|
||||||
|
placeholder="911 234 567"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.phone}
|
||||||
|
onChangeText={(v) => updateForm("phone", v)}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Role - Dropdown */}
|
||||||
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
System Role
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowRolePicker(true)}
|
||||||
|
className="flex-row items-center rounded-xl px-4 border border-border h-12"
|
||||||
|
>
|
||||||
|
<ShieldCheck size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<Text className="flex-1 ml-3 text-foreground text-sm font-medium">
|
||||||
|
{form.role}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Initial Password
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
|
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-foreground"
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={form.password}
|
||||||
|
onChangeText={(v) => updateForm("password", v)}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="h-14 bg-primary rounded-[10px] shadow-lg shadow-primary/30 mt-6"
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="text-white font-bold text-base mr-2">
|
||||||
|
Create User
|
||||||
|
</Text>
|
||||||
|
<ArrowRight color="white" size={18} strokeWidth={2.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showRolePicker}
|
||||||
|
onClose={() => setShowRolePicker(false)}
|
||||||
|
title="Select System Role"
|
||||||
|
>
|
||||||
|
{ROLES.map((role) => (
|
||||||
|
<SelectOption
|
||||||
|
key={role}
|
||||||
|
label={role}
|
||||||
|
value={role}
|
||||||
|
selected={form.role === role}
|
||||||
|
onSelect={(v) => {
|
||||||
|
updateForm("role", v);
|
||||||
|
setShowRolePicker(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
assets/fonts/DMSans-Black.ttf
Normal file
BIN
assets/fonts/DMSans-Black.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Bold.ttf
Normal file
BIN
assets/fonts/DMSans-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-ExtraBold.ttf
Normal file
BIN
assets/fonts/DMSans-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-ExtraLight.ttf
Normal file
BIN
assets/fonts/DMSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Light.ttf
Normal file
BIN
assets/fonts/DMSans-Light.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Medium.ttf
Normal file
BIN
assets/fonts/DMSans-Medium.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Regular.ttf
Normal file
BIN
assets/fonts/DMSans-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-SemiBold.ttf
Normal file
BIN
assets/fonts/DMSans-SemiBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Thin.ttf
Normal file
BIN
assets/fonts/DMSans-Thin.ttf
Normal file
Binary file not shown.
BIN
assets/google-logo.png
Normal file
BIN
assets/google-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
|
|
@ -2,8 +2,9 @@ module.exports = function (api) {
|
||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: [
|
presets: [
|
||||||
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
'nativewind/babel',
|
"nativewind/babel",
|
||||||
],
|
],
|
||||||
|
plugins: ["react-native-reanimated/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
160
components/CalendarGrid.tsx
Normal file
160
components/CalendarGrid.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, Pressable, StyleSheet } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { ArrowLeft, ArrowRight, ChevronDown } from "@/lib/icons";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
const MONTHS = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||||
|
|
||||||
|
interface CalendarGridProps {
|
||||||
|
onSelect: (v: string) => void;
|
||||||
|
selectedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarGrid({ onSelect, selectedDate }: CalendarGridProps) {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const initialDate = selectedDate ? new Date(selectedDate) : new Date();
|
||||||
|
const [viewDate, setViewDate] = useState(
|
||||||
|
new Date(initialDate.getFullYear(), initialDate.getMonth(), 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const year = viewDate.getFullYear();
|
||||||
|
const month = viewDate.getMonth();
|
||||||
|
|
||||||
|
// Days in current month
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
// Starting day of the week (0-6), where 0 is Sunday
|
||||||
|
let firstDayOfMonth = new Date(year, month, 1).getDay();
|
||||||
|
// Adjust for Monday start: Mon=0 ... Sun=6
|
||||||
|
firstDayOfMonth = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
|
||||||
|
|
||||||
|
// Days in previous month to fill the start
|
||||||
|
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
const changeMonth = (delta: number) => {
|
||||||
|
setViewDate(new Date(year, month + delta, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
// Fill previous month days (muted)
|
||||||
|
for (let i = firstDayOfMonth - 1; i >= 0; i--) {
|
||||||
|
days.push({
|
||||||
|
date: new Date(year, month - 1, prevMonthLastDay - i),
|
||||||
|
currentMonth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill current month days
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push({
|
||||||
|
date: new Date(year, month, i),
|
||||||
|
currentMonth: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill next month days (muted) to complete the grid (usually 42 cells for 6 weeks)
|
||||||
|
const remaining = 42 - days.length;
|
||||||
|
for (let i = 1; i <= remaining; i++) {
|
||||||
|
days.push({
|
||||||
|
date: new Date(year, month + 1, i),
|
||||||
|
currentMonth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-card px-2 pb-6">
|
||||||
|
<View className="flex-row justify-between items-center mb-10 mt-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => changeMonth(-1)}
|
||||||
|
className="h-12 w-12 bg-white rounded-[12px] items-center justify-center border border-border"
|
||||||
|
style={isDark ? { backgroundColor: "#1e1e1e" } : undefined}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} color="#64748b" strokeWidth={2} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Text className="text-foreground text-base font-medium tracking-tight">
|
||||||
|
{MONTHS[month]} {year}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => changeMonth(1)}
|
||||||
|
className="h-12 w-12 bg-white rounded-[12px] items-center justify-center border border-border"
|
||||||
|
style={isDark ? { backgroundColor: "#1e1e1e" } : undefined}
|
||||||
|
>
|
||||||
|
<ArrowRight size={18} color="#64748b" strokeWidth={2} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* WeekDays Header: Mo Tu We Th Fr Sa Su */}
|
||||||
|
<View className="flex-row mb-6">
|
||||||
|
{WEEKDAYS.map((day, idx) => (
|
||||||
|
<View key={idx} className="w-[14.28%] items-center">
|
||||||
|
<Text className="text-[13px] font-semibold text-slate-400 opacity-80">
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<View className="flex-row flex-wrap">
|
||||||
|
{days.map((item, i) => {
|
||||||
|
const d = item.date;
|
||||||
|
const iso = d.toISOString().split("T")[0];
|
||||||
|
const isSelected = iso === selectedDate && item.currentMonth;
|
||||||
|
const isToday = iso === new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
className="w-[14.28%] aspect-square items-center justify-center mb-1"
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onSelect(iso)}
|
||||||
|
className={`w-11 h-11 items-center justify-center rounded-full ${
|
||||||
|
isSelected ? "bg-primary" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-[15px] ${
|
||||||
|
isSelected
|
||||||
|
? "text-white font-bold"
|
||||||
|
: item.currentMonth
|
||||||
|
? "text-foreground font-medium"
|
||||||
|
: "text-slate-300 font-medium"
|
||||||
|
} ${isToday && !isSelected ? "text-primary" : ""}`}
|
||||||
|
>
|
||||||
|
{d.getDate()}
|
||||||
|
</Text>
|
||||||
|
{isToday && !isSelected && (
|
||||||
|
<View className="absolute bottom-1 w-2 h-2 bg-primary rounded-full" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
components/EmptyState.tsx
Normal file
78
components/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import React from "react";
|
||||||
|
import { View, Pressable, useColorScheme } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
hint?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onActionPress?: () => void;
|
||||||
|
previewLines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
hint,
|
||||||
|
actionLabel,
|
||||||
|
onActionPress,
|
||||||
|
previewLines = 3,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
const scheme = useColorScheme();
|
||||||
|
const isDark = scheme === "dark";
|
||||||
|
|
||||||
|
const dashColor = isDark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.14)";
|
||||||
|
const lineFill = isDark ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="w-full">
|
||||||
|
<View className="bg-card border border-border/20 rounded-2xl p-5">
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text variant="h3" className="text-foreground font-bold">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{!!description && (
|
||||||
|
<Text variant="muted" className="mt-1">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="rounded-xl p-4"
|
||||||
|
style={{ borderWidth: 1, borderStyle: "dashed", borderColor: dashColor }}
|
||||||
|
>
|
||||||
|
<View className="gap-3">
|
||||||
|
{Array.from({ length: Math.max(1, previewLines) }).map((_, idx) => (
|
||||||
|
<View
|
||||||
|
key={idx}
|
||||||
|
className="rounded-md"
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
width: `${idx === 0 ? 90 : idx === 1 ? 72 : 80}%`,
|
||||||
|
backgroundColor: lineFill,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!!hint && (
|
||||||
|
<Text variant="muted" className="mt-4">
|
||||||
|
{hint}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!!actionLabel && !!onActionPress && (
|
||||||
|
<Pressable
|
||||||
|
onPress={onActionPress}
|
||||||
|
className="mt-5 bg-primary h-10 rounded-[6px] items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-sm font-bold">{actionLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/LanguageModal.tsx
Normal file
39
components/LanguageModal.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import React from "react";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { AppLanguage } from "@/lib/language-store";
|
||||||
|
|
||||||
|
interface LanguageModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
current: AppLanguage;
|
||||||
|
onSelect: (lang: AppLanguage) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageModal({
|
||||||
|
visible,
|
||||||
|
current,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: LanguageModalProps) {
|
||||||
|
const languages = [
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "am", label: "Amharic" },
|
||||||
|
] as { value: AppLanguage; label: string }[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PickerModal visible={visible} onClose={onClose} title="Language">
|
||||||
|
{languages.map((opt) => (
|
||||||
|
<SelectOption
|
||||||
|
key={opt.value}
|
||||||
|
label={opt.label}
|
||||||
|
value={opt.value}
|
||||||
|
selected={current === opt.value}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onSelect(v as AppLanguage);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
components/PickerModal.tsx
Normal file
123
components/PickerModal.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Dimensions,
|
||||||
|
StyleSheet,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { X, Check } from "@/lib/icons";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
interface PickerModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PickerModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: PickerModalProps) {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
|
||||||
|
<View className="flex-1 justify-end">
|
||||||
|
<Pressable
|
||||||
|
className="bg-card rounded-t-[36px] overflow-hidden border-t border-border/5"
|
||||||
|
style={{
|
||||||
|
maxHeight: SCREEN_HEIGHT * 0.8,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: -10 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 20,
|
||||||
|
}}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
<View className="items-center pt-3 pb-1">
|
||||||
|
<View className="w-10 h-1 bg-border/20 rounded-full" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View className="px-6 pb-4 pt-2 flex-row justify-between items-center">
|
||||||
|
<View className="w-10" />
|
||||||
|
<Text variant="h4" className="text-foreground tracking-tight">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
className="h-10 w-10 bg-secondary/80 rounded-full items-center justify-center border border-border/10"
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
size={16}
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="p-5 pt-0"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectOption({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onSelect(value)}
|
||||||
|
className={`flex-row items-center justify-between p-4 mb-3 rounded-[6px] border ${
|
||||||
|
selected
|
||||||
|
? "bg-primary/5 border-primary/20"
|
||||||
|
: "bg-secondary/20 border-border/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`font-bold text-[15px] ${selected ? "text-primary" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
className={`h-5 w-5 rounded-full items-center justify-center ${selected ? "bg-primary" : "border border-border/20"}`}
|
||||||
|
>
|
||||||
|
{selected && <Check size={12} color="white" strokeWidth={4} />}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
components/ScreenWrapper.tsx
Normal file
40
components/ScreenWrapper.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
SafeAreaView,
|
||||||
|
Platform,
|
||||||
|
StatusBar,
|
||||||
|
useColorScheme,
|
||||||
|
} from "react-native";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ScreenWrapperProps extends ViewProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
withSafeArea?: boolean;
|
||||||
|
fixedHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenWrapper({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
withSafeArea = true,
|
||||||
|
fixedHeader = false,
|
||||||
|
...props
|
||||||
|
}: ScreenWrapperProps & { containerClassName?: string }) {
|
||||||
|
const Container = withSafeArea ? SafeAreaView : View;
|
||||||
|
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={cn("flex-1 bg-background pt-4", containerClassName)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<StatusBar barStyle={isDark ? "light-content" : "dark-content"} />
|
||||||
|
<Container className={cn("flex-1", className)}>{children}</Container>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
components/ShadowWrapper.tsx
Normal file
59
components/ShadowWrapper.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import React from "react";
|
||||||
|
import { View, ViewProps, Platform } from "react-native";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
interface ShadowWrapperProps extends ViewProps {
|
||||||
|
level?: "none" | "xs" | "sm" | "md" | "lg" | "xl";
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShadowWrapper({
|
||||||
|
level = "md",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ShadowWrapperProps) {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const shadowClasses = {
|
||||||
|
none: "",
|
||||||
|
xs: isDark ? "" : "shadow-sm shadow-slate-200/30",
|
||||||
|
sm: isDark ? "" : "shadow-sm shadow-slate-200/50",
|
||||||
|
md: isDark ? "" : "shadow-md shadow-slate-200/60",
|
||||||
|
lg: isDark ? "" : "shadow-xl shadow-slate-200/70",
|
||||||
|
xl: isDark ? "" : "shadow-2xl shadow-slate-300/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
const elevations = {
|
||||||
|
none: 0,
|
||||||
|
xs: 1,
|
||||||
|
sm: 2,
|
||||||
|
md: 4,
|
||||||
|
lg: 8,
|
||||||
|
xl: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Android elevation needs a background color to cast a shadow
|
||||||
|
const hasBgClass = className?.includes("bg-");
|
||||||
|
const androidBaseStyle =
|
||||||
|
Platform.OS === "android"
|
||||||
|
? {
|
||||||
|
elevation: isDark ? 0 : elevations[level],
|
||||||
|
backgroundColor: hasBgClass || isDark ? undefined : "white",
|
||||||
|
shadowColor: "#000",
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={cn(shadowClasses[level], className)}
|
||||||
|
style={[androidBaseStyle, props.style as any]}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
components/StandardHeader.tsx
Normal file
111
components/StandardHeader.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { View, Image, Pressable, useColorScheme } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { ArrowLeft, Bell, Settings, Info } from "@/lib/icons";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
|
||||||
|
interface StandardHeaderProps {
|
||||||
|
title?: string;
|
||||||
|
showBack?: boolean;
|
||||||
|
rightAction?: "notificationsSettings" | "companyInfo";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StandardHeader({
|
||||||
|
title,
|
||||||
|
showBack,
|
||||||
|
rightAction,
|
||||||
|
}: StandardHeaderProps) {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
|
||||||
|
// Fallback avatar if user has no profile picture
|
||||||
|
const avatarUri =
|
||||||
|
user?.avatar ||
|
||||||
|
"https://ui-avatars.com/api/?name=" +
|
||||||
|
encodeURIComponent(`${user?.firstName} ${user?.lastName}`) +
|
||||||
|
"&background=ea580c&color=fff";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-5 pt-4 pb-2 flex-row justify-between items-center bg-background">
|
||||||
|
<View className="flex-1 flex-row items-center gap-3">
|
||||||
|
{showBack && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
>
|
||||||
|
<ArrowLeft color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!title ? (
|
||||||
|
<View className="flex-row items-center gap-3 ml-1">
|
||||||
|
<View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.go("profile")}
|
||||||
|
className="h-[40px] w-[40px] rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image source={{ uri: avatarUri }} className="h-full w-full" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] uppercase tracking-widest font-bold"
|
||||||
|
>
|
||||||
|
Welcome back,
|
||||||
|
</Text>
|
||||||
|
<Text variant="h4" className="text-foreground leading-tight">
|
||||||
|
{user?.firstName + " " + user?.lastName || "User"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="flex-1 items-center ">
|
||||||
|
<Text variant="h4" className="text-foreground font-semibold">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!title && (
|
||||||
|
<Pressable
|
||||||
|
className="rounded-full p-2.5 border border-border"
|
||||||
|
onPress={() => nav.go("notifications/index")}
|
||||||
|
>
|
||||||
|
<Bell
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{title && (
|
||||||
|
<View className="w-10 items-end">
|
||||||
|
{rightAction === "notificationsSettings" ? (
|
||||||
|
<Pressable
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
onPress={() => nav.go("notifications/settings")}
|
||||||
|
>
|
||||||
|
<Settings color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
|
||||||
|
</Pressable>
|
||||||
|
) : rightAction === "companyInfo" ? (
|
||||||
|
<Pressable
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
onPress={() => nav.go("company-details")}
|
||||||
|
>
|
||||||
|
<Info color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<View className="w-0" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
components/ThemeModal.tsx
Normal file
41
components/ThemeModal.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from "react";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
|
||||||
|
type AppTheme = (typeof THEME_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
|
interface ThemeModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
current: AppTheme;
|
||||||
|
onSelect: (theme: AppTheme) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_OPTIONS = [
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
{ value: "dark", label: "Dark" },
|
||||||
|
{ value: "system", label: "System Default" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ThemeModal({
|
||||||
|
visible,
|
||||||
|
current,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: ThemeModalProps) {
|
||||||
|
return (
|
||||||
|
<PickerModal visible={visible} onClose={onClose} title="Appearance">
|
||||||
|
{THEME_OPTIONS.map((opt) => (
|
||||||
|
<SelectOption
|
||||||
|
key={opt.value}
|
||||||
|
label={opt.label}
|
||||||
|
value={opt.value}
|
||||||
|
selected={current === opt.value}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onSelect(v as "light" | "dark" | "system");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
components/Toast.tsx
Normal file
146
components/Toast.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { View, StyleSheet, Dimensions } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { useToast, ToastType } from "@/lib/toast-store";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Lightbulb,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
withTiming,
|
||||||
|
runOnJS,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.4;
|
||||||
|
|
||||||
|
const TOAST_VARIANTS: Record<
|
||||||
|
ToastType,
|
||||||
|
{
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
success: {
|
||||||
|
bg: "rgba(34, 197, 94, 0.05)",
|
||||||
|
border: "#22c55e",
|
||||||
|
icon: <CheckCircle2 size={24} color="#22c55e" />,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: "rgba(14, 165, 233, 0.05)",
|
||||||
|
border: "#0ea5e9",
|
||||||
|
icon: <Lightbulb size={24} color="#0ea5e9" />,
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: "rgba(245, 158, 11, 0.05)",
|
||||||
|
border: "#f59e0b",
|
||||||
|
icon: <AlertTriangle size={24} color="#f59e0b" />,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: "rgba(239, 68, 68, 0.05)",
|
||||||
|
border: "#ef4444",
|
||||||
|
icon: <AlertCircle size={24} color="#ef4444" />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Toast() {
|
||||||
|
const { visible, type, title, message, hide, duration } = useToast();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
const translateY = useSharedValue(-100);
|
||||||
|
const translateX = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
opacity.value = withTiming(1, { duration: 300 });
|
||||||
|
translateY.value = withSpring(0, { damping: 15, stiffness: 100 });
|
||||||
|
translateX.value = 0;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
handleHide();
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleHide = () => {
|
||||||
|
opacity.value = withTiming(0, { duration: 300 });
|
||||||
|
translateY.value = withTiming(-100, { duration: 300 }, () => {
|
||||||
|
runOnJS(hide)();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const swipeGesture = Gesture.Pan()
|
||||||
|
.onUpdate((event) => {
|
||||||
|
translateX.value = event.translationX;
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
if (Math.abs(event.translationX) > SWIPE_THRESHOLD) {
|
||||||
|
translateX.value = withTiming(
|
||||||
|
event.translationX > 0 ? SCREEN_WIDTH : -SCREEN_WIDTH,
|
||||||
|
{ duration: 200 },
|
||||||
|
() => runOnJS(handleHide)(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
translateX.value = withSpring(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
transform: [
|
||||||
|
{ translateY: translateY.value },
|
||||||
|
{ translateX: translateX.value },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const variant = TOAST_VARIANTS[type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={swipeGesture}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
top: insets.top + 10,
|
||||||
|
borderColor: variant.border,
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
]}
|
||||||
|
className="border-2 rounded-2xl shadow-xl bg-background dark:bg-background dark:shadow-none flex-row items-center p-4 pr-10"
|
||||||
|
>
|
||||||
|
<View className="mr-4">{variant.icon}</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="font-bold text-[14px] text-foreground mb-1 leading-tight">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-xs font-medium leading-normal">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,91 +1,102 @@
|
||||||
import { TextClassContext } from '@/components/ui/text';
|
import { TextClassContext } from "@/components/ui/text";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { Platform, Pressable } from 'react-native';
|
import { Platform, Pressable } from "react-native";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
cn(
|
cn(
|
||||||
'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
|
"group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none",
|
||||||
Platform.select({
|
Platform.select({
|
||||||
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: cn(
|
default: cn(
|
||||||
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
|
"bg-primary active:bg-primary/90 shadow-sm dark:shadow-none shadow-black/5",
|
||||||
Platform.select({ web: 'hover:bg-primary/90' })
|
Platform.select({ web: "hover:bg-primary/90" }),
|
||||||
),
|
),
|
||||||
destructive: cn(
|
destructive: cn(
|
||||||
'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5',
|
"bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm dark:shadow-none shadow-black/5",
|
||||||
Platform.select({
|
Platform.select({
|
||||||
web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
|
web: "hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
outline: cn(
|
outline: cn(
|
||||||
'border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5',
|
"border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm dark:shadow-none shadow-black/5",
|
||||||
Platform.select({
|
Platform.select({
|
||||||
web: 'hover:bg-accent dark:hover:bg-input/50',
|
web: "hover:bg-accent dark:hover:bg-input/50",
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
secondary: cn(
|
secondary: cn(
|
||||||
'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5',
|
"bg-secondary active:bg-secondary/80 shadow-sm dark:shadow-none shadow-black/5",
|
||||||
Platform.select({ web: 'hover:bg-secondary/80' })
|
Platform.select({ web: "hover:bg-secondary/80" }),
|
||||||
),
|
),
|
||||||
ghost: cn(
|
ghost: cn(
|
||||||
'active:bg-accent dark:active:bg-accent/50',
|
"active:bg-accent dark:active:bg-accent/50",
|
||||||
Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' })
|
Platform.select({ web: "hover:bg-accent dark:hover:bg-accent/50" }),
|
||||||
),
|
),
|
||||||
link: '',
|
link: "",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),
|
default: cn(
|
||||||
sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })),
|
"h-10 px-4 py-2 sm:h-9",
|
||||||
lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })),
|
Platform.select({ web: "has-[>svg]:px-3" }),
|
||||||
icon: 'h-10 w-10 sm:h-9 sm:w-9',
|
),
|
||||||
|
sm: cn(
|
||||||
|
"h-9 gap-1.5 rounded-md px-3 sm:h-8",
|
||||||
|
Platform.select({ web: "has-[>svg]:px-2.5" }),
|
||||||
|
),
|
||||||
|
lg: cn(
|
||||||
|
"h-11 rounded-md px-6 sm:h-10",
|
||||||
|
Platform.select({ web: "has-[>svg]:px-4" }),
|
||||||
|
),
|
||||||
|
icon: "h-10 w-10 sm:h-9 sm:w-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const buttonTextVariants = cva(
|
const buttonTextVariants = cva(
|
||||||
cn(
|
cn(
|
||||||
'text-foreground text-sm font-medium',
|
"text-foreground text-sm font-medium",
|
||||||
Platform.select({ web: 'pointer-events-none transition-colors' })
|
Platform.select({ web: "pointer-events-none transition-colors" }),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'text-primary-foreground',
|
default: "text-primary-foreground",
|
||||||
destructive: 'text-white',
|
destructive: "text-white",
|
||||||
outline: cn(
|
outline: cn(
|
||||||
'group-active:text-accent-foreground',
|
"group-active:text-accent-foreground",
|
||||||
Platform.select({ web: 'group-hover:text-accent-foreground' })
|
Platform.select({ web: "group-hover:text-accent-foreground" }),
|
||||||
),
|
),
|
||||||
secondary: 'text-secondary-foreground',
|
secondary: "text-secondary-foreground",
|
||||||
ghost: 'group-active:text-accent-foreground',
|
ghost: "group-active:text-accent-foreground",
|
||||||
link: cn(
|
link: cn(
|
||||||
'text-primary group-active:underline',
|
"text-primary group-active:underline",
|
||||||
Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })
|
Platform.select({
|
||||||
|
web: "underline-offset-4 hover:underline group-hover:underline",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: '',
|
default: "",
|
||||||
sm: '',
|
sm: "",
|
||||||
lg: '',
|
lg: "",
|
||||||
icon: '',
|
icon: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
type ButtonProps = React.ComponentProps<typeof Pressable> &
|
type ButtonProps = React.ComponentProps<typeof Pressable> &
|
||||||
|
|
@ -96,7 +107,11 @@ function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
|
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
|
||||||
<Pressable
|
<Pressable
|
||||||
className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}
|
className={cn(
|
||||||
|
props.disabled && "opacity-50",
|
||||||
|
buttonVariants({ variant, size }),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
role="button"
|
role="button"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,26 @@
|
||||||
import { Text, TextClassContext } from '@/components/ui/text';
|
import { Text, TextClassContext } from "@/components/ui/text";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
import { View, type ViewProps } from 'react-native';
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { ShadowWrapper } from "../ShadowWrapper";
|
||||||
|
|
||||||
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||||
return (
|
return (
|
||||||
<TextClassContext.Provider value="text-card-foreground">
|
<TextClassContext.Provider value="text-card-foreground">
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn("bg-card flex flex-col border border-border gap-4 rounded-xl ", className)}
|
||||||
'bg-card border-border flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-black/5',
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</TextClassContext.Provider>
|
</TextClassContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
function CardHeader({
|
||||||
return <View className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
|
className,
|
||||||
|
...props
|
||||||
|
}: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return (
|
||||||
|
<View className={cn("flex flex-col gap-1.5 px-6", className)} {...props} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({
|
function CardTitle({
|
||||||
|
|
@ -28,7 +31,7 @@ function CardTitle({
|
||||||
<Text
|
<Text
|
||||||
role="heading"
|
role="heading"
|
||||||
aria-level={3}
|
aria-level={3}
|
||||||
className={cn('font-semibold leading-none', className)}
|
className={cn("font-semibold leading-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -38,15 +41,38 @@ function CardDescription({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
||||||
return <Text className={cn('text-muted-foreground text-sm', className)} {...props} />;
|
return (
|
||||||
|
<Text
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
function CardContent({
|
||||||
return <View className={cn('px-6', className)} {...props} />;
|
className,
|
||||||
|
...props
|
||||||
|
}: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return <View className={cn("px-4", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
function CardFooter({
|
||||||
return <View className={cn('flex flex-row items-center px-6', className)} {...props} />;
|
className,
|
||||||
|
...props
|
||||||
|
}: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={cn("flex flex-row items-center px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
export {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,73 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
import * as Slot from '@rn-primitives/slot';
|
import * as Slot from "@rn-primitives/slot";
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { Platform, Text as RNText, type Role } from 'react-native';
|
import { Platform, Text as RNText, type Role } from "react-native";
|
||||||
|
import { useColorScheme } from "react-native";
|
||||||
|
import { getMutedColor } from "@/lib/colors";
|
||||||
|
|
||||||
const textVariants = cva(
|
const textVariants = cva(
|
||||||
cn(
|
cn(
|
||||||
'text-foreground text-base',
|
"text-foreground text-base font-sans",
|
||||||
Platform.select({
|
Platform.select({
|
||||||
web: 'select-text',
|
web: "select-text",
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: '',
|
default: "",
|
||||||
h1: cn(
|
h1: cn(
|
||||||
'text-center text-4xl font-extrabold tracking-tight',
|
"text-center text-4xl font-extrabold tracking-tight",
|
||||||
Platform.select({ web: 'scroll-m-20 text-balance' })
|
Platform.select({ web: "scroll-m-20 text-balance" }),
|
||||||
),
|
),
|
||||||
h2: cn(
|
h2: cn(
|
||||||
'border-border border-b pb-2 text-3xl font-semibold tracking-tight',
|
"border-border border-b pb-2 text-3xl font-semibold tracking-tight",
|
||||||
Platform.select({ web: 'scroll-m-20 first:mt-0' })
|
Platform.select({ web: "scroll-m-20 first:mt-0" }),
|
||||||
),
|
),
|
||||||
h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
|
h3: cn(
|
||||||
h4: cn('text-xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
|
"text-2xl font-semibold tracking-tight",
|
||||||
p: 'mt-3 leading-7 sm:mt-6',
|
Platform.select({ web: "scroll-m-20" }),
|
||||||
blockquote: 'mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6',
|
),
|
||||||
|
h4: cn(
|
||||||
|
"text-xl font-semibold tracking-tight",
|
||||||
|
Platform.select({ web: "scroll-m-20" }),
|
||||||
|
),
|
||||||
|
p: "mt-3 leading-7 sm:mt-6",
|
||||||
|
blockquote: "mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6",
|
||||||
code: cn(
|
code: cn(
|
||||||
'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold'
|
"bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
|
||||||
),
|
),
|
||||||
lead: 'text-muted-foreground text-xl',
|
lead: "text-muted-foreground text-xl",
|
||||||
large: 'text-lg font-semibold',
|
large: "text-lg font-semibold",
|
||||||
small: 'text-sm font-medium leading-none',
|
small: "text-sm font-medium leading-none",
|
||||||
muted: 'text-muted-foreground text-sm',
|
muted: "text-muted-foreground text-sm",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
type TextVariantProps = VariantProps<typeof textVariants>;
|
type TextVariantProps = VariantProps<typeof textVariants>;
|
||||||
|
|
||||||
type TextVariant = NonNullable<TextVariantProps['variant']>;
|
type TextVariant = NonNullable<TextVariantProps["variant"]>;
|
||||||
|
|
||||||
const ROLE: Partial<Record<TextVariant, Role>> = {
|
const ROLE: Partial<Record<TextVariant, Role>> = {
|
||||||
h1: 'heading',
|
h1: "heading",
|
||||||
h2: 'heading',
|
h2: "heading",
|
||||||
h3: 'heading',
|
h3: "heading",
|
||||||
h4: 'heading',
|
h4: "heading",
|
||||||
blockquote: Platform.select({ web: 'blockquote' as Role }),
|
blockquote: Platform.select({ web: "blockquote" as Role }),
|
||||||
code: Platform.select({ web: 'code' as Role }),
|
code: Platform.select({ web: "code" as Role }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
|
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
|
||||||
h1: '1',
|
h1: "1",
|
||||||
h2: '2',
|
h2: "2",
|
||||||
h3: '3',
|
h3: "3",
|
||||||
h4: '4',
|
h4: "4",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TextClassContext = React.createContext<string | undefined>(undefined);
|
const TextClassContext = React.createContext<string | undefined>(undefined);
|
||||||
|
|
@ -67,7 +75,7 @@ const TextClassContext = React.createContext<string | undefined>(undefined);
|
||||||
function Text({
|
function Text({
|
||||||
className,
|
className,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
variant = 'default',
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof RNText> &
|
}: React.ComponentProps<typeof RNText> &
|
||||||
TextVariantProps &
|
TextVariantProps &
|
||||||
|
|
@ -76,11 +84,14 @@ function Text({
|
||||||
}) {
|
}) {
|
||||||
const textClass = React.useContext(TextClassContext);
|
const textClass = React.useContext(TextClassContext);
|
||||||
const Component = asChild ? Slot.Text : RNText;
|
const Component = asChild ? Slot.Text : RNText;
|
||||||
|
const isDark = useColorScheme() === 'dark';
|
||||||
|
const mutedStyle = variant === "muted" ? { color: getMutedColor(isDark) } : undefined;
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
className={cn(textVariants({ variant }), textClass, className)}
|
className={cn(textVariants({ variant }), textClass, className)}
|
||||||
role={variant ? ROLE[variant] : undefined}
|
role={variant ? ROLE[variant] : undefined}
|
||||||
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
|
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
|
||||||
|
style={mutedStyle}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
24
eas.json
Normal file
24
eas.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 18.0.5",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
global.css
86
global.css
|
|
@ -4,57 +4,47 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 255,255,255;
|
||||||
--foreground: 0 0% 3.9%;
|
--foreground: 37,22,21;
|
||||||
--card: 0 0% 100%;
|
--card: 255,255,255;
|
||||||
--card-foreground: 0 0% 3.9%;
|
--card-foreground: 37,22,21;
|
||||||
--popover: 0 0% 100%;
|
--popover: 255,249,244;
|
||||||
--popover-foreground: 0 0% 3.9%;
|
--popover-foreground: 37,22,21;
|
||||||
--primary: 24 90% 48%;
|
--primary: 228, 98, 18;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 255,249,244;
|
||||||
--secondary: 0 0% 96.1%;
|
--secondary: 255,226,216;
|
||||||
--secondary-foreground: 0 0% 9%;
|
--secondary-foreground: 66,37,32;
|
||||||
--muted: 0 0% 96.1%;
|
--muted: 255,234,227;
|
||||||
--muted-foreground: 0 0% 45.1%;
|
--muted-foreground: 118,93,88;
|
||||||
--accent: 0 0% 96.1%;
|
--accent: 255,222,207;
|
||||||
--accent-foreground: 0 0% 9%;
|
--accent-foreground: 66,37,32;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 239,67,94;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 255,249,244;
|
||||||
--border: 0 0% 89.8%;
|
--border: 237,213,209;
|
||||||
--input: 0 0% 89.8%;
|
--input: 244,206,198;
|
||||||
--ring: 0 0% 63%;
|
--ring: 233,87,82;
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--chart-1: 12 76% 61%;
|
|
||||||
--chart-2: 173 58% 39%;
|
|
||||||
--chart-3: 197 37% 24%;
|
|
||||||
--chart-4: 43 74% 66%;
|
|
||||||
--chart-5: 27 87% 67%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark:root {
|
.dark:root {
|
||||||
--background: 0 0% 3.9%;
|
--background: 22,22,22;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 255,241,238;
|
||||||
--card: 0 0% 3.9%;
|
--card: 31,31,31;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 255,241,238;
|
||||||
--popover: 0 0% 3.9%;
|
--popover: 31, 31, 31;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 255,241,238;
|
||||||
--primary: 0 0% 98%;
|
--primary: 228, 98, 18;
|
||||||
--primary-foreground: 0 0% 9%;
|
--primary-foreground: 0,0,0;
|
||||||
--secondary: 0 0% 14.9%;
|
--secondary: 15, 9, 11;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 255,241,238;
|
||||||
--muted: 0 0% 14.9%;
|
--muted: 9, 5, 6;
|
||||||
--muted-foreground: 0 0% 63.9%;
|
--muted-foreground: 176,153,151;
|
||||||
--accent: 0 0% 14.9%;
|
--accent: 228, 125, 251;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 255,249,244;
|
||||||
--destructive: 0 70.9% 59.4%;
|
--destructive: 255,40,90;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 255,249,244;
|
||||||
--border: 0 0% 14.9%;
|
--border: 95, 95, 95;
|
||||||
--input: 0 0% 14.9%;
|
--input: 16,9,10;
|
||||||
--ring: 300 0% 45%;
|
--ring: 151,170,81;
|
||||||
--chart-1: 220 70% 50%;
|
|
||||||
--chart-2: 160 60% 45%;
|
|
||||||
--chart-3: 30 80% 55%;
|
|
||||||
--chart-4: 280 65% 60%;
|
|
||||||
--chart-5: 340 75% 55%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
ios_build_guide.md
Normal file
73
ios_build_guide.md
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# iOS Build Guide for Expo
|
||||||
|
|
||||||
|
This project uses the **Expo Managed Workflow**, which means the `ios` and `android` native directories are generated automatically via **Continuous Native Generation (CNG)**. You should not see or manually edit an `ios` folder in your project root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Development (Expo Go)
|
||||||
|
|
||||||
|
The easiest way to build/run for iOS during development is using the **Expo Go** app on your iPhone.
|
||||||
|
|
||||||
|
1. Install **Expo Go** from the App Store.
|
||||||
|
2. Run the development server:
|
||||||
|
```bash
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
3. Scan the QR code with your Camera app to open the project in Expo Go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Local Native Development (Prebuild)
|
||||||
|
|
||||||
|
If you need to test native modules, use a custom dev client, or specifically need the `ios` folder for debugging in Xcode:
|
||||||
|
|
||||||
|
1. Generate the native directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo prebuild
|
||||||
|
```
|
||||||
|
|
||||||
|
_This will create the `ios` and `android` folders based on your `app.json` configuration._
|
||||||
|
|
||||||
|
2. Run on the iOS Simulator (requires macOS + Xcode):
|
||||||
|
```bash
|
||||||
|
npx expo run:ios
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The `ios` folder is typically gitignored. In the managed workflow, any changes you make manually in the `ios` folder may be overwritten the next time you run `prebuild`. Always use `app.json` or config plugins for permanent configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Production Builds (EAS Build)
|
||||||
|
|
||||||
|
To build a `.ipa` file for TestFlight or the App Store, the recommended way is using **Expo Application Services (EAS)**.
|
||||||
|
|
||||||
|
1. Install EAS CLI:
|
||||||
|
```bash
|
||||||
|
npm install -g eas-cli
|
||||||
|
```
|
||||||
|
2. Log in to your Expo account:
|
||||||
|
```bash
|
||||||
|
eas login
|
||||||
|
```
|
||||||
|
3. Configure the project (run once):
|
||||||
|
```bash
|
||||||
|
eas build:configure
|
||||||
|
```
|
||||||
|
4. Run a build for iOS:
|
||||||
|
```bash
|
||||||
|
eas build --platform ios
|
||||||
|
```
|
||||||
|
_EAS will handle certificates, provisioning profiles, and building on their servers (no macOS/Xcode required locally)._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Commands
|
||||||
|
|
||||||
|
| Goal | Command |
|
||||||
|
| :----------------------- | :------------------------- |
|
||||||
|
| **Start Dev Server** | `npx expo start` |
|
||||||
|
| **Generate iOS Folder** | `npx expo prebuild` |
|
||||||
|
| **Run on iOS Simulator** | `npx expo run:ios` |
|
||||||
|
| **Build for Production** | `eas build --platform ios` |
|
||||||
119
lib/api-middlewares.ts
Normal file
119
lib/api-middlewares.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { Middleware } from "@simple-api/core";
|
||||||
|
import { useAuthStore } from "./auth-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to inject the authentication token into requests.
|
||||||
|
* Skips login, register, and refresh endpoints.
|
||||||
|
*/
|
||||||
|
export const authMiddleware: Middleware = async ({ config, options }, next) => {
|
||||||
|
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 =
|
||||||
|
config.path === "auth/login" ||
|
||||||
|
config.path === "auth/register" ||
|
||||||
|
config.path === "auth/refresh";
|
||||||
|
|
||||||
|
if (token && !isAuthPath) {
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to handle token refreshment on 401 Unauthorized errors.
|
||||||
|
*/
|
||||||
|
export const refreshMiddleware: Middleware = async (
|
||||||
|
{ config, options },
|
||||||
|
next,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
return await next(options);
|
||||||
|
} catch (error: any) {
|
||||||
|
const status = error.status || error.statusCode;
|
||||||
|
const { refreshToken, setAuth, logout } = useAuthStore.getState();
|
||||||
|
|
||||||
|
// Skip refresh logic for the login/refresh endpoints themselves
|
||||||
|
const isAuthPath =
|
||||||
|
config.path?.includes("auth/login") ||
|
||||||
|
config.path?.includes("auth/refresh");
|
||||||
|
|
||||||
|
if (status === 401 && refreshToken && !isAuthPath) {
|
||||||
|
console.log(
|
||||||
|
`[API Refresh] 401 detected for ${config.path}. Attempting refresh...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We call the refresh endpoint manually here to avoid circular dependencies with the 'api' object
|
||||||
|
const refreshUrl = `${config.baseUrl}auth/refresh`;
|
||||||
|
|
||||||
|
const response = await fetch(refreshUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
refreshToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const refreshErr = new Error(
|
||||||
|
errorData.message ||
|
||||||
|
`Refresh failed with status ${response.status}`,
|
||||||
|
) as any;
|
||||||
|
refreshErr.status = response.status;
|
||||||
|
throw refreshErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Backend might return snake_case (access_token) or camelCase (accessToken)
|
||||||
|
// We handle both to be safe when using raw fetch
|
||||||
|
const accessToken = data.accessToken || data.access_token;
|
||||||
|
const newRefreshToken = data.refreshToken || data.refresh_token;
|
||||||
|
const user = data.user;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error("No access token returned from refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuth(user, accessToken, newRefreshToken);
|
||||||
|
|
||||||
|
console.log("[API Refresh] Success. Retrying original request...");
|
||||||
|
|
||||||
|
// Update headers and retry
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await next(options);
|
||||||
|
} catch (refreshError: any) {
|
||||||
|
// Only logout if the refresh token itself is invalid (400, 401, 403)
|
||||||
|
// If it's a network error, we should NOT logout the user.
|
||||||
|
const refreshStatus = refreshError.status || refreshError.statusCode;
|
||||||
|
const isAuthError = refreshStatus === 401;
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
console.error("[API Refresh] Invalid refresh token. Logging out.");
|
||||||
|
logout();
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"[API Refresh] Network error or server issues during refresh. Staying logged in.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw refreshError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
119
lib/api.ts
Normal file
119
lib/api.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import {
|
||||||
|
createApi,
|
||||||
|
createLoggerMiddleware,
|
||||||
|
createTransformerMiddleware,
|
||||||
|
} from "@simple-api/core";
|
||||||
|
import { authMiddleware, refreshMiddleware } from "./api-middlewares";
|
||||||
|
|
||||||
|
// Trailing slash is essential for relative path resolution
|
||||||
|
export const BASE_URL = "https://api.yaltopiaticket.com/";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central API client using simple-api
|
||||||
|
*/
|
||||||
|
export const api = createApi({
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
middleware: [
|
||||||
|
createLoggerMiddleware(),
|
||||||
|
createTransformerMiddleware(),
|
||||||
|
refreshMiddleware,
|
||||||
|
],
|
||||||
|
services: {
|
||||||
|
notifications: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
getAll: { method: "GET", path: "notifications" },
|
||||||
|
settings: { method: "GET", path: "notifications/settings" },
|
||||||
|
update: { method: "PUT", path: "notifications/settings" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
news: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
getAll: { method: "GET", path: "news" },
|
||||||
|
getLatest: { method: "GET", path: "news/latest" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
login: { method: "POST", path: "auth/login" },
|
||||||
|
register: { method: "POST", path: "auth/login-or-register-owner" },
|
||||||
|
refresh: { method: "POST", path: "auth/refresh" },
|
||||||
|
logout: { method: "POST", path: "auth/logout" },
|
||||||
|
profile: { method: "GET", path: "auth/profile" },
|
||||||
|
googleMobile: { method: "POST", path: "auth/google/mobile" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoices: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
stats: { method: "GET", path: "invoices/stats" },
|
||||||
|
getAll: { method: "GET", path: "invoices" },
|
||||||
|
getById: { method: "GET", path: "invoices/:id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
me: { method: "GET", path: "users/me" },
|
||||||
|
getAll: { method: "GET", path: "users" },
|
||||||
|
updateProfile: { method: "PUT", path: "users/me" },
|
||||||
|
create: { method: "POST", path: "auth/register" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
get: { method: "GET", path: "company" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scan: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
invoice: { method: "POST", path: "scan/invoice" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payments: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
getAll: { method: "GET", path: "payments" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paymentRequests: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
create: { method: "POST", path: "payment-requests" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proforma: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
getAll: { method: "GET", path: "proforma" },
|
||||||
|
getById: { method: "GET", path: "proforma/:id" },
|
||||||
|
create: { method: "POST", path: "proforma" },
|
||||||
|
update: { method: "PUT", path: "proforma/:id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rbac: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
roles: { method: "GET", path: "rbac/roles" },
|
||||||
|
permissions: { method: "GET", path: "rbac/permissions" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit exports for convenience and to avoid undefined access
|
||||||
|
export const authApi = api.auth;
|
||||||
|
export const newsApi = api.news;
|
||||||
|
export const invoicesApi = api.invoices;
|
||||||
|
export const proformaApi = api.proforma;
|
||||||
|
export const rbacApi = api.rbac;
|
||||||
46
lib/auth-guards.ts
Normal file
46
lib/auth-guards.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { RouteGuard, GuardResult } from "@sirou/core";
|
||||||
|
import { useAuthStore } from "./auth-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication Guard
|
||||||
|
* Prevents unauthenticated users from accessing protected routes.
|
||||||
|
*/
|
||||||
|
export const authGuard: RouteGuard = {
|
||||||
|
name: "auth",
|
||||||
|
execute: async ({ route, meta }): Promise<GuardResult> => {
|
||||||
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
|
const requiresAuth = meta?.requiresAuth ?? false;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AUTH_GUARD] checking: "${route}" (requiresAuth: ${requiresAuth}, auth: ${isAuthenticated})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requiresAuth && !isAuthenticated) {
|
||||||
|
console.log(`[AUTH_GUARD] DENIED -> redirect /login`);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
redirect: "login", // Use name, not path
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guestGuard: RouteGuard = {
|
||||||
|
name: "guest",
|
||||||
|
execute: async ({ meta }): Promise<GuardResult> => {
|
||||||
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
|
const guestOnly = meta?.guestOnly ?? false;
|
||||||
|
|
||||||
|
if (guestOnly && isAuthenticated) {
|
||||||
|
console.log(`[GUEST_GUARD] Authenticated user blocked -> redirect /`);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
redirect: "(tabs)", // Redirect to home if already logged in
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
94
lib/auth-store.ts
Normal file
94
lib/auth-store.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
export type UserRole =
|
||||||
|
| "ADMIN"
|
||||||
|
| "BUSINESS_OWNER"
|
||||||
|
| "EMPLOYEE"
|
||||||
|
| "ACCOUNTANT"
|
||||||
|
| "CUSTOMER_SERVICE"
|
||||||
|
| "AUDITOR"
|
||||||
|
| "VIEWER";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
role: UserRole;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setAuth: (user: User, token: string, refreshToken?: string, permissions?: string[]) => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
updateUser: (user: Partial<User>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
permissions: [],
|
||||||
|
isAuthenticated: false,
|
||||||
|
setAuth: (user, token, refreshToken = undefined, permissions = []) => {
|
||||||
|
console.log("[AuthStore] Setting auth state:", {
|
||||||
|
hasUser: !!user,
|
||||||
|
hasToken: !!token,
|
||||||
|
hasRefreshToken: !!refreshToken,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
refreshToken: refreshToken ?? null,
|
||||||
|
permissions,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
console.log("[AuthStore] Logging out...");
|
||||||
|
const { isAuthenticated, token } = useAuthStore.getState();
|
||||||
|
|
||||||
|
if (isAuthenticated && token) {
|
||||||
|
try {
|
||||||
|
// Use require to avoid circularity and module flag errors
|
||||||
|
const { api } = require("./api");
|
||||||
|
await api.auth.logout();
|
||||||
|
console.log("[AuthStore] Server-side logout success.");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn("[AuthStore] Server-side logout failed:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
permissions: [],
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateUser: (updatedUser) =>
|
||||||
|
set((state) => {
|
||||||
|
console.log("[AuthStore] Updating user profile.");
|
||||||
|
return {
|
||||||
|
user: state.user ? { ...state.user, ...updatedUser } : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "yaltopia-auth-storage",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
19
lib/colors.ts
Normal file
19
lib/colors.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consistent colors for placeholders and muted text throughout the app.
|
||||||
|
* Dark: rgba(255,255,255,0.6), Light: rgba(0,0,0,0.6)
|
||||||
|
*/
|
||||||
|
export const getPlaceholderColor = (isDark: boolean) => isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)';
|
||||||
|
export const getMutedColor = (isDark: boolean) => isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get consistent colors based on current theme.
|
||||||
|
*/
|
||||||
|
export const useAppColors = () => {
|
||||||
|
const isDark = useColorScheme() === 'dark';
|
||||||
|
return {
|
||||||
|
placeholder: getPlaceholderColor(isDark),
|
||||||
|
muted: getMutedColor(isDark),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -34,4 +34,42 @@ export {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Upload,
|
Upload,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from 'lucide-react-native';
|
Briefcase,
|
||||||
|
Layout,
|
||||||
|
Hash,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
History,
|
||||||
|
DraftingCompass,
|
||||||
|
Zap,
|
||||||
|
Tag,
|
||||||
|
CreditCard,
|
||||||
|
Building2,
|
||||||
|
ExternalLink,
|
||||||
|
Scan,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
ShieldCheck,
|
||||||
|
HelpCircle,
|
||||||
|
ArrowUpRight,
|
||||||
|
Lock,
|
||||||
|
ArrowRight,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Github,
|
||||||
|
Phone,
|
||||||
|
Chrome,
|
||||||
|
Triangle,
|
||||||
|
Triangle as TrianglePlanets,
|
||||||
|
AlertTriangle,
|
||||||
|
Lightbulb,
|
||||||
|
Check,
|
||||||
|
MessageSquare,
|
||||||
|
RefreshCw,
|
||||||
|
Banknote,
|
||||||
|
Newspaper,
|
||||||
|
ChevronDown,
|
||||||
|
CalendarSearch,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react-native";
|
||||||
|
|
|
||||||
23
lib/language-store.ts
Normal file
23
lib/language-store.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
export type AppLanguage = "en" | "am";
|
||||||
|
|
||||||
|
type LanguageState = {
|
||||||
|
language: AppLanguage;
|
||||||
|
setLanguage: (lang: AppLanguage) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLanguageStore = create<LanguageState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
language: "en",
|
||||||
|
setLanguage: (language) => set({ language }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "app-language",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
54
lib/permissions.ts
Normal file
54
lib/permissions.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
|
||||||
|
export const PERMISSION_MAP = {
|
||||||
|
// Invoices
|
||||||
|
"invoices:read": "invoices:read",
|
||||||
|
"invoices:create": "invoices:create",
|
||||||
|
|
||||||
|
// Proforma
|
||||||
|
"proforma:read": "proforma:read",
|
||||||
|
"proforma:create": "proforma:create",
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
"payments:read": "payments:read",
|
||||||
|
"payments:create": "payments:create",
|
||||||
|
|
||||||
|
// Users
|
||||||
|
"users:read": "users:read",
|
||||||
|
"users:create": "users:create",
|
||||||
|
|
||||||
|
// News
|
||||||
|
"news:read": "news:read",
|
||||||
|
|
||||||
|
// Company
|
||||||
|
"company:read": "company:read",
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
"notifications:read": "notifications:read",
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
"profile:update": "profile:update",
|
||||||
|
|
||||||
|
// Scan
|
||||||
|
"scan:create": "scan:create",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to check if user has a specific permission.
|
||||||
|
*/
|
||||||
|
export function hasPermission(userPermissions: string[], permission: string): boolean {
|
||||||
|
return userPermissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to check if user has any of the permissions.
|
||||||
|
*/
|
||||||
|
export function hasAnyPermission(userPermissions: string[], permissions: string[]): boolean {
|
||||||
|
return permissions.some(perm => userPermissions.includes(perm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to check if user has all permissions.
|
||||||
|
*/
|
||||||
|
export function hasAllPermissions(userPermissions: string[], permissions: string[]): boolean {
|
||||||
|
return permissions.every(perm => userPermissions.includes(perm));
|
||||||
|
}
|
||||||
163
lib/routes.ts
Normal file
163
lib/routes.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { defineRoutes } from "@sirou/core";
|
||||||
|
|
||||||
|
export const routes = defineRoutes({
|
||||||
|
// Root and Layouts
|
||||||
|
root: {
|
||||||
|
path: "/",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"(tabs)": {
|
||||||
|
path: "/(tabs)",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
// Tabs
|
||||||
|
"(tabs)/index": {
|
||||||
|
path: "/(tabs)/index",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"(tabs)/payments": {
|
||||||
|
path: "/(tabs)/payments",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"(tabs)/scan": {
|
||||||
|
path: "/(tabs)/scan",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"(tabs)/proforma": {
|
||||||
|
path: "/(tabs)/proforma",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"(tabs)/news": {
|
||||||
|
path: "/(tabs)/news",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
path: "/history",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
path: "/help",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Help & Support" },
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
path: "/privacy",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Privacy Policy" },
|
||||||
|
},
|
||||||
|
terms: {
|
||||||
|
path: "/terms",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Terms of Service" },
|
||||||
|
},
|
||||||
|
// Stacks
|
||||||
|
"proforma/[id]": {
|
||||||
|
path: "/proforma/:id",
|
||||||
|
params: { id: "string" },
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"proforma/create": {
|
||||||
|
path: "/proforma/create",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"proforma/edit": {
|
||||||
|
path: "/proforma/edit",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"payments/[id]": {
|
||||||
|
path: "/payments/:id",
|
||||||
|
params: { id: "string" },
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"payment-requests/create": {
|
||||||
|
path: "/payment-requests/create",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"invoices/[id]": {
|
||||||
|
path: "/invoices/:id",
|
||||||
|
params: { id: "string" },
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"notifications/index": {
|
||||||
|
path: "/notifications/index",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"notifications/settings": {
|
||||||
|
path: "/notifications/settings",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"reports/index": {
|
||||||
|
path: "/reports/index",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"documents/index": {
|
||||||
|
path: "/documents/index",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
path: "/profile",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"edit-profile": {
|
||||||
|
path: "/edit-profile",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Edit Profile" },
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
path: "/settings",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"sms-scan": {
|
||||||
|
path: "/sms-scan",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
path: "/company",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Company" },
|
||||||
|
},
|
||||||
|
"company-details": {
|
||||||
|
path: "/company/details",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Company details" },
|
||||||
|
},
|
||||||
|
"user/create": {
|
||||||
|
path: "/user/create",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Add User" },
|
||||||
|
},
|
||||||
|
// Public
|
||||||
|
login: {
|
||||||
|
path: "/login",
|
||||||
|
guards: ["guest"],
|
||||||
|
meta: { requiresAuth: false, guestOnly: true },
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
path: "/register",
|
||||||
|
guards: ["guest"],
|
||||||
|
meta: { requiresAuth: false, guestOnly: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppRoutes = typeof routes;
|
||||||
125
lib/theme.ts
125
lib/theme.ts
|
|
@ -1,61 +1,53 @@
|
||||||
import { DarkTheme, DefaultTheme, type Theme } from '@react-navigation/native';
|
import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
|
||||||
|
|
||||||
export const THEME = {
|
export const THEME = {
|
||||||
light: {
|
light: {
|
||||||
background: 'hsl(0 0% 100%)',
|
background: "rgba(255,243,238,1)",
|
||||||
foreground: 'hsl(0 0% 3.9%)',
|
foreground: "rgba(37,22,21,1)",
|
||||||
card: 'hsl(0 0% 100%)',
|
card: "rgba(255,249,244,1)",
|
||||||
cardForeground: 'hsl(0 0% 3.9%)',
|
cardForeground: "rgba(37,22,21,1)",
|
||||||
popover: 'hsl(0 0% 100%)',
|
popover: "rgba(255,249,244,1)",
|
||||||
popoverForeground: 'hsl(0 0% 3.9%)',
|
popoverForeground: "rgba(37,22,21,1)",
|
||||||
primary: 'hsl(24 90% 48%)',
|
primary: "rgba(233,87,82,1)",
|
||||||
primaryForeground: 'hsl(0 0% 100%)',
|
primaryForeground: "rgba(255,249,244,1)",
|
||||||
secondary: 'hsl(0 0% 96.1%)',
|
secondary: "rgba(255,226,216,1)",
|
||||||
secondaryForeground: 'hsl(0 0% 9%)',
|
secondaryForeground: "rgba(66,37,32,1)",
|
||||||
muted: 'hsl(0 0% 96.1%)',
|
muted: "rgba(255,234,227,1)",
|
||||||
mutedForeground: 'hsl(0 0% 45.1%)',
|
mutedForeground: "rgba(118,93,88,1)",
|
||||||
accent: 'hsl(0 0% 96.1%)',
|
accent: "rgba(255,222,207,1)",
|
||||||
accentForeground: 'hsl(0 0% 9%)',
|
accentForeground: "rgba(66,37,32,1)",
|
||||||
destructive: 'hsl(0 84.2% 60.2%)',
|
destructive: "rgba(239,67,94,1)",
|
||||||
border: 'hsl(0 0% 89.8%)',
|
destructiveForeground: "rgba(255,249,244,1)",
|
||||||
input: 'hsl(0 0% 89.8%)',
|
border: "rgba(237,213,209,1)",
|
||||||
ring: 'hsl(0 0% 63%)',
|
input: "rgba(244,206,198,1)",
|
||||||
radius: '0.625rem',
|
ring: "rgba(233,87,82,1)",
|
||||||
chart1: 'hsl(12 76% 61%)',
|
radius: "0.625rem",
|
||||||
chart2: 'hsl(173 58% 39%)',
|
|
||||||
chart3: 'hsl(197 37% 24%)',
|
|
||||||
chart4: 'hsl(43 74% 66%)',
|
|
||||||
chart5: 'hsl(27 87% 67%)',
|
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
background: 'hsl(0 0% 3.9%)',
|
background: "rgba(25,21,21,1)",
|
||||||
foreground: 'hsl(0 0% 98%)',
|
foreground: "rgba(255,241,238,1)",
|
||||||
card: 'hsl(0 0% 3.9%)',
|
card: "rgba(35,30,29,1)",
|
||||||
cardForeground: 'hsl(0 0% 98%)',
|
cardForeground: "rgba(255,241,238,1)",
|
||||||
popover: 'hsl(0 0% 3.9%)',
|
popover: "rgba(35,30,29,1)",
|
||||||
popoverForeground: 'hsl(0 0% 98%)',
|
popoverForeground: "rgba(255,241,238,1)",
|
||||||
primary: 'hsl(0 0% 98%)',
|
primary: "rgba(233,87,82,1)",
|
||||||
primaryForeground: 'hsl(0 0% 9%)',
|
primaryForeground: "rgba(0,0,0,1)",
|
||||||
secondary: 'hsl(0 0% 14.9%)',
|
secondary: "rgba(16,9,10,1)",
|
||||||
secondaryForeground: 'hsl(0 0% 98%)',
|
secondaryForeground: "rgba(255,241,238,1)",
|
||||||
muted: 'hsl(0 0% 14.9%)',
|
muted: "rgba(9,5,5,1)",
|
||||||
mutedForeground: 'hsl(0 0% 63.9%)',
|
mutedForeground: "rgba(176,153,151,1)",
|
||||||
accent: 'hsl(0 0% 14.9%)',
|
accent: "rgba(197,156,221,1)",
|
||||||
accentForeground: 'hsl(0 0% 98%)',
|
accentForeground: "rgba(255,249,244,1)",
|
||||||
destructive: 'hsl(0 70.9% 59.4%)',
|
destructive: "rgba(255,40,90,1)",
|
||||||
border: 'hsl(0 0% 14.9%)',
|
destructiveForeground: "rgba(255,249,244,1)",
|
||||||
input: 'hsl(0 0% 14.9%)',
|
border: "rgba(105,93,92,1)",
|
||||||
ring: 'hsl(300 0% 45%)',
|
input: "rgba(16,9,10,1)",
|
||||||
radius: '0.625rem',
|
ring: "rgba(151,170,81,1)",
|
||||||
chart1: 'hsl(220 70% 50%)',
|
radius: "0.625rem",
|
||||||
chart2: 'hsl(160 60% 45%)',
|
|
||||||
chart3: 'hsl(30 80% 55%)',
|
|
||||||
chart4: 'hsl(280 65% 60%)',
|
|
||||||
chart5: 'hsl(340 75% 55%)',
|
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const NAV_THEME: Record<'light' | 'dark', Theme> = {
|
export const NAV_THEME: Record<"light" | "dark", Theme> = {
|
||||||
light: {
|
light: {
|
||||||
...DefaultTheme,
|
...DefaultTheme,
|
||||||
colors: {
|
colors: {
|
||||||
|
|
@ -79,3 +71,36 @@ export const NAV_THEME: Record<'light' | 'dark', Theme> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Persistent theme helpers ──────────────────────────────────────
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
export type AppTheme = "light" | "dark" | "system";
|
||||||
|
const THEME_KEY = "app_theme_preference";
|
||||||
|
|
||||||
|
export async function saveTheme(theme: AppTheme): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(THEME_KEY, theme);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTheme(): Promise<AppTheme> {
|
||||||
|
try {
|
||||||
|
const v = await AsyncStorage.getItem(THEME_KEY);
|
||||||
|
if (v === "light" || v === "dark" || v === "system") return v;
|
||||||
|
} catch {}
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop this in the root _layout to restore the saved theme on every app launch. */
|
||||||
|
export function useRestoreTheme() {
|
||||||
|
const { setColorScheme } = useColorScheme();
|
||||||
|
useEffect(() => {
|
||||||
|
// Only set it once on load
|
||||||
|
loadTheme().then((t) => {
|
||||||
|
if (t) setColorScheme(t);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
|
||||||
46
lib/toast-store.ts
Normal file
46
lib/toast-store.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type ToastType = "success" | "error" | "warning" | "info";
|
||||||
|
|
||||||
|
export interface ToastState {
|
||||||
|
visible: boolean;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
show: (params: {
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
}) => void;
|
||||||
|
hide: () => void;
|
||||||
|
showToast: (message: string, type?: ToastType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToast = create<ToastState>((set) => ({
|
||||||
|
visible: false,
|
||||||
|
type: "info",
|
||||||
|
title: "",
|
||||||
|
message: "",
|
||||||
|
duration: 4000,
|
||||||
|
show: ({ type, title, message, duration = 4000 }) => {
|
||||||
|
set({ visible: true, type, title, message, duration });
|
||||||
|
},
|
||||||
|
hide: () => set({ visible: false }),
|
||||||
|
showToast: (message, type = "info") => {
|
||||||
|
const title = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
set({ visible: true, type, title, message, duration: 4000 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const toast = {
|
||||||
|
success: (title: string, message: string) =>
|
||||||
|
useToast.getState().show({ type: "success", title, message }),
|
||||||
|
error: (title: string, message: string) =>
|
||||||
|
useToast.getState().show({ type: "error", title, message }),
|
||||||
|
warning: (title: string, message: string) =>
|
||||||
|
useToast.getState().show({ type: "warning", title, message }),
|
||||||
|
info: (title: string, message: string) =>
|
||||||
|
useToast.getState().show({ type: "info", title, message }),
|
||||||
|
};
|
||||||
10307
package-lock.json
generated
10307
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
74
package.json
74
package.json
|
|
@ -4,42 +4,58 @@
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web",
|
||||||
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~6.1.2",
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
"@react-navigation/native": "^7.1.28",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@rn-primitives/portal": "^1.3.0",
|
"@react-native-community/datetimepicker": "8.2.0",
|
||||||
"@rn-primitives/slot": "^1.2.0",
|
"@react-native-google-signin/google-signin": "^16.1.2",
|
||||||
"babel-preset-expo": "^54.0.10",
|
"@react-navigation/native": "^7.0.14",
|
||||||
|
"@rn-primitives/portal": "^1.1.0",
|
||||||
|
"@rn-primitives/slot": "^1.1.0",
|
||||||
|
"@simple-api/core": "^1.0.4",
|
||||||
|
"@simple-api/react-native": "^1.0.4",
|
||||||
|
"@sirou/core": "^1.1.0",
|
||||||
|
"@sirou/react-native": "^1.1.0",
|
||||||
|
"babel-preset-expo": "~11.0.15",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"expo": "~54.0.33",
|
"expo": "~52.0.35",
|
||||||
"expo-constants": "^18.0.13",
|
"expo-camera": "~16.0.18",
|
||||||
"expo-linking": "^8.0.11",
|
"expo-constants": "~17.0.7",
|
||||||
"expo-router": "^6.0.23",
|
"expo-linear-gradient": "~14.0.2",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-linking": "~7.0.5",
|
||||||
"lucide-react-native": "^0.575.0",
|
"expo-router": "~4.0.17",
|
||||||
"nativewind": "^4.2.2",
|
"expo-status-bar": "~2.0.1",
|
||||||
"react": "19.1.0",
|
"expo-system-ui": "~4.0.9",
|
||||||
"react-dom": "19.1.0",
|
"expo-web-browser": "~14.0.2",
|
||||||
"react-native": "0.81.5",
|
"lucide-react-native": "^0.471.0",
|
||||||
"react-native-gesture-handler": "^2.30.0",
|
"nativewind": "^4.1.23",
|
||||||
"react-native-reanimated": "^4.2.2",
|
"react": "18.3.1",
|
||||||
"react-native-safe-area-context": "^5.6.2",
|
"react-dom": "18.3.1",
|
||||||
"react-native-screens": "^4.23.0",
|
"react-native": "0.76.7",
|
||||||
"react-native-svg": "^15.15.3",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-get-sms-android": "^2.1.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"react-native-reanimated": "~3.16.1",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"react-native-safe-area-context": "4.12.0",
|
||||||
|
"react-native-screens": "~4.4.0",
|
||||||
|
"react-native-svg": "15.8.0",
|
||||||
|
"react-native-web": "~0.19.13",
|
||||||
|
"tailwind-merge": "^3.0.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~18.3.12",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "^5.3.3",
|
||||||
|
"@react-native-community/cli": "latest"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
patches/react-native-css-interop+0.2.2.patch
Normal file
28
patches/react-native-css-interop+0.2.2.patch
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
diff --git a/node_modules/react-native-css-interop/.cache/android.js b/node_modules/react-native-css-interop/.cache/android.js
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..e69de29
|
||||||
|
diff --git a/node_modules/react-native-css-interop/.cache/ios.js b/node_modules/react-native-css-interop/.cache/ios.js
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..e69de29
|
||||||
|
diff --git a/node_modules/react-native-css-interop/.cache/macos.js b/node_modules/react-native-css-interop/.cache/macos.js
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..e69de29
|
||||||
|
diff --git a/node_modules/react-native-css-interop/.cache/native.js b/node_modules/react-native-css-interop/.cache/native.js
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..e69de29
|
||||||
|
diff --git a/node_modules/react-native-css-interop/.cache/windows.js b/node_modules/react-native-css-interop/.cache/windows.js
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..e69de29
|
||||||
|
diff --git a/node_modules/react-native-css-interop/babel.js b/node_modules/react-native-css-interop/babel.js
|
||||||
|
index d84e52b..6e6fd21 100644
|
||||||
|
--- a/node_modules/react-native-css-interop/babel.js
|
||||||
|
+++ b/node_modules/react-native-css-interop/babel.js
|
||||||
|
@@ -10,7 +10,7 @@ module.exports = function () {
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Use this plugin in reanimated 4 and later
|
||||||
|
- "react-native-worklets/plugin",
|
||||||
|
+ // "react-native-worklets/plugin",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,73 +1,81 @@
|
||||||
const { hairlineWidth } = require('nativewind/theme');
|
const { hairlineWidth } = require("nativewind/theme");
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: 'class',
|
darkMode: "class",
|
||||||
content: ['./App.tsx', './index.ts', './components/**/*.{js,jsx,ts,tsx}', './app/**/*.{js,jsx,ts,tsx}'],
|
content: [
|
||||||
presets: [require('nativewind/preset')],
|
"./App.tsx",
|
||||||
|
"./index.ts",
|
||||||
|
"./components/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./app/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
presets: [require("nativewind/preset")],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['DMSans-Regular', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: "rgba(var(--border), <alpha-value>)",
|
||||||
input: 'hsl(var(--input))',
|
input: "rgba(var(--input), <alpha-value>)",
|
||||||
ring: 'hsl(var(--ring))',
|
ring: "rgba(var(--ring), <alpha-value>)",
|
||||||
background: 'hsl(var(--background))',
|
background: "rgba(var(--background), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: "rgba(var(--foreground), <alpha-value>)",
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: "rgba(var(--primary), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--primary-foreground))',
|
foreground: "rgba(var(--primary-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: "rgba(var(--secondary), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
foreground: "rgba(var(--secondary-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: "rgba(var(--destructive), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--destructive-foreground))',
|
foreground: "rgba(var(--destructive-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: "rgba(var(--muted), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--muted-foreground))',
|
foreground: "rgba(var(--muted-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: "rgba(var(--accent), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--accent-foreground))',
|
foreground: "rgba(var(--accent-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: "rgba(var(--popover), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--popover-foreground))',
|
foreground: "rgba(var(--popover-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: "rgba(var(--card), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--card-foreground))',
|
foreground: "rgba(var(--card-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: "var(--radius)",
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: 'calc(var(--radius) - 4px)',
|
sm: "calc(var(--radius) - 4px)",
|
||||||
},
|
},
|
||||||
borderWidth: {
|
borderWidth: {
|
||||||
hairline: hairlineWidth(),
|
hairline: hairlineWidth(),
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'accordion-down': {
|
"accordion-down": {
|
||||||
from: { height: '0' },
|
from: { height: "0" },
|
||||||
to: { height: 'var(--radix-accordion-content-height)' },
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
},
|
},
|
||||||
'accordion-up': {
|
"accordion-up": {
|
||||||
from: { height: 'var(--radix-accordion-content-height)' },
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
to: { height: '0' },
|
to: { height: "0" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
future: {
|
future: {
|
||||||
hoverOnlyWhenSupported: true,
|
hoverOnlyWhenSupported: true,
|
||||||
},
|
},
|
||||||
plugins: [require('tailwindcss-animate')],
|
plugins: [require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user