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
|
||||
/ios
|
||||
/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 { Home, ScanLine, FileText, Wallet, User } from '@/lib/icons';
|
||||
import { Tabs, router } from "expo-router";
|
||||
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 = '#ea580c';
|
||||
const INACTIVE_TINT = '#a1a1aa';
|
||||
const ACTIVE_TINT = "rgba(228, 98, 18, 1)";
|
||||
const INACTIVE_TINT = "#94a3b8";
|
||||
|
||||
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 (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: NAV_BG },
|
||||
headerTintColor: '#ffffff',
|
||||
headerTitleStyle: { fontWeight: '600', fontSize: 18 },
|
||||
tabBarStyle: { backgroundColor: NAV_BG, paddingTop: 8 },
|
||||
headerShown: false,
|
||||
tabBarShowLabel: true,
|
||||
tabBarActiveTintColor: ACTIVE_TINT,
|
||||
tabBarInactiveTintColor: INACTIVE_TINT,
|
||||
tabBarLabelStyle: { fontSize: 11 },
|
||||
tabBarShowLabel: true,
|
||||
tabBarButton: ({ ref, ...navProps }) => (
|
||||
<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
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarLabel: 'Home',
|
||||
tabBarIcon: ({ color, size }) => <Home color={color} size={size ?? 22} strokeWidth={2} />,
|
||||
}}
|
||||
/>
|
||||
<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} />,
|
||||
tabBarLabel: "Home",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<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="payments"
|
||||
options={{
|
||||
title: 'Payments',
|
||||
tabBarLabel: 'Payments',
|
||||
tabBarIcon: ({ color, size }) => <Wallet color={color} size={size ?? 22} strokeWidth={2} />,
|
||||
tabBarLabel: "Payments",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<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
|
||||
name="profile"
|
||||
name="scan"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarLabel: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => <User color={color} size={size ?? 22} strokeWidth={2} />,
|
||||
tabBarLabel: "SCAN",
|
||||
tabBarLabelStyle: {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,147 +1,354 @@
|
|||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { EARNINGS_SUMMARY, MOCK_INVOICES, MOCK_USER } from '@/lib/mock-data';
|
||||
import { router } from 'expo-router';
|
||||
import { Camera, Send, ChevronRight, Wallet, DollarSign, Clock } from '@/lib/icons';
|
||||
|
||||
const PRIMARY = '#ea580c';
|
||||
const statusColor: Record<string, string> = {
|
||||
Waiting: 'bg-amber-500/20 text-amber-700',
|
||||
Paid: 'bg-emerald-500/20 text-emerald-700',
|
||||
Draft: 'bg-gray-200 text-gray-700',
|
||||
Unpaid: 'bg-red-500/20 text-red-700',
|
||||
};
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
useColorScheme,
|
||||
} from "react-native";
|
||||
import { api } from "@/lib/api";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
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() {
|
||||
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 (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
|
||||
paddingTop: 10,
|
||||
paddingBottom: 150,
|
||||
}}
|
||||
>
|
||||
<View className="mb-5">
|
||||
<Text className="text-2xl font-bold text-gray-900">Hi {MOCK_USER.name},</Text>
|
||||
<Text className="text-muted-foreground mt-1 text-base">Take a look at your last activity.</Text>
|
||||
<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 }] }}
|
||||
/>
|
||||
|
||||
<Text className="text-white/60 text-[14px] font-semibold">
|
||||
Available Balance
|
||||
</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>
|
||||
|
||||
<Card className="mb-5 overflow-hidden rounded-2xl border-0 shadow-sm">
|
||||
<View className="bg-primary/10 px-5 py-5">
|
||||
<Text className="text-muted-foreground text-sm">Earnings balance</Text>
|
||||
<Text className="mt-1 text-3xl font-bold text-gray-900">${EARNINGS_SUMMARY.balance.toLocaleString()}</Text>
|
||||
<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>
|
||||
<View className="flex-row border-t border-border">
|
||||
<Pressable
|
||||
className="flex-1 flex-row items-center gap-3 px-5 py-4"
|
||||
onPress={() => router.push('/(tabs)/payments')}
|
||||
>
|
||||
<View className="rounded-xl bg-primary/15 p-2">
|
||||
<Clock color={PRIMARY} size={20} strokeWidth={2} />
|
||||
<Text className="text-white text-[12px] font-semibold">
|
||||
Pending
|
||||
</Text>
|
||||
</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>
|
||||
<Text className="text-white font-bold text-xl mt-2">
|
||||
${stats.pending.toLocaleString()}
|
||||
</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 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>
|
||||
<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>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<View className="mb-5 flex-row gap-3">
|
||||
<Button className="min-h-12 flex-1 rounded-xl bg-primary" onPress={() => router.push('/(tabs)/scan')}>
|
||||
<Camera color="#ffffff" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 text-primary-foreground font-medium">Scan invoice</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-12 flex-1 rounded-xl border-border"
|
||||
onPress={() => router.push('/(tabs)/proforma')}
|
||||
>
|
||||
<Send color={PRIMARY} size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 font-medium text-gray-700">Send proforma</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="-mx-1 mb-4">
|
||||
<View className="flex-row gap-2 px-1">
|
||||
{['All', 'Draft', 'Waiting', 'Paid', 'Unpaid'].map((filter) => (
|
||||
{/* 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
|
||||
onPress={() => nav.go("history")}
|
||||
className="px-4 py-2 rounded-full"
|
||||
>
|
||||
<Text className="text-primary font-bold text-xs">View all</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Filters */}
|
||||
<View className="mb-6">
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8 }}
|
||||
>
|
||||
{["All", "Draft", "Pending", "Paid", "Overdue", "Cancelled"].map(
|
||||
(filter) => (
|
||||
<Pressable
|
||||
key={filter}
|
||||
className={`rounded-full px-4 py-2.5 ${filter === 'Waiting' ? 'bg-primary' : 'bg-white'} border border-border`}
|
||||
onPress={() => setActiveFilter(filter)}
|
||||
className={`rounded-[4px] px-4 py-1.5 ${
|
||||
activeFilter === filter
|
||||
? "bg-primary"
|
||||
: "bg-card border border-border"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
filter === 'Waiting' ? 'text-primary-foreground text-sm font-medium' : 'text-muted-foreground text-sm'
|
||||
}
|
||||
className={`text-xs font-bold ${
|
||||
activeFilter === filter
|
||||
? "text-white"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{filter}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<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" />
|
||||
{/* Transactions List */}
|
||||
<View className="gap-2">
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#ea580c" className="py-20" />
|
||||
) : invoices.length > 0 ? (
|
||||
invoices.map((inv) => (
|
||||
<Pressable
|
||||
key={inv.id}
|
||||
onPress={() => nav.go("invoices/[id]", { id: inv.id })}
|
||||
>
|
||||
<ShadowWrapper level="xs">
|
||||
<Card className="overflow-hidden rounded-[6px] bg-card">
|
||||
<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>
|
||||
{MOCK_INVOICES.filter((i) => i.status === 'Waiting').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} · Due {inv.dueDate}</Text>
|
||||
<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 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 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>
|
||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ShadowWrapper>
|
||||
</Pressable>
|
||||
))}
|
||||
|
||||
<View className="mb-2 mt-6 flex-row items-center gap-2">
|
||||
<View className="h-px flex-1 bg-border" />
|
||||
<Text className="text-muted-foreground text-xs font-medium">Yesterday</Text>
|
||||
<View className="h-px flex-1 bg-border" />
|
||||
))
|
||||
) : (
|
||||
<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>
|
||||
{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>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onPress?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="pt-2 items-center w-[75px]">
|
||||
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className="h-12 w-12 rounded-full bg-card border border-border/20 items-center justify-center flex-shrink-0"
|
||||
>
|
||||
{icon}
|
||||
</Pressable>
|
||||
<Text
|
||||
variant="p"
|
||||
className="flex-1 text-foreground text-[12px] font-bold tracking-tight text-center leading-4"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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 { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { MOCK_PAYMENTS } from '@/lib/mock-data';
|
||||
import { ScanLine, Link2, CheckCircle2, Wallet, ChevronRight } from '@/lib/icons';
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
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() {
|
||||
const matched = MOCK_PAYMENTS.filter((p) => p.matched);
|
||||
const pending = MOCK_PAYMENTS.filter((p) => !p.matched);
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
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 (
|
||||
<ScrollView
|
||||
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>
|
||||
// Check permissions
|
||||
const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]);
|
||||
|
||||
<Button className="mb-5 min-h-12 rounded-xl bg-primary">
|
||||
<ScanLine color="#ffffff" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 text-primary-foreground font-medium">Scan SMS now</Text>
|
||||
</Button>
|
||||
const fetchPayments = useCallback(
|
||||
async (pageNum: number, isRefresh = false) => {
|
||||
const { isAuthenticated } = useAuthStore.getState();
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
<View className="mb-3 flex-row items-center gap-2">
|
||||
<Link2 color="#71717a" size={18} strokeWidth={2} />
|
||||
<Text className="text-muted-foreground text-sm font-medium">Pending match</Text>
|
||||
</View>
|
||||
{pending.map((pay) => (
|
||||
<Card key={pay.id} className="mb-2.5 overflow-hidden rounded-xl border-2 border-amber-500/30 bg-white">
|
||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
||||
<Wallet color={PRIMARY} 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}</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" className="rounded-lg" onPress={() => router.push(`/payments/${pay.id}`)}>
|
||||
<Text className="font-medium">Match</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
try {
|
||||
if (!isRefresh) {
|
||||
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
|
||||
}
|
||||
|
||||
<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>
|
||||
</View>
|
||||
<View className="rounded-full bg-emerald-500/20 px-2.5 py-1">
|
||||
<Text className="text-xs font-medium text-emerald-700">Matched</Text>
|
||||
</View>
|
||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</ScrollView>
|
||||
const response = await api.payments.getAll({
|
||||
query: { page: pageNum, limit: 10 },
|
||||
});
|
||||
|
||||
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 className="flex-1">
|
||||
<Text variant="p" className="text-foreground font-bold">
|
||||
{pay.currency || "$"}
|
||||
{amountValue?.toLocaleString()}
|
||||
</Text>
|
||||
<Text variant="muted" className="text-xs">
|
||||
{pay.paymentMethod} · {dateStr}
|
||||
</Text>
|
||||
</View>
|
||||
{isFlagged ? (
|
||||
<View className="bg-red-500/10 px-3 py-1 rounded-[6px]">
|
||||
<Text className="text-red-700 text-[10px] font-semibold">
|
||||
Flagged
|
||||
</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>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
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 { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
import { MOCK_PROFORMA } from '@/lib/mock-data';
|
||||
import { router } from 'expo-router';
|
||||
import { Plus, Send, FileText, ChevronRight, Calendar } from '@/lib/icons';
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
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() {
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text className="text-muted-foreground mb-5 text-base">
|
||||
Create or select proforma requests and share with contacts via email or SMS.
|
||||
</Text>
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const permissions = useAuthStore((s) => s.permissions);
|
||||
const [proformas, setProformas] = useState<ProformaItem[]>([]);
|
||||
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);
|
||||
|
||||
<Button className="mb-5 min-h-12 rounded-xl bg-primary" onPress={() => {}}>
|
||||
<Plus color="#ffffff" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 text-primary-foreground font-medium">Create new proforma</Text>
|
||||
</Button>
|
||||
// Check permissions
|
||||
const canCreateProformas = hasPermission(permissions, PERMISSION_MAP["proforma:create"]);
|
||||
|
||||
<View className="mb-3 flex-row items-center gap-2">
|
||||
<FileText color="#71717a" size={18} strokeWidth={2} />
|
||||
<Text className="text-muted-foreground text-sm font-medium">Your proforma requests</Text>
|
||||
</View>
|
||||
{MOCK_PROFORMA.map((pf) => (
|
||||
<Pressable key={pf.id} onPress={() => router.push(`/proforma/${pf.id}`)}>
|
||||
<Card className="mb-3 overflow-hidden rounded-xl border border-border bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<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">
|
||||
<Calendar color="#71717a" size={14} strokeWidth={2} />
|
||||
<Text className="text-muted-foreground text-xs">Deadline {pf.deadline} · {pf.itemCount} items</Text>
|
||||
</View>
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
const fetchProformas = useCallback(
|
||||
async (pageNum: number, isRefresh = false) => {
|
||||
const { isAuthenticated } = useAuthStore.getState();
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
if (!isRefresh) {
|
||||
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
|
||||
}
|
||||
|
||||
const response = await api.proforma.getAll({
|
||||
query: { page: pageNum, limit: 10 },
|
||||
});
|
||||
|
||||
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>
|
||||
</Card>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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 { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Camera, FileText, ChevronRight } from '@/lib/icons';
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
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() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [torch, setTorch] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const navigation = useNavigation();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ tabBarStyle: { display: "none" } });
|
||||
return () => {
|
||||
navigation.setOptions({
|
||||
tabBarStyle: {
|
||||
display: "flex",
|
||||
backgroundColor: NAV_BG,
|
||||
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 (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
<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-muted-foreground mb-5 text-base">
|
||||
Capture paper or digital invoices with your camera. We'll extract vendor, amount, date, and line items.
|
||||
<Text className="text-white font-bold uppercase tracking-widest">
|
||||
Enable Camera
|
||||
</Text>
|
||||
|
||||
<Card className="mb-5 overflow-hidden rounded-2xl border-2 border-dashed border-border bg-white">
|
||||
<CardContent className="items-center justify-center py-14">
|
||||
<View className="mb-5 h-24 w-24 items-center justify-center rounded-full bg-primary/10">
|
||||
<Camera color={PRIMARY} size={40} strokeWidth={2} />
|
||||
</View>
|
||||
<Text className="mb-2 text-center text-lg font-semibold text-gray-900">Scan invoice</Text>
|
||||
<Text className="text-muted-foreground mb-6 text-center text-sm">
|
||||
Tap below to open camera and capture an invoice
|
||||
</Text>
|
||||
<Button className="min-h-12 rounded-xl bg-primary px-8">
|
||||
<Camera color="#ffffff" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 text-primary-foreground font-medium">Open camera</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<View className="mb-3 flex-row items-center gap-2">
|
||||
<FileText color="#71717a" size={18} strokeWidth={2} />
|
||||
<Text className="text-muted-foreground text-sm font-medium">Recent scans</Text>
|
||||
</View>
|
||||
<Card 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="flex-1">
|
||||
<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>
|
||||
<View className="rounded-full bg-amber-500/20 px-2.5 py-1">
|
||||
<Text className="text-xs font-medium text-amber-700">Pending</Text>
|
||||
</View>
|
||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card 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="flex-1">
|
||||
<Text className="font-medium text-gray-900">Tech Supplies Ltd - Invoice #88</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-sm">Scanned Sep 11, 2022 · $890</Text>
|
||||
</View>
|
||||
<View className="rounded-full bg-emerald-500/20 px-2.5 py-1">
|
||||
<Text className="text-xs font-medium text-emerald-700">Saved</Text>
|
||||
</View>
|
||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
<Pressable onPress={() => nav.back()} className="mt-6">
|
||||
<Text className="text-muted-foreground font-bold">Go Back</Text>
|
||||
</Pressable>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={{ flex: 1 }}
|
||||
facing="back"
|
||||
enableTorch={torch}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Capture Button */}
|
||||
<View className="items-center pb-10 gap-4">
|
||||
<Pressable
|
||||
onPress={handleScan}
|
||||
disabled={scanning}
|
||||
className="h-20 w-20 rounded-full bg-primary items-center justify-center border-4 border-white/30"
|
||||
>
|
||||
{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>
|
||||
</CameraView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
222
app/_layout.tsx
222
app/_layout.tsx
|
|
@ -1,38 +1,218 @@
|
|||
import '../global.css';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { PortalHost } from '@rn-primitives/portal';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { View } from 'react-native';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { PortalHost } from "@rn-primitives/portal";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import "@/global.css";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { View, ActivityIndicator, InteractionManager } from "react-native";
|
||||
import { 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() {
|
||||
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 (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<NavigationIndependentTree>
|
||||
<NavigationContainer>
|
||||
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
||||
<ThemeProvider
|
||||
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
|
||||
>
|
||||
<View className="flex-1 bg-background">
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#2d2d2d' },
|
||||
headerTintColor: '#ffffff',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="proforma/[id]" options={{ title: 'Proforma request' }} />
|
||||
<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="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" options={{ title: 'Reports' }} />
|
||||
<Stack.Screen name="documents" options={{ title: 'Documents' }} />
|
||||
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
|
||||
<Stack.Screen
|
||||
name="sms-scan"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="proforma/[id]"
|
||||
options={{ title: "Proforma request" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="payments/[id]"
|
||||
options={{ title: "Payment" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="notifications/index"
|
||||
options={{ title: "Notifications" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="notifications/settings"
|
||||
options={{ title: "Notification settings" }}
|
||||
/>
|
||||
<Stack.Screen name="help" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="history" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="company" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="company-details"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ title: "Sign in", headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="register"
|
||||
options={{ title: "Create account", headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="invoices/[id]"
|
||||
options={{ title: "Invoice" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="reports/index"
|
||||
options={{ title: "Reports" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="documents/index"
|
||||
options={{ title: "Documents" }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
||||
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="edit-profile"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
</Stack>
|
||||
<SirouBridge />
|
||||
<BackupGuard />
|
||||
<PortalHost />
|
||||
<Toast />
|
||||
</View>
|
||||
</ThemeProvider>
|
||||
</SirouRouterProvider>
|
||||
</NavigationContainer>
|
||||
</NavigationIndependentTree>
|
||||
</SafeAreaProvider>
|
||||
</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 { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText, ChevronRight, FolderOpen, Upload } from '@/lib/icons';
|
||||
import { MOCK_DOCUMENTS } from '@/lib/mock-data';
|
||||
import { View, ScrollView, Pressable } 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 { Button } from "@/components/ui/button";
|
||||
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() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
|
|
@ -23,21 +25,32 @@ export default function DocumentsScreen() {
|
|||
Uploaded invoices, scans, and attachments. Synced with your account.
|
||||
</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} />
|
||||
<Text className="ml-2 font-medium text-gray-700">Upload document</Text>
|
||||
</Button>
|
||||
|
||||
{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>
|
||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
||||
<FileText color={PRIMARY} size={22} strokeWidth={2} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium text-gray-900" numberOfLines={1}>{d.name}</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-sm">{d.size} · {d.uploadedAt}</Text>
|
||||
<Text className="font-medium text-gray-900" numberOfLines={1}>
|
||||
{d.name}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-sm">
|
||||
{d.size} · {d.uploadedAt}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight color="#71717a" size={20} strokeWidth={2} />
|
||||
</CardContent>
|
||||
|
|
@ -45,7 +58,11 @@ export default function DocumentsScreen() {
|
|||
</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>
|
||||
</Button>
|
||||
</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 { useLocalSearchParams, 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 { FileText, Calendar, User, Share2, Download, ChevronRight } from '@/lib/icons';
|
||||
import { MOCK_INVOICES } from '@/lib/mock-data';
|
||||
|
||||
const PRIMARY = '#ea580c';
|
||||
const MOCK_ITEMS = [
|
||||
{ description: 'Marketing Landing Page Package', qty: 1, unitPrice: 1000, total: 1000 },
|
||||
{ description: 'Instagram Post Initial Design', qty: 4, unitPrice: 100, total: 400 },
|
||||
];
|
||||
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 { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
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() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const invoice = MOCK_INVOICES.find((i) => i.id === id);
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
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 (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
||||
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>
|
||||
<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>
|
||||
<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">
|
||||
<CardHeader className="pb-2">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<User color="#71717a" size={18} strokeWidth={2} />
|
||||
<CardTitle className="text-base">Bill to</CardTitle>
|
||||
</View>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Text className="font-medium text-gray-900">{invoice?.recipient ?? '—'}</Text>
|
||||
<Text className="text-muted-foreground text-sm">{invoice?.recipientEmail ?? '—'}</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Items</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="gap-2">
|
||||
{MOCK_ITEMS.map((item, i) => (
|
||||
<View key={i} className="flex-row justify-between border-b border-border py-2 last:border-0">
|
||||
<Text className="text-gray-700">{item.description} × {item.qty}</Text>
|
||||
<Text className="font-medium text-gray-900">${item.total.toLocaleString()}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View className="mt-2 border-t border-border pt-3">
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="font-semibold text-gray-900">Total</Text>
|
||||
<Text className="font-semibold text-gray-900">${invoice?.amount.toLocaleString() ?? '1,540'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<Button variant="outline" className="min-h-12 flex-1 rounded-xl border-border" onPress={() => {}}>
|
||||
<Share2 color={PRIMARY} size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 font-medium text-gray-700">Share</Text>
|
||||
</Button>
|
||||
<Button variant="outline" className="min-h-12 flex-1 rounded-xl border-border" onPress={() => {}}>
|
||||
<Download color={PRIMARY} size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 font-medium text-gray-700">PDF</Text>
|
||||
</Button>
|
||||
</View>
|
||||
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
|
||||
<ChevronRight className="rotate-180" color="#71717a" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
</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 (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Invoice Details" showBack />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Status Hero Card */}
|
||||
<Card className="mb-4 overflow-hidden rounded-[6px] border-0 bg-primary">
|
||||
<View className="p-5">
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
||||
<FileText color="white" size={16} strokeWidth={2.5} />
|
||||
</View>
|
||||
<View
|
||||
className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-[10px] font-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
|
||||
>
|
||||
{invoice.status || "Pending"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text variant="small" className="text-white/70 mb-0.5">
|
||||
Total Amount
|
||||
</Text>
|
||||
<Text variant="h3" className="text-white font-bold mb-3">
|
||||
${Number(invoice.amount).toLocaleString()}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Calendar color="rgba(255,255,255,0.9)" size={12} />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
Due {new Date(invoice.dueDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-3 w-[1px] bg-white/60" />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
#{invoice.invoiceNumber || id}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Recipient & Category — inline info strip */}
|
||||
<Card className="bg-card rounded-[6px] mb-4">
|
||||
<View className="flex-row px-4 py-2">
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<View className="flex-col">
|
||||
<Text className="text-foreground text-xs opacity-60">
|
||||
Recipient
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{invoice.customerName || "—"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="w-[1px] bg-border/70 mx-3" />
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<View className="flex-col">
|
||||
<Text className="text-foreground text-xs opacity-60">
|
||||
Category
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
General
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Items / Billing Summary */}
|
||||
<Card className="mb-4 bg-card rounded-[6px]">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-bold opacity-60 uppercase text-[10px] tracking-widest"
|
||||
>
|
||||
Billing Summary
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
320
app/login.tsx
320
app/login.tsx
|
|
@ -1,40 +1,288 @@
|
|||
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, CardDescription } from '@/components/ui/card';
|
||||
import { Mail, ArrowLeft } from '@/lib/icons';
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
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() {
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
|
||||
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Sign in</CardTitle>
|
||||
<CardDescription className="mt-1">Use the same account as the web app.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="gap-3">
|
||||
<Button className="min-h-12 rounded-xl bg-primary">
|
||||
<Mail color="#ffffff" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
|
||||
</Button>
|
||||
<Button variant="outline" className="min-h-12 rounded-xl border-border">
|
||||
<Text className="font-medium text-gray-700">Continue with Google</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Pressable onPress={() => router.push('/register')} className="mt-4">
|
||||
<Text className="text-center text-primary font-medium">Create account</Text>
|
||||
</Pressable>
|
||||
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
|
||||
<ArrowLeft color="#71717a" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
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 (
|
||||
<ScreenWrapper>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1"
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 10 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="flex-row justify-end mb-4">
|
||||
<Pressable
|
||||
onPress={() => setLanguageModalVisible(true)}
|
||||
className="p-2 rounded-full bg-card border border-border"
|
||||
>
|
||||
<Globe color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Logo / Branding */}
|
||||
<View className="items-center mb-10">
|
||||
<Text variant="h2" className="mt-6 font-bold text-foreground">
|
||||
Login
|
||||
</Text>
|
||||
<Text variant="muted" className="mt-2 text-center">
|
||||
Sign in to manage your tickets & invoices
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 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 { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Bell, Settings, ChevronRight } from '@/lib/icons';
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { View, ActivityIndicator, FlatList, RefreshControl } 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 { api } from "@/lib/api";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
|
||||
const MOCK_NOTIFICATIONS = [
|
||||
{ id: '1', title: 'Invoice reminder', body: 'Invoice #2 to Robin Murray is due in 2 days.', time: '2h ago', read: false },
|
||||
{ id: '2', title: 'Payment received', body: 'Payment of $500 received for Invoice #4.', time: '1d ago', read: true },
|
||||
{ id: '3', title: 'Proforma submission', body: 'Vendor A submitted a quote for Marketing Landing Page.', time: '2d ago', read: true },
|
||||
];
|
||||
type NotificationItem = {
|
||||
id: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
message?: string;
|
||||
createdAt?: string;
|
||||
read?: boolean;
|
||||
};
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Bell color="#18181b" size={22} strokeWidth={2} />
|
||||
<Text className="text-xl font-semibold text-gray-900">Notifications</Text>
|
||||
</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>
|
||||
const [items, setItems] = useState<NotificationItem[]>([]);
|
||||
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);
|
||||
|
||||
{MOCK_NOTIFICATIONS.map((n) => (
|
||||
<Card key={n.id} className={`mb-2 ${!n.read ? 'border-primary/30' : ''}`}>
|
||||
const fetchNotifications = useCallback(
|
||||
async (pageNum: number, mode: "initial" | "refresh" | "more") => {
|
||||
try {
|
||||
if (mode === "initial") setLoading(true);
|
||||
if (mode === "refresh") setRefreshing(true);
|
||||
if (mode === "more") setLoadingMore(true);
|
||||
|
||||
const res = await (api as any).notifications.getAll({
|
||||
query: { page: pageNum, limit: 20 },
|
||||
});
|
||||
|
||||
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-gray-900">{n.title}</Text>
|
||||
<Text className="text-muted-foreground mt-1 text-sm">{n.body}</Text>
|
||||
<Text className="text-muted-foreground mt-1 text-xs">{n.time}</Text>
|
||||
<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>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
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 { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Switch,
|
||||
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() {
|
||||
const [invoiceReminders, setInvoiceReminders] = useState(true);
|
||||
const [daysBeforeDue, setDaysBeforeDue] = useState(2);
|
||||
const [newsAlerts, setNewsAlerts] = useState(true);
|
||||
const [reportReady, setReportReady] = useState(true);
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
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 (
|
||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
||||
<Card className="mb-4">
|
||||
<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} />
|
||||
<ScreenWrapper className="bg-background">
|
||||
<StandardHeader showBack title="Notification settings" />
|
||||
|
||||
{loading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-900">News & announcements</Text>
|
||||
<Switch value={newsAlerts} onValueChange={setNewsAlerts} />
|
||||
) : (
|
||||
<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-row items-center justify-between">
|
||||
<Text className="text-gray-900">Report ready</Text>
|
||||
<Switch value={reportReady} onValueChange={setReportReady} />
|
||||
<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>
|
||||
|
||||
<Button variant="outline" onPress={() => router.back()}>
|
||||
<Text className="font-medium">Back</Text>
|
||||
</Button>
|
||||
</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 { useLocalSearchParams, 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 { View, ScrollView, Pressable } from "react-native";
|
||||
import { useSirouRouter, useSirouParams } 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 { Card } from "@/components/ui/card";
|
||||
import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
|
||||
export default function PaymentDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const { id } = useSirouParams<AppRoutes, "payments/[id]">();
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
||||
<Card className="mb-4">
|
||||
<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>
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||
<Pressable
|
||||
onPress={() => nav.back()}
|
||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||
>
|
||||
<ArrowLeft color="#0f172a" size={20} />
|
||||
</Pressable>
|
||||
<Text variant="h4" className="text-foreground font-semibold">
|
||||
Payment Match
|
||||
</Text>
|
||||
<View className="w-9" />
|
||||
</View>
|
||||
<View className="flex-row justify-between py-2">
|
||||
<Text className="text-muted-foreground">Source</Text>
|
||||
<Text className="text-gray-900">Telebirr</Text>
|
||||
|
||||
<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="flex-row justify-between py-2">
|
||||
<Text className="text-muted-foreground">Date</Text>
|
||||
<Text className="text-gray-900">Sep 11, 2022</Text>
|
||||
<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 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={() => {}}>
|
||||
<Text className="text-primary-foreground font-medium">Associate to invoice</Text>
|
||||
</Button>
|
||||
<Button variant="outline" onPress={() => router.back()}>
|
||||
<Text className="font-medium">Back to payments</Text>
|
||||
{/* 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 { useLocalSearchParams, router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
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 { 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 = [
|
||||
{ description: 'Marketing Landing Page Package', qty: 1, unitPrice: 1000, total: 1000 },
|
||||
{ description: 'Instagram Post Initial Design', qty: 4, unitPrice: 100, total: 400 },
|
||||
];
|
||||
const MOCK_SUBTOTAL = 1400;
|
||||
const MOCK_TAX = 140;
|
||||
const MOCK_TOTAL = 1540;
|
||||
const dummyData = {
|
||||
id: "dummy-1",
|
||||
proformaNumber: "PF-001",
|
||||
customerName: "John Doe",
|
||||
customerEmail: "john@example.com",
|
||||
customerPhone: "+1234567890",
|
||||
amount: { value: 1000, currency: "USD" },
|
||||
currency: "USD",
|
||||
issueDate: "2026-03-10T11:51:36.134Z",
|
||||
dueDate: "2026-03-10T11:51:36.134Z",
|
||||
description: "Dummy proforma",
|
||||
notes: "Test notes",
|
||||
taxAmount: { value: 100, currency: "USD" },
|
||||
discountAmount: { value: 50, currency: "USD" },
|
||||
pdfPath: "dummy.pdf",
|
||||
userId: "user-1",
|
||||
items: [
|
||||
{
|
||||
id: "item-1",
|
||||
description: "Test item",
|
||||
quantity: 1,
|
||||
unitPrice: { value: 1000, currency: "USD" },
|
||||
total: { value: 1000, currency: "USD" }
|
||||
}
|
||||
],
|
||||
createdAt: "2026-03-10T11:51:36.134Z",
|
||||
updatedAt: "2026-03-10T11:51:36.134Z"
|
||||
};
|
||||
|
||||
export default function ProformaDetailScreen() {
|
||||
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 (
|
||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Proforma Request #{id ?? '—'}</CardTitle>
|
||||
<CardDescription>Marketing Landing Page Package</CardDescription>
|
||||
<Text className="text-muted-foreground mt-1 text-sm">Deadline: Sep 20, 2022 · OPEN</Text>
|
||||
</CardHeader>
|
||||
<CardContent className="gap-2">
|
||||
{MOCK_ITEMS.map((item, i) => (
|
||||
<View key={i} className="flex-row justify-between py-2">
|
||||
<Text className="text-gray-700">{item.description} × {item.qty}</Text>
|
||||
<Text className="font-medium text-gray-900">${item.total.toLocaleString()}</Text>
|
||||
<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>
|
||||
))}
|
||||
<View className="mt-2 border-t border-border pt-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="text-muted-foreground">Subtotal</Text>
|
||||
<Text className="text-gray-900">${MOCK_SUBTOTAL.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="text-muted-foreground">Tax (10%)</Text>
|
||||
<Text className="text-gray-900">${MOCK_TAX.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="font-semibold text-gray-900">Total</Text>
|
||||
<Text className="font-semibold text-gray-900">${MOCK_TOTAL.toLocaleString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button className="mb-3 bg-primary" onPress={() => {}}>
|
||||
<Text className="text-primary-foreground font-medium">Send to contacts</Text>
|
||||
</Button>
|
||||
<Button variant="outline" onPress={() => router.back()}>
|
||||
<Text className="font-medium">Back to list</Text>
|
||||
</Button>
|
||||
|
||||
<Text className="text-muted-foreground mt-6 mb-2 text-sm">Submissions (mock)</Text>
|
||||
<Card>
|
||||
<CardContent className="py-3">
|
||||
<Text className="font-medium text-gray-900">Vendor A — $1,450</Text>
|
||||
<Text className="text-muted-foreground text-sm">Submitted Sep 15, 2022</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</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 (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
{/* Header */}
|
||||
<StandardHeader title="Proforma" showBack />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Proforma Info Card */}
|
||||
<Card className="bg-card rounded-[12px] mb-4 border border-border">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-3 mb-3">
|
||||
<View className="bg-primary/10 p-2 rounded-[8px]">
|
||||
<DraftingCompass color="#ea580c" size={16} strokeWidth={2.5} />
|
||||
</View>
|
||||
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
|
||||
Proforma Details
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Proforma Number</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.proformaNumber}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Issued Date</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.issueDate).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Due Date</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.dueDate).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Currency</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.currency}</Text>
|
||||
</View>
|
||||
{proforma.description && (
|
||||
<View className="mt-2">
|
||||
<Text variant="muted" className="text-xs font-medium mb-1">Description</Text>
|
||||
<Text className="text-foreground text-sm">{proforma.description}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Customer Info Card */}
|
||||
<Card className="bg-card rounded-[12px] mb-4 border border-border">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-3 mb-3">
|
||||
<View className="bg-primary/10 p-2 rounded-[8px]">
|
||||
<CheckCircle2 color="#ea580c" size={16} strokeWidth={2.5} />
|
||||
</View>
|
||||
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
|
||||
Customer Information
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Name</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.customerName}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Email</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.customerEmail || "N/A"}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text variant="muted" className="text-xs font-medium">Phone</Text>
|
||||
<Text className="text-foreground font-semibold text-sm">{proforma.customerPhone || "N/A"}</Text>
|
||||
</View>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
273
app/register.tsx
273
app/register.tsx
|
|
@ -1,40 +1,251 @@
|
|||
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, CardDescription } from '@/components/ui/card';
|
||||
import { Mail, ArrowLeft, UserPlus } from '@/lib/icons';
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
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() {
|
||||
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 (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
<ScreenWrapper className="bg-background">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1"
|
||||
>
|
||||
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
|
||||
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Create account</CardTitle>
|
||||
<CardDescription className="mt-1">Register with the same account format as the web app.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="gap-3">
|
||||
<Button className="min-h-12 rounded-xl bg-primary">
|
||||
<UserPlus color="#ffffff" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
|
||||
</Button>
|
||||
<Button variant="outline" className="min-h-12 rounded-xl border-border">
|
||||
<Text className="font-medium text-gray-700">Continue with Google</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Pressable onPress={() => router.push('/login')} className="mt-2">
|
||||
<Text className="text-center text-primary font-medium">Already have an account? Sign in</Text>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingHorizontal:16 , paddingBottom: 10 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="flex-row justify-end mt-4">
|
||||
<Pressable
|
||||
onPress={() => setLanguageModalVisible(true)}
|
||||
className="p-2 rounded-full bg-card border border-border"
|
||||
>
|
||||
<Globe color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
|
||||
</Pressable>
|
||||
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
|
||||
<ArrowLeft color="#71717a" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center mb-10">
|
||||
<Text
|
||||
variant="h2"
|
||||
className="mt-6 font-bold text-foreground text-center"
|
||||
>
|
||||
Create Account
|
||||
</Text>
|
||||
<Text variant="muted" className="mt-2 text-center">
|
||||
Join Yaltopia and start managing your business
|
||||
</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 { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FileText, Download, ChevronRight, BarChart3 } from '@/lib/icons';
|
||||
import { MOCK_REPORTS } from '@/lib/mock-data';
|
||||
import { View, ScrollView, 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 } from "@/components/ui/card";
|
||||
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() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
|
|
@ -24,7 +26,10 @@ export default function ReportsScreen() {
|
|||
</Text>
|
||||
|
||||
{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>
|
||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
||||
|
|
@ -32,8 +37,12 @@ export default function ReportsScreen() {
|
|||
</View>
|
||||
<View className="flex-1">
|
||||
<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-xs">Generated {r.generatedAt}</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-sm">
|
||||
{r.period}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-xs">
|
||||
Generated {r.generatedAt}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Pressable className="rounded-lg bg-primary/10 p-2">
|
||||
|
|
@ -46,7 +55,11 @@ export default function ReportsScreen() {
|
|||
</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>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
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 { Settings, Bell, Globe, ChevronRight, Info } from '@/lib/icons';
|
||||
import { View, ScrollView, 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 { Settings, Bell, Globe, ChevronRight, Info } from "@/lib/icons";
|
||||
|
||||
const PRIMARY = '#ea580c';
|
||||
const PRIMARY = "#ea580c";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
|
|
@ -26,7 +28,7 @@ export default function SettingsScreen() {
|
|||
<CardContent className="gap-0">
|
||||
<Pressable
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
</Button>
|
||||
</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);
|
||||
return {
|
||||
presets: [
|
||||
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||
'nativewind/babel',
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"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 { cn } from '@/lib/utils';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Platform, Pressable } from 'react-native';
|
||||
import { TextClassContext } from "@/components/ui/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Platform, Pressable } from "react-native";
|
||||
|
||||
const buttonVariants = cva(
|
||||
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({
|
||||
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: {
|
||||
variant: {
|
||||
default: cn(
|
||||
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
|
||||
Platform.select({ web: 'hover:bg-primary/90' })
|
||||
"bg-primary active:bg-primary/90 shadow-sm dark:shadow-none shadow-black/5",
|
||||
Platform.select({ web: "hover:bg-primary/90" }),
|
||||
),
|
||||
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({
|
||||
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(
|
||||
'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({
|
||||
web: 'hover:bg-accent dark:hover:bg-input/50',
|
||||
})
|
||||
web: "hover:bg-accent dark:hover:bg-input/50",
|
||||
}),
|
||||
),
|
||||
secondary: cn(
|
||||
'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5',
|
||||
Platform.select({ web: 'hover:bg-secondary/80' })
|
||||
"bg-secondary active:bg-secondary/80 shadow-sm dark:shadow-none shadow-black/5",
|
||||
Platform.select({ web: "hover:bg-secondary/80" }),
|
||||
),
|
||||
ghost: cn(
|
||||
'active:bg-accent dark:active:bg-accent/50',
|
||||
Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' })
|
||||
"active:bg-accent dark:active:bg-accent/50",
|
||||
Platform.select({ web: "hover:bg-accent dark:hover:bg-accent/50" }),
|
||||
),
|
||||
link: '',
|
||||
link: "",
|
||||
},
|
||||
size: {
|
||||
default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),
|
||||
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',
|
||||
default: cn(
|
||||
"h-10 px-4 py-2 sm:h-9",
|
||||
Platform.select({ web: "has-[>svg]:px-3" }),
|
||||
),
|
||||
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: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const buttonTextVariants = cva(
|
||||
cn(
|
||||
'text-foreground text-sm font-medium',
|
||||
Platform.select({ web: 'pointer-events-none transition-colors' })
|
||||
"text-foreground text-sm font-medium",
|
||||
Platform.select({ web: "pointer-events-none transition-colors" }),
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-primary-foreground',
|
||||
destructive: 'text-white',
|
||||
default: "text-primary-foreground",
|
||||
destructive: "text-white",
|
||||
outline: cn(
|
||||
'group-active:text-accent-foreground',
|
||||
Platform.select({ web: 'group-hover:text-accent-foreground' })
|
||||
"group-active:text-accent-foreground",
|
||||
Platform.select({ web: "group-hover:text-accent-foreground" }),
|
||||
),
|
||||
secondary: 'text-secondary-foreground',
|
||||
ghost: 'group-active:text-accent-foreground',
|
||||
secondary: "text-secondary-foreground",
|
||||
ghost: "group-active:text-accent-foreground",
|
||||
link: cn(
|
||||
'text-primary group-active:underline',
|
||||
Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })
|
||||
"text-primary group-active:underline",
|
||||
Platform.select({
|
||||
web: "underline-offset-4 hover:underline group-hover:underline",
|
||||
}),
|
||||
),
|
||||
},
|
||||
size: {
|
||||
default: '',
|
||||
sm: '',
|
||||
lg: '',
|
||||
icon: '',
|
||||
default: "",
|
||||
sm: "",
|
||||
lg: "",
|
||||
icon: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
type ButtonProps = React.ComponentProps<typeof Pressable> &
|
||||
|
|
@ -96,7 +107,11 @@ function Button({ className, variant, size, ...props }: ButtonProps) {
|
|||
return (
|
||||
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
|
||||
<Pressable
|
||||
className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}
|
||||
className={cn(
|
||||
props.disabled && "opacity-50",
|
||||
buttonVariants({ variant, size }),
|
||||
className,
|
||||
)}
|
||||
role="button"
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
import { Text, TextClassContext } from '@/components/ui/text';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
import { Text, TextClassContext } from "@/components/ui/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { ShadowWrapper } from "../ShadowWrapper";
|
||||
|
||||
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||
return (
|
||||
<TextClassContext.Provider value="text-card-foreground">
|
||||
<View
|
||||
className={cn(
|
||||
'bg-card border-border flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-black/5',
|
||||
className
|
||||
)}
|
||||
className={cn("bg-card flex flex-col border border-border gap-4 rounded-xl ", className)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||
return <View className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: ViewProps & React.RefAttributes<View>) {
|
||||
return (
|
||||
<View className={cn("flex flex-col gap-1.5 px-6", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
|
|
@ -28,7 +31,7 @@ function CardTitle({
|
|||
<Text
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
className={cn('font-semibold leading-none', className)}
|
||||
className={cn("font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -38,15 +41,38 @@ function CardDescription({
|
|||
className,
|
||||
...props
|
||||
}: 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>) {
|
||||
return <View className={cn('px-6', className)} {...props} />;
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: ViewProps & React.RefAttributes<View>) {
|
||||
return <View className={cn("px-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||
return <View className={cn('flex flex-row items-center px-6', className)} {...props} />;
|
||||
function CardFooter({
|
||||
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 * as Slot from '@rn-primitives/slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { Platform, Text as RNText, type Role } from 'react-native';
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as Slot from "@rn-primitives/slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { Platform, Text as RNText, type Role } from "react-native";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { getMutedColor } from "@/lib/colors";
|
||||
|
||||
const textVariants = cva(
|
||||
cn(
|
||||
'text-foreground text-base',
|
||||
"text-foreground text-base font-sans",
|
||||
Platform.select({
|
||||
web: 'select-text',
|
||||
})
|
||||
web: "select-text",
|
||||
}),
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
default: "",
|
||||
h1: cn(
|
||||
'text-center text-4xl font-extrabold tracking-tight',
|
||||
Platform.select({ web: 'scroll-m-20 text-balance' })
|
||||
"text-center text-4xl font-extrabold tracking-tight",
|
||||
Platform.select({ web: "scroll-m-20 text-balance" }),
|
||||
),
|
||||
h2: cn(
|
||||
'border-border border-b pb-2 text-3xl font-semibold tracking-tight',
|
||||
Platform.select({ web: 'scroll-m-20 first:mt-0' })
|
||||
"border-border border-b pb-2 text-3xl font-semibold tracking-tight",
|
||||
Platform.select({ web: "scroll-m-20 first:mt-0" }),
|
||||
),
|
||||
h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
|
||||
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',
|
||||
h3: cn(
|
||||
"text-2xl font-semibold tracking-tight",
|
||||
Platform.select({ web: "scroll-m-20" }),
|
||||
),
|
||||
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(
|
||||
'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',
|
||||
large: 'text-lg font-semibold',
|
||||
small: 'text-sm font-medium leading-none',
|
||||
muted: 'text-muted-foreground text-sm',
|
||||
lead: "text-muted-foreground text-xl",
|
||||
large: "text-lg font-semibold",
|
||||
small: "text-sm font-medium leading-none",
|
||||
muted: "text-muted-foreground text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
type TextVariantProps = VariantProps<typeof textVariants>;
|
||||
|
||||
type TextVariant = NonNullable<TextVariantProps['variant']>;
|
||||
type TextVariant = NonNullable<TextVariantProps["variant"]>;
|
||||
|
||||
const ROLE: Partial<Record<TextVariant, Role>> = {
|
||||
h1: 'heading',
|
||||
h2: 'heading',
|
||||
h3: 'heading',
|
||||
h4: 'heading',
|
||||
blockquote: Platform.select({ web: 'blockquote' as Role }),
|
||||
code: Platform.select({ web: 'code' as Role }),
|
||||
h1: "heading",
|
||||
h2: "heading",
|
||||
h3: "heading",
|
||||
h4: "heading",
|
||||
blockquote: Platform.select({ web: "blockquote" as Role }),
|
||||
code: Platform.select({ web: "code" as Role }),
|
||||
};
|
||||
|
||||
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
|
||||
h1: '1',
|
||||
h2: '2',
|
||||
h3: '3',
|
||||
h4: '4',
|
||||
h1: "1",
|
||||
h2: "2",
|
||||
h3: "3",
|
||||
h4: "4",
|
||||
};
|
||||
|
||||
const TextClassContext = React.createContext<string | undefined>(undefined);
|
||||
|
|
@ -67,7 +75,7 @@ const TextClassContext = React.createContext<string | undefined>(undefined);
|
|||
function Text({
|
||||
className,
|
||||
asChild = false,
|
||||
variant = 'default',
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof RNText> &
|
||||
TextVariantProps &
|
||||
|
|
@ -76,11 +84,14 @@ function Text({
|
|||
}) {
|
||||
const textClass = React.useContext(TextClassContext);
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
const isDark = useColorScheme() === 'dark';
|
||||
const mutedStyle = variant === "muted" ? { color: getMutedColor(isDark) } : undefined;
|
||||
return (
|
||||
<Component
|
||||
className={cn(textVariants({ variant }), textClass, className)}
|
||||
role={variant ? ROLE[variant] : undefined}
|
||||
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
|
||||
style={mutedStyle}
|
||||
{...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 {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 24 90% 48%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 63%;
|
||||
--background: 255,255,255;
|
||||
--foreground: 37,22,21;
|
||||
--card: 255,255,255;
|
||||
--card-foreground: 37,22,21;
|
||||
--popover: 255,249,244;
|
||||
--popover-foreground: 37,22,21;
|
||||
--primary: 228, 98, 18;
|
||||
--primary-foreground: 255,249,244;
|
||||
--secondary: 255,226,216;
|
||||
--secondary-foreground: 66,37,32;
|
||||
--muted: 255,234,227;
|
||||
--muted-foreground: 118,93,88;
|
||||
--accent: 255,222,207;
|
||||
--accent-foreground: 66,37,32;
|
||||
--destructive: 239,67,94;
|
||||
--destructive-foreground: 255,249,244;
|
||||
--border: 237,213,209;
|
||||
--input: 244,206,198;
|
||||
--ring: 233,87,82;
|
||||
--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 {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 70.9% 59.4%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 300 0% 45%;
|
||||
--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%;
|
||||
--background: 22,22,22;
|
||||
--foreground: 255,241,238;
|
||||
--card: 31,31,31;
|
||||
--card-foreground: 255,241,238;
|
||||
--popover: 31, 31, 31;
|
||||
--popover-foreground: 255,241,238;
|
||||
--primary: 228, 98, 18;
|
||||
--primary-foreground: 0,0,0;
|
||||
--secondary: 15, 9, 11;
|
||||
--secondary-foreground: 255,241,238;
|
||||
--muted: 9, 5, 6;
|
||||
--muted-foreground: 176,153,151;
|
||||
--accent: 228, 125, 251;
|
||||
--accent-foreground: 255,249,244;
|
||||
--destructive: 255,40,90;
|
||||
--destructive-foreground: 255,249,244;
|
||||
--border: 95, 95, 95;
|
||||
--input: 16,9,10;
|
||||
--ring: 151,170,81;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
Upload,
|
||||
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 = {
|
||||
light: {
|
||||
background: 'hsl(0 0% 100%)',
|
||||
foreground: 'hsl(0 0% 3.9%)',
|
||||
card: 'hsl(0 0% 100%)',
|
||||
cardForeground: 'hsl(0 0% 3.9%)',
|
||||
popover: 'hsl(0 0% 100%)',
|
||||
popoverForeground: 'hsl(0 0% 3.9%)',
|
||||
primary: 'hsl(24 90% 48%)',
|
||||
primaryForeground: 'hsl(0 0% 100%)',
|
||||
secondary: 'hsl(0 0% 96.1%)',
|
||||
secondaryForeground: 'hsl(0 0% 9%)',
|
||||
muted: 'hsl(0 0% 96.1%)',
|
||||
mutedForeground: 'hsl(0 0% 45.1%)',
|
||||
accent: 'hsl(0 0% 96.1%)',
|
||||
accentForeground: 'hsl(0 0% 9%)',
|
||||
destructive: 'hsl(0 84.2% 60.2%)',
|
||||
border: 'hsl(0 0% 89.8%)',
|
||||
input: 'hsl(0 0% 89.8%)',
|
||||
ring: 'hsl(0 0% 63%)',
|
||||
radius: '0.625rem',
|
||||
chart1: 'hsl(12 76% 61%)',
|
||||
chart2: 'hsl(173 58% 39%)',
|
||||
chart3: 'hsl(197 37% 24%)',
|
||||
chart4: 'hsl(43 74% 66%)',
|
||||
chart5: 'hsl(27 87% 67%)',
|
||||
background: "rgba(255,243,238,1)",
|
||||
foreground: "rgba(37,22,21,1)",
|
||||
card: "rgba(255,249,244,1)",
|
||||
cardForeground: "rgba(37,22,21,1)",
|
||||
popover: "rgba(255,249,244,1)",
|
||||
popoverForeground: "rgba(37,22,21,1)",
|
||||
primary: "rgba(233,87,82,1)",
|
||||
primaryForeground: "rgba(255,249,244,1)",
|
||||
secondary: "rgba(255,226,216,1)",
|
||||
secondaryForeground: "rgba(66,37,32,1)",
|
||||
muted: "rgba(255,234,227,1)",
|
||||
mutedForeground: "rgba(118,93,88,1)",
|
||||
accent: "rgba(255,222,207,1)",
|
||||
accentForeground: "rgba(66,37,32,1)",
|
||||
destructive: "rgba(239,67,94,1)",
|
||||
destructiveForeground: "rgba(255,249,244,1)",
|
||||
border: "rgba(237,213,209,1)",
|
||||
input: "rgba(244,206,198,1)",
|
||||
ring: "rgba(233,87,82,1)",
|
||||
radius: "0.625rem",
|
||||
},
|
||||
dark: {
|
||||
background: 'hsl(0 0% 3.9%)',
|
||||
foreground: 'hsl(0 0% 98%)',
|
||||
card: 'hsl(0 0% 3.9%)',
|
||||
cardForeground: 'hsl(0 0% 98%)',
|
||||
popover: 'hsl(0 0% 3.9%)',
|
||||
popoverForeground: 'hsl(0 0% 98%)',
|
||||
primary: 'hsl(0 0% 98%)',
|
||||
primaryForeground: 'hsl(0 0% 9%)',
|
||||
secondary: 'hsl(0 0% 14.9%)',
|
||||
secondaryForeground: 'hsl(0 0% 98%)',
|
||||
muted: 'hsl(0 0% 14.9%)',
|
||||
mutedForeground: 'hsl(0 0% 63.9%)',
|
||||
accent: 'hsl(0 0% 14.9%)',
|
||||
accentForeground: 'hsl(0 0% 98%)',
|
||||
destructive: 'hsl(0 70.9% 59.4%)',
|
||||
border: 'hsl(0 0% 14.9%)',
|
||||
input: 'hsl(0 0% 14.9%)',
|
||||
ring: 'hsl(300 0% 45%)',
|
||||
radius: '0.625rem',
|
||||
chart1: 'hsl(220 70% 50%)',
|
||||
chart2: 'hsl(160 60% 45%)',
|
||||
chart3: 'hsl(30 80% 55%)',
|
||||
chart4: 'hsl(280 65% 60%)',
|
||||
chart5: 'hsl(340 75% 55%)',
|
||||
background: "rgba(25,21,21,1)",
|
||||
foreground: "rgba(255,241,238,1)",
|
||||
card: "rgba(35,30,29,1)",
|
||||
cardForeground: "rgba(255,241,238,1)",
|
||||
popover: "rgba(35,30,29,1)",
|
||||
popoverForeground: "rgba(255,241,238,1)",
|
||||
primary: "rgba(233,87,82,1)",
|
||||
primaryForeground: "rgba(0,0,0,1)",
|
||||
secondary: "rgba(16,9,10,1)",
|
||||
secondaryForeground: "rgba(255,241,238,1)",
|
||||
muted: "rgba(9,5,5,1)",
|
||||
mutedForeground: "rgba(176,153,151,1)",
|
||||
accent: "rgba(197,156,221,1)",
|
||||
accentForeground: "rgba(255,249,244,1)",
|
||||
destructive: "rgba(255,40,90,1)",
|
||||
destructiveForeground: "rgba(255,249,244,1)",
|
||||
border: "rgba(105,93,92,1)",
|
||||
input: "rgba(16,9,10,1)",
|
||||
ring: "rgba(151,170,81,1)",
|
||||
radius: "0.625rem",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const NAV_THEME: Record<'light' | 'dark', Theme> = {
|
||||
export const NAV_THEME: Record<"light" | "dark", Theme> = {
|
||||
light: {
|
||||
...DefaultTheme,
|
||||
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",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@react-navigation/native": "^7.1.28",
|
||||
"@rn-primitives/portal": "^1.3.0",
|
||||
"@rn-primitives/slot": "^1.2.0",
|
||||
"babel-preset-expo": "^54.0.10",
|
||||
"@expo/metro-runtime": "~4.0.1",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/datetimepicker": "8.2.0",
|
||||
"@react-native-google-signin/google-signin": "^16.1.2",
|
||||
"@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",
|
||||
"clsx": "^2.1.1",
|
||||
"expo": "~54.0.33",
|
||||
"expo-constants": "^18.0.13",
|
||||
"expo-linking": "^8.0.11",
|
||||
"expo-router": "^6.0.23",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"lucide-react-native": "^0.575.0",
|
||||
"nativewind": "^4.2.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "^2.30.0",
|
||||
"react-native-reanimated": "^4.2.2",
|
||||
"react-native-safe-area-context": "^5.6.2",
|
||||
"react-native-screens": "^4.23.0",
|
||||
"react-native-svg": "^15.15.3",
|
||||
"react-native-web": "^0.21.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"expo": "~52.0.35",
|
||||
"expo-camera": "~16.0.18",
|
||||
"expo-constants": "~17.0.7",
|
||||
"expo-linear-gradient": "~14.0.2",
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-router": "~4.0.17",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.9",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"lucide-react-native": "^0.471.0",
|
||||
"nativewind": "^4.1.23",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.7",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-get-sms-android": "^2.1.0",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"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": {
|
||||
"@types/react": "~19.1.0",
|
||||
"@types/react": "~18.3.12",
|
||||
"patch-package": "^8.0.1",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2"
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.3.3",
|
||||
"@react-native-community/cli": "latest"
|
||||
},
|
||||
"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} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ['./App.tsx', './index.ts', './components/**/*.{js,jsx,ts,tsx}', './app/**/*.{js,jsx,ts,tsx}'],
|
||||
presets: [require('nativewind/preset')],
|
||||
darkMode: "class",
|
||||
content: [
|
||||
"./App.tsx",
|
||||
"./index.ts",
|
||||
"./components/**/*.{js,jsx,ts,tsx}",
|
||||
"./app/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
presets: [require("nativewind/preset")],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['DMSans-Regular', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
border: "rgba(var(--border), <alpha-value>)",
|
||||
input: "rgba(var(--input), <alpha-value>)",
|
||||
ring: "rgba(var(--ring), <alpha-value>)",
|
||||
background: "rgba(var(--background), <alpha-value>)",
|
||||
foreground: "rgba(var(--foreground), <alpha-value>)",
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
DEFAULT: "rgba(var(--primary), <alpha-value>)",
|
||||
foreground: "rgba(var(--primary-foreground), <alpha-value>)",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
DEFAULT: "rgba(var(--secondary), <alpha-value>)",
|
||||
foreground: "rgba(var(--secondary-foreground), <alpha-value>)",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
DEFAULT: "rgba(var(--destructive), <alpha-value>)",
|
||||
foreground: "rgba(var(--destructive-foreground), <alpha-value>)",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
DEFAULT: "rgba(var(--muted), <alpha-value>)",
|
||||
foreground: "rgba(var(--muted-foreground), <alpha-value>)",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
DEFAULT: "rgba(var(--accent), <alpha-value>)",
|
||||
foreground: "rgba(var(--accent-foreground), <alpha-value>)",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
DEFAULT: "rgba(var(--popover), <alpha-value>)",
|
||||
foreground: "rgba(var(--popover-foreground), <alpha-value>)",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
DEFAULT: "rgba(var(--card), <alpha-value>)",
|
||||
foreground: "rgba(var(--card-foreground), <alpha-value>)",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
borderWidth: {
|
||||
hairline: hairlineWidth(),
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user