Compare commits
No commits in common. "be2bde41a28bb3478e1571652fef31688da2c5b5" and "837e3f4646f471b7d25e96b76f635c4c94764b61" have entirely different histories.
be2bde41a2
...
837e3f4646
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -39,6 +39,3 @@ yarn-error.*
|
|||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
*.apk
|
||||
*.aab
|
||||
|
|
|
|||
55
app.json
55
app.json
|
|
@ -1,54 +1 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
{"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"}}
|
||||
|
|
@ -1,127 +1,62 @@
|
|||
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";
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Home, ScanLine, FileText, Wallet, User } from '@/lib/icons';
|
||||
|
||||
const ACTIVE_TINT = "rgba(228, 98, 18, 1)";
|
||||
const INACTIVE_TINT = "#94a3b8";
|
||||
const NAV_BG = '#2d2d2d';
|
||||
const ACTIVE_TINT = '#ea580c';
|
||||
const INACTIVE_TINT = '#a1a1aa';
|
||||
|
||||
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={{
|
||||
headerShown: false,
|
||||
tabBarShowLabel: true,
|
||||
headerStyle: { backgroundColor: NAV_BG },
|
||||
headerTintColor: '#ffffff',
|
||||
headerTitleStyle: { fontWeight: '600', fontSize: 18 },
|
||||
tabBarStyle: { backgroundColor: NAV_BG, paddingTop: 8 },
|
||||
tabBarActiveTintColor: ACTIVE_TINT,
|
||||
tabBarInactiveTintColor: INACTIVE_TINT,
|
||||
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,
|
||||
},
|
||||
tabBarLabelStyle: { fontSize: 11 },
|
||||
tabBarShowLabel: true,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
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={{
|
||||
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>
|
||||
),
|
||||
title: 'Home',
|
||||
tabBarLabel: 'Home',
|
||||
tabBarIcon: ({ color, size }) => <Home color={color} size={size ?? 22} strokeWidth={2} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="scan"
|
||||
options={{
|
||||
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>
|
||||
),
|
||||
title: 'Scan Invoice',
|
||||
tabBarLabel: 'Scan',
|
||||
tabBarIcon: ({ color, size }) => <ScanLine color={color} size={size ?? 22} strokeWidth={2} />,
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
),
|
||||
title: 'Proforma',
|
||||
tabBarLabel: 'Proforma',
|
||||
tabBarIcon: ({ color, size }) => <FileText color={color} size={size ?? 22} strokeWidth={2} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="news"
|
||||
name="payments"
|
||||
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}
|
||||
title: 'Payments',
|
||||
tabBarLabel: 'Payments',
|
||||
tabBarIcon: ({ color, size }) => <Wallet color={color} size={size ?? 22} strokeWidth={2} />,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarLabel: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => <User color={color} size={size ?? 22} strokeWidth={2} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -1,354 +1,147 @@
|
|||
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";
|
||||
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',
|
||||
};
|
||||
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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 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>
|
||||
</View>
|
||||
|
||||
<View className="mt-4 flex-row gap-4">
|
||||
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="p-1.5 bg-white/20 rounded-lg">
|
||||
<Clock color="white" size={12} strokeWidth={2.5} />
|
||||
<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>
|
||||
<Text className="text-white text-[12px] font-semibold">
|
||||
Pending
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-white font-bold text-xl mt-2">
|
||||
${stats.pending.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="p-1.5 bg-white/20 rounded-lg">
|
||||
<DollarSign color="white" size={12} strokeWidth={2.5} />
|
||||
</View>
|
||||
<Text className="text-white text-[12px] font-semibold">
|
||||
Income
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-white font-bold text-xl mt-2">
|
||||
${stats.totalRevenue.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
{/* 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>
|
||||
<View className="flex-row border-t border-border">
|
||||
<Pressable
|
||||
onPress={() => nav.go("history")}
|
||||
className="px-4 py-2 rounded-full"
|
||||
className="flex-1 flex-row items-center gap-3 px-5 py-4"
|
||||
onPress={() => router.push('/(tabs)/payments')}
|
||||
>
|
||||
<Text className="text-primary font-bold text-xs">View all</Text>
|
||||
<View className="rounded-xl bg-primary/15 p-2">
|
||||
<Clock color={PRIMARY} size={20} strokeWidth={2} />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-muted-foreground text-xs">Waiting for pay</Text>
|
||||
<Text className="font-semibold text-gray-900">${EARNINGS_SUMMARY.waitingAmount.toLocaleString()}</Text>
|
||||
<Text className="text-muted-foreground text-xs">{EARNINGS_SUMMARY.waitingCount} Waiting invoice</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<View className="w-px bg-border" />
|
||||
<Pressable className="flex-1 flex-row items-center gap-3 px-5 py-4">
|
||||
<View className="rounded-xl bg-emerald-500/15 p-2">
|
||||
<DollarSign color="#059669" size={20} strokeWidth={2} />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-muted-foreground text-xs">Paid this month</Text>
|
||||
<Text className="font-semibold text-gray-900">${EARNINGS_SUMMARY.paidThisMonth.toLocaleString()}</Text>
|
||||
<Text className="text-muted-foreground text-xs">{EARNINGS_SUMMARY.paidCount} Paid invoice</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Filters */}
|
||||
<View className="mb-6">
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8 }}
|
||||
<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')}
|
||||
>
|
||||
{["All", "Draft", "Pending", "Paid", "Overdue", "Cancelled"].map(
|
||||
(filter) => (
|
||||
<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) => (
|
||||
<Pressable
|
||||
key={filter}
|
||||
onPress={() => setActiveFilter(filter)}
|
||||
className={`rounded-[4px] px-4 py-1.5 ${
|
||||
activeFilter === filter
|
||||
? "bg-primary"
|
||||
: "bg-card border border-border"
|
||||
}`}
|
||||
className={`rounded-full px-4 py-2.5 ${filter === 'Waiting' ? 'bg-primary' : 'bg-white'} border border-border`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs font-bold ${
|
||||
activeFilter === filter
|
||||
? "text-white"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
className={
|
||||
filter === 'Waiting' ? 'text-primary-foreground text-sm font-medium' : 'text-muted-foreground text-sm'
|
||||
}
|
||||
>
|
||||
{filter}
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 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 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" />
|
||||
</View>
|
||||
<View className="flex-1 mt-[-20px]">
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold"
|
||||
>
|
||||
{inv.customerName}
|
||||
</Text>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="mt-1 text-[11px] font-medium opacity-70"
|
||||
>
|
||||
{new Date(inv.issueDate).toLocaleDateString()} ·
|
||||
Proforma
|
||||
</Text>
|
||||
{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>
|
||||
<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 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>
|
||||
</ShadowWrapper>
|
||||
</Pressable>
|
||||
))
|
||||
) : (
|
||||
<View className="py-10">
|
||||
<EmptyState
|
||||
title="No transactions yet"
|
||||
description="Your recent activity will show up here once you create and send invoices."
|
||||
hint="Create a proforma invoice to get started."
|
||||
actionLabel="Create Proforma"
|
||||
onActionPress={() => nav.go("proforma/create")}
|
||||
previewLines={3}
|
||||
/>
|
||||
))}
|
||||
|
||||
<View 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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,313 +0,0 @@
|
|||
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,302 +1,76 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
const PRIMARY = '#ea580c';
|
||||
|
||||
export default function PaymentsScreen() {
|
||||
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);
|
||||
|
||||
// Check permissions
|
||||
const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]);
|
||||
|
||||
const fetchPayments = 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.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();
|
||||
const matched = MOCK_PAYMENTS.filter((p) => p.matched);
|
||||
const pending = MOCK_PAYMENTS.filter((p) => !p.matched);
|
||||
|
||||
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 }}
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
||||
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 className="text-muted-foreground mb-5 text-base">
|
||||
Match payment SMS (e.g. bank or Telebirr) to invoices for quick reconciliation.
|
||||
</Text>
|
||||
|
||||
<Button className="mb-5 min-h-12 rounded-xl bg-primary">
|
||||
<ScanLine color="#ffffff" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 text-primary-foreground font-medium">Scan SMS now</Text>
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* 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
|
||||
<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="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 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>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
119
app/(tabs)/profile.tsx
Normal file
119
app/(tabs)/profile.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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,251 +1,55 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
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"
|
||||
};
|
||||
const PRIMARY = '#ea580c';
|
||||
|
||||
export default function ProformaScreen() {
|
||||
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);
|
||||
|
||||
// Check permissions
|
||||
const canCreateProformas = hasPermission(permissions, PERMISSION_MAP["proforma:create"]);
|
||||
|
||||
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"
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<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 className="text-muted-foreground mb-5 text-base">
|
||||
Create or select proforma requests and share with contacts via email or SMS.
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,208 +1,66 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
const NAV_BG = "#ffffff";
|
||||
const PRIMARY = '#ea580c';
|
||||
|
||||
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 (
|
||||
<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}
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text className="text-white font-bold uppercase tracking-widest">
|
||||
Enable Camera
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<Pressable onPress={() => nav.back()} className="mt-6">
|
||||
<Text className="text-muted-foreground font-bold">Go Back</Text>
|
||||
</Pressable>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
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 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>
|
||||
|
||||
{/* 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" />
|
||||
<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>
|
||||
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
|
||||
Align Invoice Within Frame
|
||||
</Text>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
</CameraView>
|
||||
</View>
|
||||
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
222
app/_layout.tsx
222
app/_layout.tsx
|
|
@ -1,218 +1,38 @@
|
|||
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;
|
||||
}
|
||||
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';
|
||||
|
||||
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={colorScheme === "dark" ? "light" : "dark"} />
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
headerStyle: { backgroundColor: '#2d2d2d' },
|
||||
headerTintColor: '#ffffff',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="sms-scan"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="proforma/[id]"
|
||||
options={{ title: "Proforma request" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="payments/[id]"
|
||||
options={{ title: "Payment" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="notifications/index"
|
||||
options={{ title: "Notifications" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="notifications/settings"
|
||||
options={{ title: "Notification settings" }}
|
||||
/>
|
||||
<Stack.Screen name="help" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="history" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="company" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="company-details"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ title: "Sign in", headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="register"
|
||||
options={{ title: "Create account", headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="invoices/[id]"
|
||||
options={{ title: "Invoice" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="reports/index"
|
||||
options={{ title: "Reports" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="documents/index"
|
||||
options={{ title: "Documents" }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
||||
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="edit-profile"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.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>
|
||||
<SirouBridge />
|
||||
<BackupGuard />
|
||||
<PortalHost />
|
||||
<Toast />
|
||||
</View>
|
||||
</ThemeProvider>
|
||||
</SirouRouterProvider>
|
||||
</NavigationContainer>
|
||||
</NavigationIndependentTree>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,220 +0,0 @@
|
|||
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
169
app/company.tsx
|
|
@ -1,169 +0,0 @@
|
|||
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,16 +1,14 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
const PRIMARY = "#ea580c";
|
||||
const PRIMARY = '#ea580c';
|
||||
|
||||
export default function DocumentsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
|
|
@ -25,32 +23,21 @@ 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>
|
||||
|
|
@ -58,11 +45,7 @@ export default function DocumentsScreen() {
|
|||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 rounded-xl border-border"
|
||||
onPress={() => nav.back()}
|
||||
>
|
||||
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
|
||||
<Text className="font-medium">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
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
59
app/help.tsx
|
|
@ -1,59 +0,0 @@
|
|||
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
194
app/history.tsx
|
|
@ -1,194 +0,0 @@
|
|||
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,281 +1,100 @@
|
|||
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";
|
||||
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 },
|
||||
];
|
||||
|
||||
export default function InvoiceDetailScreen() {
|
||||
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 (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Invoice Details" showBack />
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<ActivityIndicator color="#ea580c" size="large" />
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Invoice Details" showBack />
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text variant="muted">Invoice not found</Text>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const invoice = MOCK_INVOICES.find((i) => i.id === id);
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Invoice Details" showBack />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
|
||||
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} />
|
||||
<Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
|
||||
<CardContent className="p-5">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<FileText color={PRIMARY} size={22} strokeWidth={2} />
|
||||
<Text className="font-semibold text-gray-900">Invoice #{invoice?.invoiceNumber ?? id}</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`rounded-[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 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 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">
|
||||
<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="rgba(255,255,255,0.9)" size={12} />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
Due {new Date(invoice.dueDate).toLocaleDateString()}
|
||||
</Text>
|
||||
<Calendar color="#71717a" size={16} strokeWidth={2} />
|
||||
<Text className="text-muted-foreground text-sm">Due {invoice?.dueDate ?? '—'}</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 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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<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="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>
|
||||
<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>
|
||||
|
||||
{/* 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 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>
|
||||
<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 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>
|
||||
</ShadowWrapper>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
308
app/login.tsx
308
app/login.tsx
|
|
@ -1,288 +1,40 @@
|
|||
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,
|
||||
});
|
||||
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';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
const { language, setLanguage } = useLanguageStore();
|
||||
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!identifier || !password) {
|
||||
toast.error(
|
||||
"Required Fields",
|
||||
"Please enter both identifier and password",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const isEmail = identifier.includes("@");
|
||||
const payload = isEmail
|
||||
? { email: identifier, password }
|
||||
: { phone: identifier, password };
|
||||
|
||||
try {
|
||||
// Using the new api.auth.login which is powered by simple-api
|
||||
const response = await api.auth.login({ body: payload });
|
||||
|
||||
// Store user, access token, refresh token, and permissions
|
||||
// // Fetch roles to get permissions
|
||||
// const rolesResponse = await rbacApi.roles();
|
||||
// const userRole = response.user.role;
|
||||
// const roleData = rolesResponse.find((r: any) => r.role === userRole);
|
||||
// const permissions = roleData ? roleData.permissions : [];
|
||||
const permissions: string[] = [];
|
||||
|
||||
// Store user, access token, refresh token, and permissions
|
||||
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
|
||||
toast.success("Welcome Back!", "You have successfully logged in.");
|
||||
|
||||
// Explicitly navigate to home
|
||||
nav.go("(tabs)");
|
||||
} catch (err: any) {
|
||||
toast.error("Login Failed", err.message || "Invalid credentials");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await GoogleSignin.hasPlayServices();
|
||||
const userInfo = await GoogleSignin.signIn();
|
||||
|
||||
// In newer versions of the library, the response is in data
|
||||
// If using idToken, ensure you configured webClientId
|
||||
const idToken = userInfo.data?.idToken || (userInfo as any).idToken;
|
||||
|
||||
if (!idToken) {
|
||||
throw new Error("Failed to obtain Google ID Token");
|
||||
}
|
||||
|
||||
// Send idToken to our new consolidated endpoint
|
||||
const response = await api.auth.googleMobile({ body: { idToken } });
|
||||
|
||||
// Fetch roles to get permissions
|
||||
// const rolesResponse = await rbacApi.roles();
|
||||
// const userRole = response.user.role;
|
||||
// const roleData = rolesResponse.find((r: any) => r.role === userRole);
|
||||
// const permissions = roleData ? roleData.permissions : [];
|
||||
const permissions: string[] = [];
|
||||
|
||||
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
|
||||
toast.success("Welcome!", "Signed in with Google.");
|
||||
nav.go("(tabs)");
|
||||
} catch (error: any) {
|
||||
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
|
||||
// User cancelled the login flow
|
||||
} else if (error.code === statusCodes.IN_PROGRESS) {
|
||||
toast.error("Login in progress", "Please wait...");
|
||||
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
|
||||
toast.error("Play Services", "Google Play Services not available");
|
||||
} else {
|
||||
console.error("[Login] Google Error:", error);
|
||||
toast.error(
|
||||
"Google Login Failed",
|
||||
error.message || "An error occurred",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1"
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 10 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<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} />
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<LanguageModal
|
||||
visible={languageModalVisible}
|
||||
current={language}
|
||||
onSelect={(lang) => setLanguage(lang)}
|
||||
onClose={() => setLanguageModalVisible(false)}
|
||||
/>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,134 +1,38 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
type NotificationItem = {
|
||||
id: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
message?: string;
|
||||
createdAt?: string;
|
||||
read?: boolean;
|
||||
};
|
||||
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 },
|
||||
];
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
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);
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
{MOCK_NOTIFICATIONS.map((n) => (
|
||||
<Card key={n.id} className={`mb-2 ${!n.read ? 'border-primary/30' : ''}`}>
|
||||
<CardContent className="py-3">
|
||||
<Text className="font-semibold text-foreground">
|
||||
{item.title ?? "Notification"}
|
||||
</Text>
|
||||
{message ? (
|
||||
<Text className="text-muted-foreground mt-1 text-sm">{message}</Text>
|
||||
) : null}
|
||||
{time ? (
|
||||
<Text className="text-muted-foreground mt-1 text-xs">{time}</Text>
|
||||
) : null}
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<StandardHeader
|
||||
showBack
|
||||
title="Notifications"
|
||||
rightAction="notificationsSettings"
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(i) => i.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.4}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View className="px-[16px] py-6">
|
||||
<EmptyState
|
||||
title="No notifications"
|
||||
description="You don't have any notifications yet."
|
||||
hint="Pull to refresh to check for new notifications."
|
||||
previewLines={3}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
loadingMore ? (
|
||||
<View className="py-4">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ScreenWrapper>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,221 +1,41 @@
|
|||
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;
|
||||
};
|
||||
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';
|
||||
|
||||
export default function NotificationSettingsScreen() {
|
||||
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);
|
||||
}
|
||||
};
|
||||
const [invoiceReminders, setInvoiceReminders] = useState(true);
|
||||
const [daysBeforeDue, setDaysBeforeDue] = useState(2);
|
||||
const [newsAlerts, setNewsAlerts] = useState(true);
|
||||
const [reportReady, setReportReady] = useState(true);
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<StandardHeader showBack title="Notification settings" />
|
||||
|
||||
{loading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
<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} />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 110 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View className="mb-5">
|
||||
<Text variant="muted" className="text-xs font-semibold mb-2 px-1">
|
||||
Preferences
|
||||
</Text>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<View className="flex-row items-center px-4 py-3 border-b border-border/40">
|
||||
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||
<Bell size={17} color="#ea580c" />
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-900">News & announcements</Text>
|
||||
<Switch value={newsAlerts} onValueChange={setNewsAlerts} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-foreground font-medium">
|
||||
Invoice reminders
|
||||
</Text>
|
||||
<Text variant="muted" className="text-xs mt-0.5">
|
||||
Get reminders before invoices are due
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={invoiceReminders}
|
||||
onValueChange={setInvoiceReminders}
|
||||
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
|
||||
thumbColor="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="px-4 py-3 border-b border-border/40">
|
||||
<View className="flex-row items-center mb-2">
|
||||
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||
<CalendarSearch size={17} color="#ea580c" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-foreground font-medium">
|
||||
Days before due date
|
||||
</Text>
|
||||
<Text variant="muted" className="text-xs mt-0.5">
|
||||
Currently: {daysBeforeDueDate} days
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => setDaysModalVisible(true)}>
|
||||
<ChevronRight size={18} color="#ea580c" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center px-4 py-3 border-b border-border/40">
|
||||
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||
<Newspaper size={17} color="#ea580c" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-foreground font-medium">News alerts</Text>
|
||||
<Text variant="muted" className="text-xs mt-0.5">
|
||||
Product updates and announcements
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={newsAlerts}
|
||||
onValueChange={setNewsAlerts}
|
||||
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
|
||||
thumbColor="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center px-4 py-3">
|
||||
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||
<FileText size={17} color="#ea580c" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-foreground font-medium">Report ready</Text>
|
||||
<Text variant="muted" className="text-xs mt-0.5">
|
||||
Notify when reports are generated
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={reportReady}
|
||||
onValueChange={setReportReady}
|
||||
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
|
||||
thumbColor="#ffffff"
|
||||
/>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-900">Report ready</Text>
|
||||
<Switch value={reportReady} onValueChange={setReportReady} />
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View className="absolute bottom-0 pb-10 left-0 right-0 p-4 bg-background border-t border-border">
|
||||
<Button className="bg-primary" onPress={onSave} disabled={saving || loading}>
|
||||
<Text className="text-white font-semibold">
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Text>
|
||||
<Button variant="outline" onPress={() => router.back()}>
|
||||
<Text className="font-medium">Back</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>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,795 +0,0 @@
|
|||
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,131 +1,44 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
export default function PaymentDetailScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const { id } = useSirouParams<AppRoutes, "payments/[id]">();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||
<Pressable
|
||||
onPress={() => nav.back()}
|
||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||
>
|
||||
<ArrowLeft color="#0f172a" size={20} />
|
||||
</Pressable>
|
||||
<Text variant="h4" className="text-foreground font-semibold">
|
||||
Payment Match
|
||||
</Text>
|
||||
<View className="w-9" />
|
||||
<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>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Card className=" overflow-hidden rounded-[6px] border-0 bg-primary">
|
||||
<View className="p-5">
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
||||
<Wallet color="white" size={18} strokeWidth={2.5} />
|
||||
<View className="flex-row justify-between py-2">
|
||||
<Text className="text-muted-foreground">Source</Text>
|
||||
<Text className="text-gray-900">Telebirr</Text>
|
||||
</View>
|
||||
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
|
||||
<Text className={`text-[10px] font-bold text-white`}>
|
||||
Pending Match
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text variant="small" className="text-white/70 mb-0.5">
|
||||
Received Amount
|
||||
</Text>
|
||||
<Text variant="h3" className="text-white font-bold mb-3">
|
||||
$2,000.00
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
TXN-9982734
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-3 w-[1px] bg-white/60" />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
Telebirr SMS
|
||||
</Text>
|
||||
<View className="flex-row justify-between py-2">
|
||||
<Text className="text-muted-foreground">Date</Text>
|
||||
<Text className="text-gray-900">Sep 11, 2022</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between py-2">
|
||||
<Text className="text-muted-foreground">Associated invoice</Text>
|
||||
<Text className="text-amber-600">Not linked</Text>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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 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>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
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
362
app/profile.tsx
|
|
@ -1,362 +0,0 @@
|
|||
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,329 +1,66 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
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"
|
||||
};
|
||||
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;
|
||||
|
||||
export default function ProformaDetailScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const router = useRouter();
|
||||
const { id } = useLocalSearchParams();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [proforma, setProforma] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProforma();
|
||||
}, [id]);
|
||||
|
||||
const fetchProforma = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.proforma.getById({ params: { id: id as string } });
|
||||
setProforma(data);
|
||||
} catch (error: any) {
|
||||
console.error("[ProformaDetail] Error:", error);
|
||||
toast.error("Error", "Failed to load proforma details");
|
||||
setProforma(dummyData); // Use dummy data for testing
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Proforma" showBack />
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<ActivityIndicator color="#ea580c" size="large" />
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!proforma) {
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StandardHeader title="Proforma" showBack />
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text variant="muted">Proforma not found</Text>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const subtotal =
|
||||
proforma.items?.reduce(
|
||||
(acc: number, item: any) => acc + (Number(item.total) || 0),
|
||||
0,
|
||||
) || 0;
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
|
||||
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>
|
||||
<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>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View className="mt-3 pt-3 border-t border-border/40 gap-2">
|
||||
<View className="mt-2 border-t border-border pt-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>
|
||||
<Text className="text-muted-foreground">Subtotal</Text>
|
||||
<Text className="text-gray-900">${MOCK_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>
|
||||
<Text className="text-muted-foreground">Tax (10%)</Text>
|
||||
<Text className="text-gray-900">${MOCK_TAX.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>
|
||||
<Text className="font-semibold text-gray-900">Total</Text>
|
||||
<Text className="font-semibold text-gray-900">${MOCK_TOTAL.toLocaleString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,622 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,584 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
271
app/register.tsx
271
app/register.tsx
|
|
@ -1,251 +1,40 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
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 (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1"
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingHorizontal:16 , paddingBottom: 10 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<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>
|
||||
</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} />
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<LanguageModal
|
||||
visible={languageModalVisible}
|
||||
current={language}
|
||||
onSelect={(lang) => setLanguage(lang)}
|
||||
onClose={() => setLanguageModalVisible(false)}
|
||||
/>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
const PRIMARY = "#ea580c";
|
||||
const PRIMARY = '#ea580c';
|
||||
|
||||
export default function ReportsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
|
|
@ -26,10 +24,7 @@ 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">
|
||||
|
|
@ -37,12 +32,8 @@ 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">
|
||||
|
|
@ -55,11 +46,7 @@ export default function ReportsScreen() {
|
|||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 rounded-xl border-border"
|
||||
onPress={() => nav.back()}
|
||||
>
|
||||
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
|
||||
<Text className="font-medium">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
const PRIMARY = "#ea580c";
|
||||
const PRIMARY = '#ea580c';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
|
|
@ -28,7 +26,7 @@ export default function SettingsScreen() {
|
|||
<CardContent className="gap-0">
|
||||
<Pressable
|
||||
className="flex-row items-center justify-between border-b border-border py-3"
|
||||
onPress={() => nav.go("notifications/settings")}
|
||||
onPress={() => router.push('/notifications/settings')}
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Bell color="#71717a" size={20} strokeWidth={2} />
|
||||
|
|
@ -62,16 +60,11 @@ 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={() => nav.back()}
|
||||
>
|
||||
<Button variant="outline" className="mt-6 rounded-xl border-border" onPress={() => router.back()}>
|
||||
<Text className="font-medium">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
|
|
|
|||
366
app/sms-scan.tsx
366
app/sms-scan.tsx
|
|
@ -1,366 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 395 KiB |
|
|
@ -2,9 +2,8 @@ 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"],
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
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,102 +1,91 @@
|
|||
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 dark:shadow-none shadow-black/5",
|
||||
Platform.select({ web: "hover:bg-primary/90" }),
|
||||
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
|
||||
Platform.select({ web: 'hover:bg-primary/90' })
|
||||
),
|
||||
destructive: cn(
|
||||
"bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm dark:shadow-none shadow-black/5",
|
||||
'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm 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 dark:shadow-none 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 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 dark:shadow-none shadow-black/5",
|
||||
Platform.select({ web: "hover:bg-secondary/80" }),
|
||||
'bg-secondary active:bg-secondary/80 shadow-sm 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> &
|
||||
|
|
@ -107,11 +96,7 @@ 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,26 +1,23 @@
|
|||
import { Text, TextClassContext } from "@/components/ui/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { ShadowWrapper } from "../ShadowWrapper";
|
||||
import { Text, TextClassContext } from '@/components/ui/text';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||
return (
|
||||
<TextClassContext.Provider value="text-card-foreground">
|
||||
<View
|
||||
className={cn("bg-card flex flex-col border border-border gap-4 rounded-xl ", className)}
|
||||
className={cn(
|
||||
'bg-card border-border flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-black/5',
|
||||
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({
|
||||
|
|
@ -31,7 +28,7 @@ function CardTitle({
|
|||
<Text
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
className={cn("font-semibold leading-none", className)}
|
||||
className={cn('font-semibold leading-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -41,38 +38,15 @@ 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-4", className)} {...props} />;
|
||||
function CardContent({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||
return <View className={cn('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}
|
||||
/>
|
||||
);
|
||||
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,73 +1,65 @@
|
|||
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";
|
||||
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';
|
||||
|
||||
const textVariants = cva(
|
||||
cn(
|
||||
"text-foreground text-base font-sans",
|
||||
'text-foreground text-base',
|
||||
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);
|
||||
|
|
@ -75,7 +67,7 @@ const TextClassContext = React.createContext<string | undefined>(undefined);
|
|||
function Text({
|
||||
className,
|
||||
asChild = false,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof RNText> &
|
||||
TextVariantProps &
|
||||
|
|
@ -84,14 +76,11 @@ 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
24
eas.json
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"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,47 +4,57 @@
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
--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;
|
||||
--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%;
|
||||
--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: 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;
|
||||
--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%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
# 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` |
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
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
119
lib/api.ts
|
|
@ -1,119 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
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),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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,42 +34,4 @@ export {
|
|||
BarChart3,
|
||||
Upload,
|
||||
UserPlus,
|
||||
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";
|
||||
} from 'lucide-react-native';
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
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),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
|
||||
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
163
lib/routes.ts
|
|
@ -1,163 +0,0 @@
|
|||
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,53 +1,61 @@
|
|||
import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
|
||||
import { DarkTheme, DefaultTheme, type Theme } from '@react-navigation/native';
|
||||
|
||||
export const THEME = {
|
||||
light: {
|
||||
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",
|
||||
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%)',
|
||||
},
|
||||
dark: {
|
||||
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",
|
||||
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%)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const NAV_THEME: Record<"light" | "dark", Theme> = {
|
||||
export const NAV_THEME: Record<'light' | 'dark', Theme> = {
|
||||
light: {
|
||||
...DefaultTheme,
|
||||
colors: {
|
||||
|
|
@ -71,36 +79,3 @@ 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);
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
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 }),
|
||||
};
|
||||
10179
package-lock.json
generated
10179
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
74
package.json
74
package.json
|
|
@ -4,58 +4,42 @@
|
|||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"postinstall": "patch-package"
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.3.12",
|
||||
"patch-package": "^8.0.1",
|
||||
"@types/react": "~19.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.3.3",
|
||||
"@react-native-community/cli": "latest"
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
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,81 +1,73 @@
|
|||
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: "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>)",
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: "rgba(var(--primary), <alpha-value>)",
|
||||
foreground: "rgba(var(--primary-foreground), <alpha-value>)",
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "rgba(var(--secondary), <alpha-value>)",
|
||||
foreground: "rgba(var(--secondary-foreground), <alpha-value>)",
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "rgba(var(--destructive), <alpha-value>)",
|
||||
foreground: "rgba(var(--destructive-foreground), <alpha-value>)",
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "rgba(var(--muted), <alpha-value>)",
|
||||
foreground: "rgba(var(--muted-foreground), <alpha-value>)",
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "rgba(var(--accent), <alpha-value>)",
|
||||
foreground: "rgba(var(--accent-foreground), <alpha-value>)",
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "rgba(var(--popover), <alpha-value>)",
|
||||
foreground: "rgba(var(--popover-foreground), <alpha-value>)",
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "rgba(var(--card), <alpha-value>)",
|
||||
foreground: "rgba(var(--card-foreground), <alpha-value>)",
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
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