added a whole lot
This commit is contained in:
parent
1b41dbd97a
commit
7162fb87e8
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -39,3 +39,6 @@ yarn-error.*
|
||||||
# generated native folders
|
# generated native folders
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Tabs, router } from "expo-router";
|
import { Tabs, router } from "expo-router";
|
||||||
import { Home, ScanLine, FileText, Wallet, History, Scan } from "@/lib/icons";
|
import { Home, ScanLine, FileText, Wallet, Newspaper, Scan } from "@/lib/icons";
|
||||||
import { Platform, View, Pressable } from "react-native";
|
import { Platform, View, Pressable } from "react-native";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
|
||||||
|
|
@ -98,12 +98,12 @@ export default function TabsLayout() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="history"
|
name="news"
|
||||||
options={{
|
options={{
|
||||||
tabBarLabel: "History",
|
tabBarLabel: "News",
|
||||||
tabBarIcon: ({ color, focused }) => (
|
tabBarIcon: ({ color, focused }) => (
|
||||||
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}>
|
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}>
|
||||||
<History
|
<Newspaper
|
||||||
color={color}
|
color={color}
|
||||||
size={18}
|
size={18}
|
||||||
strokeWidth={focused ? 2.5 : 2}
|
strokeWidth={focused ? 2.5 : 2}
|
||||||
|
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { View, ScrollView, Pressable } from "react-native";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
Wallet,
|
|
||||||
ChevronRight,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Clock,
|
|
||||||
} from "@/lib/icons";
|
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
|
||||||
import { MOCK_INVOICES, MOCK_PAYMENTS } from "@/lib/mock-data";
|
|
||||||
|
|
||||||
export default function HistoryScreen() {
|
|
||||||
// Combine and sort by date (mocking real activity)
|
|
||||||
const activity = [
|
|
||||||
...MOCK_INVOICES.map((inv) => ({
|
|
||||||
id: `inv-${inv.id}`,
|
|
||||||
type: "Invoice Sent",
|
|
||||||
title: inv.recipient,
|
|
||||||
amount: inv.amount,
|
|
||||||
date: inv.createdAt,
|
|
||||||
icon: <FileText size={16} color="#ea580c" />,
|
|
||||||
})),
|
|
||||||
...MOCK_PAYMENTS.map((pay) => ({
|
|
||||||
id: `pay-${pay.id}`,
|
|
||||||
type: "Payment Received",
|
|
||||||
title: pay.source,
|
|
||||||
amount: pay.amount,
|
|
||||||
date: pay.date,
|
|
||||||
icon: <Wallet size={16} color="#10b981" />,
|
|
||||||
})),
|
|
||||||
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScreenWrapper className="bg-background">
|
|
||||||
<StandardHeader />
|
|
||||||
<ScrollView
|
|
||||||
className="flex-1"
|
|
||||||
contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
<View className="flex-row gap-2 mb-10">
|
|
||||||
<ShadowWrapper className="flex-1">
|
|
||||||
<View className="bg-card rounded-[10px] p-3">
|
|
||||||
<View className="h-8 w-8 bg-emerald-500/10 rounded-[6px] items-center justify-center mb-1">
|
|
||||||
<TrendingUp color="#10b981" size={16} />
|
|
||||||
</View>
|
|
||||||
<Text variant="muted" className="font-semibold">
|
|
||||||
Inflow
|
|
||||||
</Text>
|
|
||||||
<Text variant="h3" className="text-foreground">
|
|
||||||
$4,120
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</ShadowWrapper>
|
|
||||||
<ShadowWrapper className="flex-1">
|
|
||||||
<View className="bg-card rounded-[10px] p-3">
|
|
||||||
<View className="h-8 w-8 bg-amber-500/10 rounded-[6px] items-center justify-center mb-1">
|
|
||||||
<TrendingDown color="#f59e0b" size={16} />
|
|
||||||
</View>
|
|
||||||
<Text variant="muted" className="font-semibold">
|
|
||||||
Pending
|
|
||||||
</Text>
|
|
||||||
<Text variant="h3" className="text-foreground">
|
|
||||||
$1,540
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</ShadowWrapper>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text variant="h4" className="text-foreground mb-2">
|
|
||||||
Recent Activity
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View className="gap-2">
|
|
||||||
{activity.map((item) => (
|
|
||||||
<ShadowWrapper key={item.id} level="xs">
|
|
||||||
<Card className="rounded-[6px] bg-card overflow-hidden">
|
|
||||||
<View className="flex-row items-center p-3">
|
|
||||||
<View className="bg-secondary/50 p-1 rounded-[6px] mr-4 border border-border/10">
|
|
||||||
{item.icon}
|
|
||||||
</View>
|
|
||||||
<View className="flex-1 mt-[-10px]">
|
|
||||||
<Text variant="p" className="text-foreground font-semibold">
|
|
||||||
{item.title}
|
|
||||||
</Text>
|
|
||||||
<Text variant="muted" className="text-xs font-medium">
|
|
||||||
{item.type} · {item.date}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="items-end mt-[-10px]">
|
|
||||||
<Text variant="p" className="text-foreground font-semibold">
|
|
||||||
{item.type.includes("Payment") ? "+" : ""}$
|
|
||||||
{item.amount.toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
<View className="flex-row items-center gap-1">
|
|
||||||
<Clock color="#000" size={12} />
|
|
||||||
<Text className="text-[10px] text-foreground font-semibold">
|
|
||||||
Success
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
</ShadowWrapper>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</ScreenWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { View, ScrollView, Pressable } from "react-native";
|
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { EARNINGS_SUMMARY, MOCK_INVOICES } from "@/lib/mock-data";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { router } from "expo-router";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Send,
|
Send,
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
BarChart3,
|
Briefcase,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
|
|
@ -17,21 +18,62 @@ import {
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
const statusColor: Record<string, string> = {
|
|
||||||
Waiting: "bg-amber-500/30 text-amber-600",
|
|
||||||
Paid: "bg-emerald-500/30 text-emerald-600",
|
|
||||||
Draft: "bg-secondary text-muted-foreground",
|
|
||||||
Unpaid: "bg-red-500/30 text-red-600",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const [activeFilter, setActiveFilter] = useState("All");
|
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>();
|
||||||
|
|
||||||
const filteredInvoices =
|
React.useEffect(() => {
|
||||||
activeFilter === "All"
|
fetchStats();
|
||||||
? MOCK_INVOICES
|
}, []);
|
||||||
: MOCK_INVOICES.filter((inv) => inv.status === activeFilter);
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
|
|
@ -61,7 +103,7 @@ export default function HomeScreen() {
|
||||||
<View className="mt-2 flex-row items-baseline">
|
<View className="mt-2 flex-row items-baseline">
|
||||||
<Text className="text-white text-2xl font-medium">$</Text>
|
<Text className="text-white text-2xl font-medium">$</Text>
|
||||||
<Text className="ml-1 text-4xl font-bold text-white">
|
<Text className="ml-1 text-4xl font-bold text-white">
|
||||||
{EARNINGS_SUMMARY.balance.toLocaleString()}
|
{stats.total.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -76,7 +118,7 @@ export default function HomeScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-white font-bold text-xl mt-2">
|
<Text className="text-white font-bold text-xl mt-2">
|
||||||
${EARNINGS_SUMMARY.waitingAmount.toLocaleString()}
|
${stats.pending.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl">
|
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl">
|
||||||
|
|
@ -89,7 +131,7 @@ export default function HomeScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-white font-bold text-xl mt-2">
|
<Text className="text-white font-bold text-xl mt-2">
|
||||||
${EARNINGS_SUMMARY.paidThisMonth.toLocaleString()}
|
${stats.totalRevenue.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -101,23 +143,24 @@ export default function HomeScreen() {
|
||||||
{/* Circular Quick Actions Section */}
|
{/* Circular Quick Actions Section */}
|
||||||
<View className="mb-4 flex-row justify-around items-center px-2">
|
<View className="mb-4 flex-row justify-around items-center px-2">
|
||||||
<QuickAction
|
<QuickAction
|
||||||
icon={<Plus color="#000" size={20} strokeWidth={1.5} />}
|
icon={<Briefcase color="#000" size={20} strokeWidth={1.5} />}
|
||||||
label="Scan"
|
label="Company"
|
||||||
onPress={() => router.push("/(tabs)/scan")}
|
onPress={() => nav.go("company")}
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
<QuickAction
|
||||||
icon={<Send color="#000" size={20} strokeWidth={1.5} />}
|
icon={<Send color="#000" size={20} strokeWidth={1.5} />}
|
||||||
label="Send"
|
label="Send"
|
||||||
onPress={() => router.push("/(tabs)/proforma")}
|
onPress={() => nav.go("(tabs)/proforma")}
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
<QuickAction
|
||||||
icon={<HistoryIcon color="#000" size={20} strokeWidth={1.5} />}
|
icon={<HistoryIcon color="#000" size={20} strokeWidth={1.5} />}
|
||||||
label="History"
|
label="History"
|
||||||
onPress={() => router.push("/(tabs)/history")}
|
onPress={() => nav.go("history")}
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
<QuickAction
|
||||||
icon={<BarChart3 color="#000" size={20} strokeWidth={1.5} />}
|
icon={<Plus color="#000" size={20} strokeWidth={1.5} />}
|
||||||
label="Analytics"
|
label="Create Proforma"
|
||||||
|
onPress={() => nav.go("proforma/create")}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -126,7 +169,10 @@ export default function HomeScreen() {
|
||||||
<Text variant="h4" className="text-foreground tracking-tight">
|
<Text variant="h4" className="text-foreground tracking-tight">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable className="px-4 py-2 rounded-full">
|
<Pressable
|
||||||
|
onPress={() => nav.go("history")}
|
||||||
|
className="px-4 py-2 rounded-full"
|
||||||
|
>
|
||||||
<Text className="text-primary font-bold text-xs">View all</Text>
|
<Text className="text-primary font-bold text-xs">View all</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -138,11 +184,16 @@ export default function HomeScreen() {
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{ gap: 8 }}
|
contentContainerStyle={{ gap: 8 }}
|
||||||
>
|
>
|
||||||
{["All", "Paid", "Waiting", "Unpaid"].map((filter) => (
|
{["All", "Draft", "Pending", "Paid", "Overdue", "Cancelled"].map(
|
||||||
|
(filter) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={filter}
|
key={filter}
|
||||||
onPress={() => setActiveFilter(filter)}
|
onPress={() => setActiveFilter(filter)}
|
||||||
className={`rounded-[4px] px-4 py-1.5 ${activeFilter === filter ? "bg-primary" : "bg-card border border-border"}`}
|
className={`rounded-[4px] px-4 py-1.5 ${
|
||||||
|
activeFilter === filter
|
||||||
|
? "bg-primary"
|
||||||
|
: "bg-card border border-border"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`text-xs font-bold ${
|
className={`text-xs font-bold ${
|
||||||
|
|
@ -154,18 +205,22 @@ export default function HomeScreen() {
|
||||||
{filter}
|
{filter}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Transactions List */}
|
{/* Transactions List */}
|
||||||
<View className="gap-2">
|
<View className="gap-2">
|
||||||
{filteredInvoices.length > 0 ? (
|
{loading ? (
|
||||||
filteredInvoices.map((inv) => (
|
<ActivityIndicator color="#ea580c" className="py-20" />
|
||||||
|
) : invoices.length > 0 ? (
|
||||||
|
invoices.map((inv) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
onPress={() => router.push(`/invoices/${inv.id}`)}
|
onPress={() => nav.go("invoices/[id]", { id: inv.id })}
|
||||||
>
|
>
|
||||||
|
<ShadowWrapper level="xs">
|
||||||
<Card className="overflow-hidden rounded-[6px] bg-card">
|
<Card className="overflow-hidden rounded-[6px] bg-card">
|
||||||
<CardContent className="flex-row items-center py-3 px-2">
|
<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">
|
<View className="bg-secondary/40 rounded-[6px] p-2 mr-2 border border-border/10">
|
||||||
|
|
@ -180,13 +235,14 @@ export default function HomeScreen() {
|
||||||
variant="p"
|
variant="p"
|
||||||
className="text-foreground font-semibold"
|
className="text-foreground font-semibold"
|
||||||
>
|
>
|
||||||
{inv.recipient}
|
{inv.customerName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
variant="muted"
|
variant="muted"
|
||||||
className="mt-1 text-[11px] font-medium opacity-70"
|
className="mt-1 text-[11px] font-medium opacity-70"
|
||||||
>
|
>
|
||||||
{inv.dueDate} · Proforma
|
{new Date(inv.issueDate).toLocaleDateString()} ·
|
||||||
|
Proforma
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="items-end mt-[-20px]">
|
<View className="items-end mt-[-20px]">
|
||||||
|
|
@ -194,10 +250,18 @@ export default function HomeScreen() {
|
||||||
variant="p"
|
variant="p"
|
||||||
className="text-foreground font-semibold"
|
className="text-foreground font-semibold"
|
||||||
>
|
>
|
||||||
${inv.amount.toLocaleString()}
|
${Number(inv.amount).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
<View
|
<View
|
||||||
className={`mt-1 rounded-[5px] px-3 py-1 border border-border/50 ${statusColor[inv.status]}`}
|
className={`mt-1 rounded-[5px] px-3 py-1 border border-border/50 ${
|
||||||
|
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">
|
<Text className="text-[9px] font-semibold uppercase tracking-widest">
|
||||||
{inv.status}
|
{inv.status}
|
||||||
|
|
@ -206,6 +270,7 @@ export default function HomeScreen() {
|
||||||
</View>
|
</View>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</ShadowWrapper>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
311
app/(tabs)/news.tsx
Normal file
311
app/(tabs)/news.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
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 { api, newsApi } from "@/lib/api";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
|
||||||
|
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>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<StandardHeader />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 120 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor="#ea580c"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 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="bg-card/50 rounded-[12px] p-8 items-center border border-border/50">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">
|
||||||
|
No latest items
|
||||||
|
</Text>
|
||||||
|
</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-20 items-center">
|
||||||
|
<Newspaper
|
||||||
|
color="#94a3b8"
|
||||||
|
size={48}
|
||||||
|
strokeWidth={1}
|
||||||
|
className="mb-4 opacity-20"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="font-bold uppercase tracking-widest text-[10px]"
|
||||||
|
>
|
||||||
|
No news items available
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,201 @@
|
||||||
import React from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { View, ScrollView, Pressable } from "react-native";
|
import {
|
||||||
import { router } from "expo-router";
|
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 { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { MOCK_PAYMENTS } from "@/lib/mock-data";
|
import { api } from "@/lib/api";
|
||||||
import { ScanLine, CheckCircle2, Wallet, ChevronRight } from "@/lib/icons";
|
import {
|
||||||
|
ScanLine,
|
||||||
|
CheckCircle2,
|
||||||
|
Wallet,
|
||||||
|
ChevronRight,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
|
||||||
const PRIMARY = "#ea580c";
|
const PRIMARY = "#ea580c";
|
||||||
|
|
||||||
|
interface Payment {
|
||||||
|
id: string;
|
||||||
|
transactionId: string;
|
||||||
|
amount:
|
||||||
|
| {
|
||||||
|
value: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
| number;
|
||||||
|
currency: string;
|
||||||
|
paymentDate: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
notes: string;
|
||||||
|
isFlagged: boolean;
|
||||||
|
flagReason: string;
|
||||||
|
flagNotes: string;
|
||||||
|
receiptPath: string;
|
||||||
|
userId: string;
|
||||||
|
invoiceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PaymentsScreen() {
|
export default function PaymentsScreen() {
|
||||||
const matched = MOCK_PAYMENTS.filter((p) => p.matched);
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const pending = MOCK_PAYMENTS.filter((p) => !p.matched);
|
const [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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
|
|
@ -22,84 +204,78 @@ export default function PaymentsScreen() {
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
|
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScroll={({ nativeEvent }) => {
|
||||||
|
const isCloseToBottom =
|
||||||
|
nativeEvent.layoutMeasurement.height +
|
||||||
|
nativeEvent.contentOffset.y >=
|
||||||
|
nativeEvent.contentSize.height - 20;
|
||||||
|
if (isCloseToBottom) loadMore();
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={400}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
|
||||||
|
onPress={() => nav.go("sms-scan")}
|
||||||
>
|
>
|
||||||
<Button className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30">
|
|
||||||
<ScanLine color="#ffffff" size={18} strokeWidth={2.5} />
|
<ScanLine color="#ffffff" size={18} strokeWidth={2.5} />
|
||||||
<Text className=" text-white text-xs font-semibold uppercase tracking-widest">
|
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
|
||||||
Scan SMS
|
Scan SMS
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Flagged Section */}
|
||||||
|
{categorized.flagged.length > 0 && (
|
||||||
|
<>
|
||||||
|
<View className="mb-4 flex-row items-center gap-3">
|
||||||
|
<Text variant="h4" className="text-red-600">
|
||||||
|
Flagged Payments
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="gap-2 mb-6">
|
||||||
|
{categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Section */}
|
||||||
<View className="mb-4 flex-row items-center gap-3">
|
<View className="mb-4 flex-row items-center gap-3">
|
||||||
<Text variant="h4" className="text-foreground">
|
<Text variant="h4" className="text-foreground">
|
||||||
Pending Match
|
Pending Match
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View className="gap-2 mb-6">
|
||||||
<View className="gap-2">
|
{categorized.pending.length > 0 ? (
|
||||||
{pending.map((pay) => (
|
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
|
||||||
<Pressable
|
) : (
|
||||||
key={pay.id}
|
<Text variant="muted" className="text-center py-4">
|
||||||
onPress={() => router.push(`/payments/${pay.id}`)}
|
No pending matches.
|
||||||
>
|
|
||||||
<Card className="rounded-[10px] bg-card overflow-hidden">
|
|
||||||
<View className="flex-row items-center p-3">
|
|
||||||
<View className="mr-2 rounded-[6px] bg-primary/10 p-2 border border-primary/5">
|
|
||||||
<Wallet color={PRIMARY} size={18} strokeWidth={2.5} />
|
|
||||||
</View>
|
|
||||||
<View className="flex-1 mt-[-15px]">
|
|
||||||
<Text variant="p" className="text-foreground font-bold">
|
|
||||||
${pay.amount.toLocaleString()}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="muted" className="text-xs">
|
)}
|
||||||
{pay.source} · {pay.date}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="bg-amber-500/10 px-4 py-2 rounded-[6px]">
|
|
||||||
<Text className="text-amber-700 text-[10px] font-semibold">
|
|
||||||
Match
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="mb-4 mt-4 flex-row items-center gap-3">
|
{/* Reconciled Section */}
|
||||||
|
<View className="mb-4 flex-row items-center gap-3">
|
||||||
<Text variant="h4" className="text-foreground">
|
<Text variant="h4" className="text-foreground">
|
||||||
Reconciled
|
Reconciled
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="gap-2">
|
<View className="gap-2">
|
||||||
{matched.map((pay) => (
|
{categorized.reconciled.length > 0 ? (
|
||||||
<Card
|
categorized.reconciled.map((p) =>
|
||||||
key={pay.id}
|
renderPaymentItem(p, "reconciled"),
|
||||||
className="rounded-[10px] bg-card overflow-hidden opacity-80"
|
)
|
||||||
>
|
) : (
|
||||||
<View className="flex-row items-center p-3">
|
<Text variant="muted" className="text-center py-4">
|
||||||
<View className="mr-2 rounded-[6px] bg-emerald-500/10 p-2 border border-emerald-500/5">
|
No reconciled payments.
|
||||||
<CheckCircle2 color="#10b981" size={18} strokeWidth={2.5} />
|
|
||||||
</View>
|
|
||||||
<View className="flex-1 mt-[-15px]">
|
|
||||||
<Text variant="p" className="text-foreground font-bold">
|
|
||||||
${pay.amount.toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
<Text variant="muted" className="text-xs">
|
|
||||||
{pay.source} · {pay.date}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<ChevronRight
|
|
||||||
className="text-foreground"
|
{loadingMore && (
|
||||||
size={18}
|
<View className="py-4">
|
||||||
strokeWidth={2}
|
<ActivityIndicator color={PRIMARY} />
|
||||||
color="#000"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,209 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { View, ScrollView, Pressable } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
ListRenderItem,
|
||||||
|
} from "react-native";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { MOCK_PROFORMA } from "@/lib/mock-data";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { router } from "expo-router";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import {
|
import { Plus, Send, FileText, Clock } from "@/lib/icons";
|
||||||
Plus,
|
|
||||||
Send,
|
|
||||||
FileText,
|
|
||||||
ChevronRight,
|
|
||||||
Clock,
|
|
||||||
History,
|
|
||||||
DraftingCompass,
|
|
||||||
} from "@/lib/icons";
|
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
|
||||||
|
interface ProformaItem {
|
||||||
|
id: string;
|
||||||
|
proformaNumber: string;
|
||||||
|
customerName: string;
|
||||||
|
amount: any;
|
||||||
|
currency: string;
|
||||||
|
issueDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProformaScreen() {
|
export default function ProformaScreen() {
|
||||||
const [activeTab, setActiveTab] = React.useState("All");
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const newData = response.data;
|
||||||
|
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);
|
||||||
|
} 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 dateStr = new Date(item.issueDate).toLocaleDateString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
|
||||||
<StandardHeader />
|
|
||||||
<ScrollView
|
|
||||||
className="flex-1"
|
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
|
|
||||||
onPress={() => router.push("/proforma/create")}
|
|
||||||
>
|
|
||||||
<Plus color="white" size={18} strokeWidth={2.5} />
|
|
||||||
<Text className=" text-white text-sm font-semibold uppercase tracking-widest">
|
|
||||||
Create Proforma
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* <View className="flex-row gap-4 mb-8">
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setActiveTab("All")}
|
|
||||||
className={`flex-1 py-3 rounded-[10px] items-center border ${activeTab === "All" ? "bg-primary border-primary" : "bg-card border-border"}`}
|
|
||||||
>
|
|
||||||
<DraftingCompass
|
|
||||||
color={activeTab === "All" ? "white" : "#94a3b8"}
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
className={`mt-1 text-[10px] font-black uppercase tracking-widest ${activeTab === "All" ? "text-white" : "text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setActiveTab("Pending")}
|
|
||||||
className={`flex-1 py-3 rounded-[10px] items-center border ${activeTab === "Pending" ? "bg-primary border-primary" : "bg-card border-border"}`}
|
|
||||||
>
|
|
||||||
<History
|
|
||||||
color={activeTab === "Pending" ? "white" : "#94a3b8"}
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
className={`mt-1 text-[10px] font-black uppercase tracking-widest ${activeTab === "Pending" ? "text-white" : "text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
Pending
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View> */}
|
|
||||||
|
|
||||||
<View className="gap-3">
|
|
||||||
{MOCK_PROFORMA.map((item) => (
|
|
||||||
<Pressable
|
<Pressable
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onPress={() => router.push(`/proforma/${item.id}`)}
|
onPress={() => nav.go("proforma/[id]", { id: item.id })}
|
||||||
|
className="mb-3"
|
||||||
>
|
>
|
||||||
<Card className="rounded-[6px] bg-card overflow-hidden">
|
<Card className="rounded-[10px] bg-card overflow-hidden">
|
||||||
<View className="p-3">
|
<View className="p-4">
|
||||||
<View className="flex-row justify-between items-start">
|
<View className="flex-row justify-between items-start mb-3">
|
||||||
<View className="bg-secondary/50 p-2 rounded-[10px]">
|
<View className="bg-primary/10 p-2.5 rounded-[12px] border border-primary/5">
|
||||||
<FileText color="#000" size={18} />
|
<FileText color="#ea580c" size={20} strokeWidth={2.5} />
|
||||||
</View>
|
</View>
|
||||||
<View className="bg-emerald-500/10 px-3 py-1 rounded-[6px] border border-emerald-500/20">
|
<View className="items-end">
|
||||||
<Text className="text-emerald-600 text-[10px] font-bold uppercase tracking-tighter">
|
<Text variant="p" className="text-foreground font-bold text-lg">
|
||||||
{item.sentCount} Shared
|
{item.currency || "$"}
|
||||||
|
{amountVal?.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest mt-0.5"
|
||||||
|
>
|
||||||
|
{item.proformaNumber}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text variant="p" className="text-foreground font-semibold">
|
<Text variant="p" className="text-foreground font-bold mb-1">
|
||||||
{item.title}
|
{item.customerName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="muted" className="mb-4 line-clamp-2 text-xs">
|
{item.description && (
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-xs line-clamp-1 mb-4 opacity-70"
|
||||||
|
>
|
||||||
{item.description}
|
{item.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<View className="h-[1px] bg-border mb-4 opacity-50" />
|
<View className="h-[1px] bg-border/50 mb-4" />
|
||||||
|
|
||||||
<View className="flex-row justify-between items-center">
|
<View className="flex-row justify-between items-center">
|
||||||
<View className="flex-row gap-4">
|
<View className="flex-row items-center gap-2">
|
||||||
<View className="flex-row items-center gap-1.5">
|
<View className="p-1 bg-secondary/80 rounded-md">
|
||||||
<Clock
|
<Clock color="#64748b" size={12} strokeWidth={2.5} />
|
||||||
className="text-muted-foreground"
|
</View>
|
||||||
color="#000"
|
<Text variant="muted" className="text-[11px] font-medium">
|
||||||
size={12}
|
Issued: {dateStr}
|
||||||
/>
|
|
||||||
<Text variant="muted" className="text-xs">
|
|
||||||
{item.deadline}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-row items-center gap-3">
|
<Pressable
|
||||||
<Pressable className="bg-secondary px-2 py-1 rounded-[6px] border border-border/50 flex-row items-center gap-1">
|
className="bg-primary/10 px-3.5 py-1.5 rounded-full border border-primary/20 flex-row items-center gap-1.5"
|
||||||
<Send color="#000" size={12} />
|
onPress={(e) => {
|
||||||
<Text variant="muted" className="text-xs">
|
e.stopPropagation();
|
||||||
|
// Handle share
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send color="#ea580c" size={12} strokeWidth={2.5} />
|
||||||
|
<Text className="text-primary text-[11px] font-bold uppercase tracking-tight">
|
||||||
Share
|
Share
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader />
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={proformas}
|
||||||
|
renderItem={renderProformaItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Button
|
||||||
|
className="mb-6 h-12 rounded-[14px] bg-primary shadow-lg shadow-primary/30"
|
||||||
|
onPress={() => nav.go("proforma/create")}
|
||||||
|
>
|
||||||
|
<Plus color="white" size={20} strokeWidth={3} />
|
||||||
|
<Text className="text-white text-sm font-bold uppercase tracking-widest ml-1">
|
||||||
|
Create New Proforma
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
loadingMore ? (
|
||||||
|
<ActivityIndicator color="#ea580c" className="py-4" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
!loading ? (
|
||||||
|
<View className="py-20 items-center">
|
||||||
|
<Text variant="muted">No proformas found</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
) : (
|
||||||
|
<View className="py-20">
|
||||||
|
<ActivityIndicator size="large" color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,36 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
Platform,
|
Platform,
|
||||||
Dimensions,
|
ActivityIndicator,
|
||||||
StyleSheet,
|
Alert,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Zap, Camera as CameraIcon } from "@/lib/icons";
|
import { X, Zap, Camera as CameraIcon, ScanLine } from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import { router, useNavigation } from "expo-router";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { BASE_URL } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
const { width } = Dimensions.get("window");
|
|
||||||
|
|
||||||
export default function ScanScreen() {
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
|
||||||
const [torch, setTorch] = useState(false);
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const NAV_BG = "#ffffff";
|
const NAV_BG = "#ffffff";
|
||||||
|
|
||||||
// Hide tab bar when on this screen (since it's a dedicated camera view)
|
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(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({ tabBarStyle: { display: "none" } });
|
||||||
tabBarStyle: {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
|
|
@ -54,34 +56,88 @@ export default function ScanScreen() {
|
||||||
};
|
};
|
||||||
}, [navigation]);
|
}, [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) {
|
if (!permission) {
|
||||||
// Camera permissions are still loading.
|
|
||||||
return <View className="flex-1 bg-black" />;
|
return <View className="flex-1 bg-black" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!permission.granted) {
|
if (!permission.granted) {
|
||||||
// Camera permissions are not granted yet.
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background items-center justify-center p-10">
|
<ScreenWrapper className="bg-background items-center justify-center p-10 px-16">
|
||||||
<View className="bg-primary/10 p-6 rounded-[24px] mb-6">
|
<View className="bg-primary/10 p-6 rounded-[24px] mb-6">
|
||||||
<CameraIcon className="text-primary" size={48} strokeWidth={1.5} />
|
<CameraIcon className="text-primary" size={48} strokeWidth={1.5} />
|
||||||
</View>
|
</View>
|
||||||
<Text variant="h2" className="text-center mb-2">
|
<Text variant="h2" className="text-center mb-2">
|
||||||
Camera Access
|
Camera Access
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="muted" className="text-center mb-10 leading-6">
|
<Text variant="muted" className="text-center mb-10 leading-6 px-10">
|
||||||
We need your permission to use the camera to scan invoices and
|
We need your permission to use the camera to scan invoices and
|
||||||
receipts automatically.
|
receipts automatically.
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
className="w-full h-14 rounded-[12px] bg-primary"
|
className="w-3/4 h-14 rounded-[12px] bg-primary px-10"
|
||||||
onPress={requestPermission}
|
onPress={requestPermission}
|
||||||
>
|
>
|
||||||
<Text className="text-white font-bold uppercase tracking-widest">
|
<Text className="text-white font-bold uppercase tracking-widest">
|
||||||
Enable Camera
|
Enable Camera
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Pressable onPress={() => router.back()} className="mt-6">
|
<Pressable onPress={() => nav.back()} className="mt-6">
|
||||||
<Text className="text-muted-foreground font-bold">Go Back</Text>
|
<Text className="text-muted-foreground font-bold">Go Back</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
|
|
@ -91,11 +147,13 @@ export default function ScanScreen() {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-black">
|
<View className="flex-1 bg-black">
|
||||||
<CameraView
|
<CameraView
|
||||||
style={StyleSheet.absoluteFill}
|
ref={cameraRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
facing="back"
|
facing="back"
|
||||||
enableTorch={torch}
|
enableTorch={torch}
|
||||||
>
|
>
|
||||||
<View className="flex-1 justify-between p-10 pt-16">
|
<View className="flex-1 justify-between p-10 pt-16">
|
||||||
|
{/* Top bar */}
|
||||||
<View className="flex-row justify-between items-center">
|
<View className="flex-row justify-between items-center">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setTorch(!torch)}
|
onPress={() => setTorch(!torch)}
|
||||||
|
|
@ -109,31 +167,41 @@ export default function ScanScreen() {
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => nav.back()}
|
||||||
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
|
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
|
||||||
>
|
>
|
||||||
<X color="white" size={24} />
|
<X color="white" size={24} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Scan Frame */}
|
||||||
<View className="items-center">
|
<View className="items-center">
|
||||||
{/* Scanning Frame */}
|
<View className="w-72 h-72 border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
|
||||||
<View className="w-72 h-72 border-2 border-primary rounded-3xl border-dashed opacity-50 items-center justify-center">
|
|
||||||
<View className="w-64 h-64 border border-white/10 rounded-2xl" />
|
<View className="w-64 h-64 border border-white/10 rounded-2xl" />
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
|
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
|
||||||
Align Invoice
|
Align Invoice Within Frame
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="items-center pb-10">
|
{/* Capture Button */}
|
||||||
<View className="bg-black/40 px-6 py-3 rounded-full border border-white/10">
|
<View className="items-center pb-10 gap-4">
|
||||||
<Text className="text-white/60 text-[10px] font-black uppercase tracking-widest">
|
<Pressable
|
||||||
AI Auto-detecting...
|
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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</CameraView>
|
</CameraView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
135
app/_layout.tsx
135
app/_layout.tsx
|
|
@ -1,24 +1,127 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import "../global.css";
|
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { PortalHost } from "@rn-primitives/portal";
|
import { PortalHost } from "@rn-primitives/portal";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import { Toast } from "@/components/Toast";
|
||||||
|
import "@/global.css";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { View } from "react-native";
|
import { View, ActivityIndicator } from "react-native";
|
||||||
import { useRestoreTheme } from "@/lib/theme";
|
import { useRestoreTheme } from "@/lib/theme";
|
||||||
|
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { authGuard, guestGuard } from "@/lib/auth-guards";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
export default function RootLayout() {
|
import { useSegments, router as expoRouter } from "expo-router";
|
||||||
useRestoreTheme();
|
|
||||||
|
function BackupGuard() {
|
||||||
|
const segments = useSegments();
|
||||||
|
const isAuthed = useAuthStore((s) => s.isAuthenticated);
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isMounted) return null;
|
useEffect(() => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
const rootSegment = segments[0];
|
||||||
|
const isPublic = rootSegment === "login" || rootSegment === "register";
|
||||||
|
|
||||||
|
if (!isAuthed && !isPublic && segments.length > 0) {
|
||||||
|
console.log("[BackupGuard] Safety redirect to /login");
|
||||||
|
expoRouter.replace("/login");
|
||||||
|
}
|
||||||
|
}, [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 expoRouter for filesystem navigation
|
||||||
|
expoRouter.replace(`/${result.redirect}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(
|
||||||
|
`[SirouBridge] guard crash for "${routeName}":`,
|
||||||
|
e.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, [segments, sirou, isMounted, isAuthenticated]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
useRestoreTheme();
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="large" color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
||||||
|
<SirouBridge />
|
||||||
|
<BackupGuard />
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<View className="flex-1 bg-background">
|
<View className="flex-1 bg-background">
|
||||||
|
|
@ -31,11 +134,15 @@ export default function RootLayout() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="sms-scan" options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="proforma/[id]"
|
name="proforma/[id]"
|
||||||
options={{ title: "Proforma request" }}
|
options={{ title: "Proforma request" }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="payments/[id]" options={{ title: "Payment" }} />
|
<Stack.Screen
|
||||||
|
name="payments/[id]"
|
||||||
|
options={{ title: "Payment" }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="notifications/index"
|
name="notifications/index"
|
||||||
options={{ title: "Notifications" }}
|
options={{ title: "Notifications" }}
|
||||||
|
|
@ -52,18 +159,30 @@ export default function RootLayout() {
|
||||||
name="register"
|
name="register"
|
||||||
options={{ title: "Create account", headerShown: false }}
|
options={{ title: "Create account", headerShown: false }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="invoices/[id]" options={{ title: "Invoice" }} />
|
<Stack.Screen
|
||||||
<Stack.Screen name="reports/index" options={{ title: "Reports" }} />
|
name="invoices/[id]"
|
||||||
|
options={{ title: "Invoice" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="reports/index"
|
||||||
|
options={{ title: "Reports" }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="documents/index"
|
name="documents/index"
|
||||||
options={{ title: "Documents" }}
|
options={{ title: "Documents" }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
||||||
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="edit-profile"
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<PortalHost />
|
<PortalHost />
|
||||||
|
<Toast />
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
</SirouRouterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
166
app/company.tsx
Normal file
166
app/company.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
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 { api } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
UserPlus,
|
||||||
|
Search,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
ChevronRight,
|
||||||
|
Briefcase,
|
||||||
|
} 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 />
|
||||||
|
|
||||||
|
<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={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Worker List Header */}
|
||||||
|
<View className="flex-row justify-between items-center mb-4">
|
||||||
|
<Text variant="h4" className="text-foreground tracking-tight">
|
||||||
|
Workers ({filteredWorkers.length})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<ActivityIndicator color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor="#ea580c"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ paddingBottom: 100 }}
|
||||||
|
>
|
||||||
|
{filteredWorkers.length > 0 ? (
|
||||||
|
filteredWorkers.map((worker) => (
|
||||||
|
<ShadowWrapper key={worker.id} level="xs">
|
||||||
|
<Card className="mb-3 overflow-hidden rounded-[12px] bg-card border-0">
|
||||||
|
<CardContent className="flex-row items-center p-4">
|
||||||
|
<View className="h-12 w-12 rounded-full bg-secondary/50 items-center justify-center mr-4">
|
||||||
|
<Text className="text-primary font-bold text-lg">
|
||||||
|
{worker.firstName?.[0]}
|
||||||
|
{worker.lastName?.[0]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-bold text-base">
|
||||||
|
{worker.firstName} {worker.lastName}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center mt-1">
|
||||||
|
<Text className="text-muted-foreground text-xs bg-secondary px-2 py-0.5 rounded-md uppercase font-bold tracking-widest text-[10px]">
|
||||||
|
{worker.role || "WORKER"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ChevronRight
|
||||||
|
size={18}
|
||||||
|
color={isDark ? "#334155" : "#cbd5e1"}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ShadowWrapper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="py-20 items-center">
|
||||||
|
<Briefcase
|
||||||
|
size={48}
|
||||||
|
color={isDark ? "#1e293b" : "#f1f5f9"}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<Text variant="muted" className="mt-4">
|
||||||
|
No workers found
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Floating Action Button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.go("user/create")}
|
||||||
|
className="absolute bottom-8 right-8 h-14 w-14 bg-primary rounded-full items-center justify-center shadow-lg shadow-primary/40"
|
||||||
|
>
|
||||||
|
<UserPlus size={24} color="white" strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import { View, ScrollView, Pressable } from "react-native";
|
||||||
import { router } from 'expo-router';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { FileText, ChevronRight, FolderOpen, Upload } from '@/lib/icons';
|
import { Button } from "@/components/ui/button";
|
||||||
import { MOCK_DOCUMENTS } from '@/lib/mock-data';
|
import { FileText, ChevronRight, FolderOpen, Upload } from "@/lib/icons";
|
||||||
|
import { MOCK_DOCUMENTS } from "@/lib/mock-data";
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
const PRIMARY = "#ea580c";
|
||||||
|
|
||||||
export default function DocumentsScreen() {
|
export default function DocumentsScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
className="flex-1 bg-[#f5f5f5]"
|
||||||
|
|
@ -23,21 +25,32 @@ export default function DocumentsScreen() {
|
||||||
Uploaded invoices, scans, and attachments. Synced with your account.
|
Uploaded invoices, scans, and attachments. Synced with your account.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Button variant="outline" className="mb-5 min-h-12 rounded-xl border-border" onPress={() => {}}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mb-5 min-h-12 rounded-xl border-border"
|
||||||
|
onPress={() => {}}
|
||||||
|
>
|
||||||
<Upload color={PRIMARY} size={20} strokeWidth={2} />
|
<Upload color={PRIMARY} size={20} strokeWidth={2} />
|
||||||
<Text className="ml-2 font-medium text-gray-700">Upload document</Text>
|
<Text className="ml-2 font-medium text-gray-700">Upload document</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{MOCK_DOCUMENTS.map((d) => (
|
{MOCK_DOCUMENTS.map((d) => (
|
||||||
<Card key={d.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
<Card
|
||||||
|
key={d.id}
|
||||||
|
className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"
|
||||||
|
>
|
||||||
<Pressable>
|
<Pressable>
|
||||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
||||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
||||||
<FileText color={PRIMARY} size={22} strokeWidth={2} />
|
<FileText color={PRIMARY} size={22} strokeWidth={2} />
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="font-medium text-gray-900" numberOfLines={1}>{d.name}</Text>
|
<Text className="font-medium text-gray-900" numberOfLines={1}>
|
||||||
<Text className="text-muted-foreground mt-0.5 text-sm">{d.size} · {d.uploadedAt}</Text>
|
{d.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mt-0.5 text-sm">
|
||||||
|
{d.size} · {d.uploadedAt}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<ChevronRight color="#71717a" size={20} strokeWidth={2} />
|
<ChevronRight color="#71717a" size={20} strokeWidth={2} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -45,7 +58,11 @@ export default function DocumentsScreen() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4 rounded-xl border-border"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
>
|
||||||
<Text className="font-medium">Back</Text>
|
<Text className="font-medium">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
162
app/edit-profile.tsx
Normal file
162
app/edit-profile.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { ArrowLeft, User, Check, X } from "@/lib/icons";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useToast } from "@/lib/toast-store";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
export default function EditProfileScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { user, updateUser } = useAuthStore();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [firstName, setFirstName] = useState(user?.firstName || "");
|
||||||
|
const [lastName, setLastName] = useState(user?.lastName || "");
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!firstName.trim() || !lastName.trim()) {
|
||||||
|
showToast("First and last name are required", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.users.updateProfile({
|
||||||
|
body: {
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local store with the returned user data
|
||||||
|
updateUser(response);
|
||||||
|
|
||||||
|
showToast("Profile updated successfully", "success");
|
||||||
|
nav.back();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[EditProfile] Update failed:", e);
|
||||||
|
showToast(e.message || "Failed to update profile", "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
>
|
||||||
|
<ArrowLeft color={isDark ? "#fff" : "#0f172a"} size={20} />
|
||||||
|
</Pressable>
|
||||||
|
<Text variant="h4" className="text-foreground font-semibold">
|
||||||
|
Edit Profile
|
||||||
|
</Text>
|
||||||
|
<View className="w-10" /> {/* Spacer */}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 32,
|
||||||
|
paddingBottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="gap-6">
|
||||||
|
{/* First Name */}
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold mb-2 ml-1 text-foreground/70"
|
||||||
|
>
|
||||||
|
First Name
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center bg-secondary/30 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 bg-secondary/30 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
app/history.tsx
Normal file
189
app/history.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
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 { 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-20 items-center opacity-40">
|
||||||
|
<FileText size={48} color="#94a3b8" strokeWidth={1} />
|
||||||
|
<Text variant="muted" className="mt-4 font-bold">
|
||||||
|
No activity found
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { View, ScrollView, Pressable } from "react-native";
|
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||||
import { useLocalSearchParams, router, Stack } from "expo-router";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
@ -10,52 +12,66 @@ import {
|
||||||
Share2,
|
Share2,
|
||||||
Download,
|
Download,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Tag,
|
|
||||||
CreditCard,
|
|
||||||
Building2,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { MOCK_INVOICES } from "@/lib/mock-data";
|
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
const MOCK_ITEMS = [
|
import { api } from "@/lib/api";
|
||||||
{
|
import { toast } from "@/lib/toast-store";
|
||||||
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() {
|
export default function InvoiceDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const invoice = MOCK_INVOICES.find((i) => i.id === id);
|
const { id } = useLocalSearchParams();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [invoice, setInvoice] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInvoice();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchInvoice = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await api.invoices.getById({ params: { id: id as string } });
|
||||||
|
setInvoice(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[InvoiceDetail] Error:", error);
|
||||||
|
toast.error("Error", "Failed to load invoice details");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Invoice Details" showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<ActivityIndicator color="#ea580c" size="large" />
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Invoice Details" showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<Text variant="muted">Invoice not found</Text>
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<Stack.Screen options={{ headerShown: false }} />
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Invoice Details" showBack />
|
||||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.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">
|
|
||||||
Invoice Details
|
|
||||||
</Text>
|
|
||||||
<Pressable className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border">
|
|
||||||
<ExternalLink className="text-foreground" color="#000" size={18} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
|
@ -70,12 +86,12 @@ export default function InvoiceDetailScreen() {
|
||||||
<FileText color="white" size={16} strokeWidth={2.5} />
|
<FileText color="white" size={16} strokeWidth={2.5} />
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className={`rounded-[6px] px-3 py-1 ${invoice?.status === "Paid" ? "bg-emerald-500/20" : "bg-white/15"}`}
|
className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`text-[10px] font-bold ${invoice?.status === "Paid" ? "text-emerald-400" : "text-white"}`}
|
className={`text-[10px] font-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
|
||||||
>
|
>
|
||||||
{invoice?.status || "Pending"}
|
{invoice.status || "Pending"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -84,19 +100,19 @@ export default function InvoiceDetailScreen() {
|
||||||
Total Amount
|
Total Amount
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="h3" className="text-white font-bold mb-3">
|
<Text variant="h3" className="text-white font-bold mb-3">
|
||||||
${invoice?.amount.toLocaleString() ?? "—"}
|
${Number(invoice.amount).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||||
<View className="flex-row items-center gap-1.5">
|
<View className="flex-row items-center gap-1.5">
|
||||||
<Calendar color="rgba(255,255,255,0.9)" size={12} />
|
<Calendar color="rgba(255,255,255,0.9)" size={12} />
|
||||||
<Text className="text-white/90 text-xs font-semibold">
|
<Text className="text-white/90 text-xs font-semibold">
|
||||||
Due {invoice?.dueDate || "—"}
|
Due {new Date(invoice.dueDate).toLocaleDateString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="h-3 w-[1px] bg-white/60" />
|
<View className="h-3 w-[1px] bg-white/60" />
|
||||||
<Text className="text-white/90 text-xs font-semibold">
|
<Text className="text-white/90 text-xs font-semibold">
|
||||||
#{invoice?.invoiceNumber || id}
|
#{invoice.invoiceNumber || id}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -107,26 +123,30 @@ export default function InvoiceDetailScreen() {
|
||||||
<View className="flex-row px-4 py-2">
|
<View className="flex-row px-4 py-2">
|
||||||
<View className="flex-1 flex-row items-center">
|
<View className="flex-1 flex-row items-center">
|
||||||
<View className="flex-col">
|
<View className="flex-col">
|
||||||
<Text className="text-foreground text-xs">Recipient</Text>
|
<Text className="text-foreground text-xs opacity-60">
|
||||||
|
Recipient
|
||||||
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
variant="p"
|
variant="p"
|
||||||
className="text-foreground font-semibold"
|
className="text-foreground font-semibold"
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{invoice?.recipient || "—"}
|
{invoice.customerName || "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="w-[1px] bg-border/70 mx-3" />
|
<View className="w-[1px] bg-border/70 mx-3" />
|
||||||
<View className="flex-1 flex-row items-center">
|
<View className="flex-1 flex-row items-center">
|
||||||
<View className="flex-col">
|
<View className="flex-col">
|
||||||
<Text className="text-foreground text-xs">Category</Text>
|
<Text className="text-foreground text-xs opacity-60">
|
||||||
|
Category
|
||||||
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
variant="p"
|
variant="p"
|
||||||
className="text-foreground font-semibold"
|
className="text-foreground font-semibold"
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
Subscription
|
General
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -136,33 +156,47 @@ export default function InvoiceDetailScreen() {
|
||||||
{/* Items / Billing Summary */}
|
{/* Items / Billing Summary */}
|
||||||
<Card className="mb-4 bg-card rounded-[6px]">
|
<Card className="mb-4 bg-card rounded-[6px]">
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2 mb-2">
|
||||||
<Text variant="small" className="">
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-bold opacity-60 uppercase text-[10px] tracking-widest"
|
||||||
|
>
|
||||||
Billing Summary
|
Billing Summary
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{MOCK_ITEMS.map((item, i) => (
|
<View className="flex-row justify-between py-3 border-b border-border/70">
|
||||||
<View
|
|
||||||
key={i}
|
|
||||||
className={`flex-row justify-between py-3 ${i < MOCK_ITEMS.length - 1 ? "border-b border-border/70" : ""}`}
|
|
||||||
>
|
|
||||||
<View className="flex-1 pr-4">
|
<View className="flex-1 pr-4">
|
||||||
<Text
|
<Text
|
||||||
variant="p"
|
variant="p"
|
||||||
className="text-foreground font-semibold text-sm"
|
className="text-foreground font-semibold text-sm"
|
||||||
>
|
>
|
||||||
{item.description}
|
Subtotal
|
||||||
</Text>
|
|
||||||
<Text variant="muted" className="text-[10px] mt-0.5">
|
|
||||||
QTY: {item.qty} · ${item.unitPrice}/unit
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||||
${item.total.toLocaleString()}
|
$
|
||||||
|
{(
|
||||||
|
Number(invoice.amount) - (Number(invoice.taxAmount) || 0)
|
||||||
|
).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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">
|
<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">
|
<Text variant="muted" className="font-semibold text-sm">
|
||||||
|
|
@ -172,7 +206,7 @@ export default function InvoiceDetailScreen() {
|
||||||
variant="h3"
|
variant="h3"
|
||||||
className="text-foreground font-semibold text-xl tracking-tight"
|
className="text-foreground font-semibold text-xl tracking-tight"
|
||||||
>
|
>
|
||||||
${invoice?.amount.toLocaleString() || "0"}
|
${Number(invoice.amount).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -181,22 +215,22 @@ export default function InvoiceDetailScreen() {
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<Button
|
<Button
|
||||||
className=" flex-1 mb-4 h-10 rounded-[6px] bg-primary shadow-lg shadow-primary/30"
|
className=" flex-1 mb-4 h-11 rounded-[6px] bg-primary shadow-lg shadow-primary/30"
|
||||||
onPress={() => {}}
|
onPress={() => {}}
|
||||||
>
|
>
|
||||||
<Share2 color="#ffffff" size={14} strokeWidth={2.5} />
|
<Share2 color="#ffffff" size={14} strokeWidth={2.5} />
|
||||||
<Text className=" text-white text-xs font-semibold uppercase tracking-widest">
|
<Text className="ml-2 text-white text-[11px] font-bold uppercase tracking-widest">
|
||||||
Share
|
Share
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<ShadowWrapper>
|
<ShadowWrapper>
|
||||||
<Button
|
<Button
|
||||||
className=" flex-1 mb-4 h-10 rounded-[10px] bg-card"
|
className=" flex-1 mb-4 h-11 rounded-[6px] bg-card border border-border"
|
||||||
onPress={() => {}}
|
onPress={() => {}}
|
||||||
>
|
>
|
||||||
<Download color="#000" size={14} strokeWidth={2.5} />
|
<Download color="#0f172a" size={14} strokeWidth={2.5} />
|
||||||
<Text className="text-black text-xs font-semibold uppercase tracking-widest">
|
<Text className="ml-2 text-foreground text-[11px] font-bold uppercase tracking-widest">
|
||||||
Download PDF
|
PDF
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</ShadowWrapper>
|
</ShadowWrapper>
|
||||||
|
|
|
||||||
242
app/login.tsx
242
app/login.tsx
|
|
@ -1,40 +1,220 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import React, { useState } from "react";
|
||||||
import { router } from 'expo-router';
|
import {
|
||||||
import { Text } from '@/components/ui/text';
|
View,
|
||||||
import { Button } from '@/components/ui/button';
|
ScrollView,
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
Pressable,
|
||||||
import { Mail, ArrowLeft } from '@/lib/icons';
|
TextInput,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Image,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User } from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { api, BASE_URL } from "@/lib/api";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [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, and refresh token
|
||||||
|
setAuth(response.user, response.accessToken, response.refreshToken);
|
||||||
|
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 () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Hit api.auth.google directly — that's it
|
||||||
|
const response = await api.auth.google();
|
||||||
|
setAuth(response.user, response.accessToken, response.refreshToken);
|
||||||
|
toast.success("Welcome!", "Signed in with Google.");
|
||||||
|
nav.go("(tabs)");
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[Login] Google Login Error:", err);
|
||||||
|
toast.error(
|
||||||
|
"Google Login Failed",
|
||||||
|
err.message || "An unexpected error occurred.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScreenWrapper className="bg-background">
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
<KeyboardAvoidingView
|
||||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
showsVerticalScrollIndicator={false}
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
|
<ScrollView
|
||||||
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
|
className="flex-1"
|
||||||
<CardHeader>
|
contentContainerStyle={{ padding: 24, paddingTop: 60 }}
|
||||||
<CardTitle className="text-lg">Sign in</CardTitle>
|
keyboardShouldPersistTaps="handled"
|
||||||
<CardDescription className="mt-1">Use the same account as the web app.</CardDescription>
|
>
|
||||||
</CardHeader>
|
{/* Logo / Branding */}
|
||||||
<CardContent className="gap-3">
|
<View className="items-center mb-10">
|
||||||
<Button className="min-h-12 rounded-xl bg-primary">
|
<Text variant="h2" className="mt-6 font-bold text-foreground">
|
||||||
<Mail color="#ffffff" size={20} strokeWidth={2} />
|
Login
|
||||||
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
|
</Text>
|
||||||
</Button>
|
<Text variant="muted" className="mt-2 text-center">
|
||||||
<Button variant="outline" className="min-h-12 rounded-xl border-border">
|
Sign in to manage your tickets & invoices
|
||||||
<Text className="font-medium text-gray-700">Continue with Google</Text>
|
</Text>
|
||||||
</Button>
|
</View>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* Form */}
|
||||||
<Pressable onPress={() => router.push('/register')} className="mt-4">
|
<View className="gap-5">
|
||||||
<Text className="text-center text-primary font-medium">Create account</Text>
|
<View>
|
||||||
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
|
Email or Phone Number
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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>
|
</Pressable>
|
||||||
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
|
</View>
|
||||||
<ArrowLeft color="#71717a" size={20} strokeWidth={2} />
|
</View>
|
||||||
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
|
|
||||||
|
<Button
|
||||||
|
className="h-14 bg-primary rounded-[6px] shadow-lg shadow-primary/30 mt-2"
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="text-white font-bold text-base mr-2">
|
||||||
|
Sign In
|
||||||
|
</Text>
|
||||||
|
<ArrowRight color="white" size={18} strokeWidth={2.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</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-14 border border-border rounded-[6px] items-center justify-center flex-row bg-card"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={isDark ? "white" : "black"} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/google-logo.png")}
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<Text className="ml-3 font-bold text-foreground text-base">
|
||||||
|
Continue with Google
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-10 items-center justify-center py-2"
|
||||||
|
onPress={() => nav.go("register")}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Text className="text-primary">Create one</Text>
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,62 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import { View, ScrollView, Pressable } from "react-native";
|
||||||
import { router } from 'expo-router';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Text } from "@/components/ui/text";
|
||||||
import { Bell, Settings, ChevronRight } from '@/lib/icons';
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Bell, Settings, ChevronRight } from "@/lib/icons";
|
||||||
|
|
||||||
const MOCK_NOTIFICATIONS = [
|
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: "1",
|
||||||
{ id: '3', title: 'Proforma submission', body: 'Vendor A submitted a quote for Marketing Landing Page.', time: '2d ago', read: true },
|
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() {
|
export default function NotificationsScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
<ScrollView
|
||||||
|
className="flex-1 bg-background"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
>
|
||||||
<View className="mb-4 flex-row items-center justify-between">
|
<View className="mb-4 flex-row items-center justify-between">
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<Bell color="#18181b" size={22} strokeWidth={2} />
|
<Bell color="#18181b" size={22} strokeWidth={2} />
|
||||||
<Text className="text-xl font-semibold text-gray-900">Notifications</Text>
|
<Text className="text-xl font-semibold text-gray-900">
|
||||||
|
Notifications
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable className="flex-row items-center gap-1" onPress={() => router.push('/notifications/settings')}>
|
<Pressable
|
||||||
|
className="flex-row items-center gap-1"
|
||||||
|
onPress={() => nav.go("notifications/settings")}
|
||||||
|
>
|
||||||
<Settings color="#ea580c" size={18} strokeWidth={2} />
|
<Settings color="#ea580c" size={18} strokeWidth={2} />
|
||||||
<Text className="text-primary font-medium">Settings</Text>
|
<Text className="text-primary font-medium">Settings</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{MOCK_NOTIFICATIONS.map((n) => (
|
{MOCK_NOTIFICATIONS.map((n) => (
|
||||||
<Card key={n.id} className={`mb-2 ${!n.read ? 'border-primary/30' : ''}`}>
|
<Card
|
||||||
|
key={n.id}
|
||||||
|
className={`mb-2 ${!n.read ? "border-primary/30" : ""}`}
|
||||||
|
>
|
||||||
<CardContent className="py-3">
|
<CardContent className="py-3">
|
||||||
<Text className="font-semibold text-gray-900">{n.title}</Text>
|
<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-sm">{n.body}</Text>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
import { View, ScrollView, Switch } from 'react-native';
|
import { View, ScrollView, Switch } from "react-native";
|
||||||
import { router } from 'expo-router';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { useState } from 'react';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Text } from '@/components/ui/text';
|
import { useState } from "react";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
export default function NotificationSettingsScreen() {
|
export default function NotificationSettingsScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const [invoiceReminders, setInvoiceReminders] = useState(true);
|
const [invoiceReminders, setInvoiceReminders] = useState(true);
|
||||||
const [daysBeforeDue, setDaysBeforeDue] = useState(2);
|
const [daysBeforeDue, setDaysBeforeDue] = useState(2);
|
||||||
const [newsAlerts, setNewsAlerts] = useState(true);
|
const [newsAlerts, setNewsAlerts] = useState(true);
|
||||||
const [reportReady, setReportReady] = useState(true);
|
const [reportReady, setReportReady] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
|
<ScrollView
|
||||||
|
className="flex-1 bg-background"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
>
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notification settings</CardTitle>
|
<CardTitle>Notification settings</CardTitle>
|
||||||
|
|
@ -20,7 +25,10 @@ export default function NotificationSettingsScreen() {
|
||||||
<CardContent className="gap-4">
|
<CardContent className="gap-4">
|
||||||
<View className="flex-row items-center justify-between">
|
<View className="flex-row items-center justify-between">
|
||||||
<Text className="text-gray-900">Invoice reminders</Text>
|
<Text className="text-gray-900">Invoice reminders</Text>
|
||||||
<Switch value={invoiceReminders} onValueChange={setInvoiceReminders} />
|
<Switch
|
||||||
|
value={invoiceReminders}
|
||||||
|
onValueChange={setInvoiceReminders}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row items-center justify-between">
|
<View className="flex-row items-center justify-between">
|
||||||
<Text className="text-gray-900">News & announcements</Text>
|
<Text className="text-gray-900">News & announcements</Text>
|
||||||
|
|
@ -33,7 +41,7 @@ export default function NotificationSettingsScreen() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Button variant="outline" onPress={() => router.back()}>
|
<Button variant="outline" onPress={() => nav.back()}>
|
||||||
<Text className="font-medium">Back</Text>
|
<Text className="font-medium">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { View, ScrollView, Pressable } from "react-native";
|
import { View, ScrollView, Pressable } from "react-native";
|
||||||
import { useLocalSearchParams, router, Stack } from "expo-router";
|
import { useSirouRouter, useSirouParams } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
@ -7,7 +9,8 @@ import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
|
||||||
export default function PaymentDetailScreen() {
|
export default function PaymentDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { id } = useSirouParams<AppRoutes, "payments/[id]">();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
|
|
@ -15,7 +18,7 @@ export default function PaymentDetailScreen() {
|
||||||
|
|
||||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => nav.back()}
|
||||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
>
|
>
|
||||||
<ArrowLeft color="#0f172a" size={20} />
|
<ArrowLeft color="#0f172a" size={20} />
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import {
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { router } from "expo-router";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -29,6 +30,11 @@ import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { saveTheme, AppTheme } from "@/lib/theme";
|
import { saveTheme, AppTheme } from "@/lib/theme";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────
|
||||||
|
const AVATAR_FALLBACK_BASE =
|
||||||
|
"https://ui-avatars.com/api/?background=ea580c&color=fff&name=";
|
||||||
|
|
||||||
// ── Theme bottom sheet ────────────────────────────────────────────
|
// ── Theme bottom sheet ────────────────────────────────────────────
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
|
|
@ -168,6 +174,8 @@ function MenuItem({
|
||||||
|
|
||||||
// ── Screen ────────────────────────────────────────────────────────
|
// ── Screen ────────────────────────────────────────────────────────
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
const { setColorScheme, colorScheme } = useColorScheme();
|
const { setColorScheme, colorScheme } = useColorScheme();
|
||||||
const [notifications, setNotifications] = useState(true);
|
const [notifications, setNotifications] = useState(true);
|
||||||
const [themeSheetVisible, setThemeSheetVisible] = useState(false);
|
const [themeSheetVisible, setThemeSheetVisible] = useState(false);
|
||||||
|
|
@ -184,7 +192,7 @@ export default function ProfileScreen() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => nav.back()}
|
||||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
>
|
>
|
||||||
<ArrowLeft color="#0f172a" size={20} />
|
<ArrowLeft color="#0f172a" size={20} />
|
||||||
|
|
@ -194,10 +202,10 @@ export default function ProfileScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
{/* Edit Profile shortcut */}
|
{/* Edit Profile shortcut */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {}}
|
onPress={() => nav.go("edit-profile")}
|
||||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
>
|
>
|
||||||
<User className="text-foreground" size={18} />
|
<User className="text-foreground" color="#000" size={18} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -211,19 +219,21 @@ export default function ProfileScreen() {
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<View className="items-center mb-8">
|
<View className="items-center mb-8">
|
||||||
<View className="h-20 w-20 rounded-full border-2 border-border overflow-hidden bg-muted mb-3">
|
<View className="h-20 w-20 rounded-full overflow-hidden bg-muted mb-3">
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
uri: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=300&h=300",
|
uri:
|
||||||
|
user?.avatar ||
|
||||||
|
`${AVATAR_FALLBACK_BASE}${encodeURIComponent(`${user?.firstName} ${user?.lastName}`)}`,
|
||||||
}}
|
}}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text variant="h4" className="text-foreground font-bold">
|
<Text variant="h4" className="text-foreground">
|
||||||
Ms. Charlotte
|
{user?.firstName} {user?.lastName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="muted" className="text-sm mt-0.5">
|
<Text variant="muted" className="text-sm mt-0.5">
|
||||||
charlotte@example.com
|
{user?.email}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -301,7 +311,7 @@ export default function ProfileScreen() {
|
||||||
icon={<LogOut color="#ef4444" size={17} />}
|
icon={<LogOut color="#ef4444" size={17} />}
|
||||||
label="Log Out"
|
label="Log Out"
|
||||||
destructive
|
destructive
|
||||||
onPress={() => {}}
|
onPress={logout}
|
||||||
right={null}
|
right={null}
|
||||||
isLast
|
isLast
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { View, ScrollView, Pressable } from "react-native";
|
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||||
import { useLocalSearchParams, router, Stack } from "expo-router";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
@ -8,104 +10,169 @@ import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
DraftingCompass,
|
DraftingCompass,
|
||||||
Clock,
|
Clock,
|
||||||
Tag,
|
|
||||||
Send,
|
Send,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
const MOCK_ITEMS = [
|
import { api } from "@/lib/api";
|
||||||
{
|
import { toast } from "@/lib/toast-store";
|
||||||
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() {
|
export default function ProformaDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
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");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Proforma" showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<ActivityIndicator color="#ea580c" size="large" />
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proforma) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Proforma" showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<Text variant="muted">Proforma not found</Text>
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtotal =
|
||||||
|
proforma.items?.reduce(
|
||||||
|
(acc: number, item: any) => acc + (Number(item.total) || 0),
|
||||||
|
0,
|
||||||
|
) || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<Stack.Screen options={{ headerShown: false }} />
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
|
||||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
{/* Header */}
|
||||||
<Pressable
|
<StandardHeader title="Proforma" showBack />
|
||||||
onPress={() => router.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">
|
|
||||||
Proforma
|
|
||||||
</Text>
|
|
||||||
<Pressable className="h-9 w-9 rounded-[6px] bg-card items-center justify-center border border-border">
|
|
||||||
<ExternalLink className="text-foreground" color="#000" size={17} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<Card className=" overflow-hidden rounded-[6px] border-0 bg-primary">
|
{/* Blue Summary Card */}
|
||||||
|
<Card className="overflow-hidden rounded-[6px] border-0 bg-primary mb-4">
|
||||||
<View className="p-5">
|
<View className="p-5">
|
||||||
<View className="flex-row items-center justify-between mb-3">
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
||||||
<DraftingCompass color="white" size={16} strokeWidth={2.5} />
|
<DraftingCompass color="white" size={16} strokeWidth={2.5} />
|
||||||
</View>
|
</View>
|
||||||
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
|
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
|
||||||
<Text className={`text-[10px] font-bold text-white`}>
|
<Text className="text-[10px] font-bold text-white uppercase tracking-widest">
|
||||||
Open Request
|
ACTIVE
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text variant="small" className="text-white/70 mb-0.5">
|
<Text variant="small" className="text-white/70 mb-0.5">
|
||||||
Target Package
|
Customer: {proforma.customerName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="h3" className="text-white font-bold mb-3">
|
<Text variant="h3" className="text-white font-bold mb-3">
|
||||||
Marketing Landing Page
|
{proforma.description || "Proforma Request"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||||
<View className="flex-row items-center gap-1.5">
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<Clock color="rgba(255,255,255,0.9)" size={12} />
|
||||||
<Text className="text-white/90 text-xs font-semibold">
|
<Text className="text-white/90 text-xs font-semibold">
|
||||||
Expires in 5 days
|
Due {new Date(proforma.dueDate).toLocaleDateString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="h-3 w-[1px] bg-white/60" />
|
<View className="h-3 w-[1px] bg-white/60" />
|
||||||
<Text className="text-white/90 text-xs font-semibold">
|
<Text className="text-white/90 text-xs font-semibold">
|
||||||
REQ-{id || "002"}
|
{proforma.proformaNumber}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Customer Info Strip (Added for functionality while keeping style) */}
|
||||||
|
<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-[10px] opacity-60 uppercase font-bold">
|
||||||
|
Email
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold text-xs"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{proforma.customerEmail || "N/A"}
|
||||||
|
</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-[10px] opacity-60 uppercase font-bold">
|
||||||
|
Phone
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-semibold text-xs"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{proforma.customerPhone || "N/A"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Line Items Card */}
|
||||||
<Card className="bg-card rounded-[6px] mb-4">
|
<Card className="bg-card rounded-[6px] mb-4">
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2 mb-2">
|
||||||
<Text variant="small" className="font-semibold">
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-bold uppercase tracking-widest text-[10px] opacity-60"
|
||||||
|
>
|
||||||
Line Items
|
Line Items
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{MOCK_ITEMS.map((item, i) => (
|
{proforma.items?.map((item: any, i: number) => (
|
||||||
<View
|
<View
|
||||||
key={i}
|
key={item.id || i}
|
||||||
className={`flex-row justify-between py-3 ${i < MOCK_ITEMS.length - 1 ? "border-b border-border/40" : ""}`}
|
className={`flex-row justify-between py-3 ${i < proforma.items.length - 1 ? "border-b border-border/40" : ""}`}
|
||||||
>
|
>
|
||||||
<View className="flex-1 pr-4">
|
<View className="flex-1 pr-4">
|
||||||
<Text
|
<Text
|
||||||
|
|
@ -115,11 +182,12 @@ export default function ProformaDetailScreen() {
|
||||||
{item.description}
|
{item.description}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="muted" className="text-[10px] mt-0.5">
|
<Text variant="muted" className="text-[10px] mt-0.5">
|
||||||
{item.qty} × ${item.unitPrice.toLocaleString()}
|
{item.quantity} × {proforma.currency}{" "}
|
||||||
|
{Number(item.unitPrice).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||||
${item.total.toLocaleString()}
|
{proforma.currency} {Number(item.total).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
@ -133,59 +201,76 @@ export default function ProformaDetailScreen() {
|
||||||
Subtotal
|
Subtotal
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||||
${MOCK_SUBTOTAL.toLocaleString()}
|
{proforma.currency} {subtotal.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{Number(proforma.taxAmount) > 0 && (
|
||||||
<View className="flex-row justify-between">
|
<View className="flex-row justify-between">
|
||||||
<Text
|
<Text
|
||||||
variant="p"
|
variant="p"
|
||||||
className="text-foreground font-semibold text-sm"
|
className="text-foreground font-semibold text-sm"
|
||||||
>
|
>
|
||||||
Tax (10%)
|
Tax
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
<Text
|
||||||
${MOCK_TAX.toLocaleString()}
|
variant="p"
|
||||||
|
className="text-foreground font-bold text-sm"
|
||||||
|
>
|
||||||
|
{proforma.currency}{" "}
|
||||||
|
{Number(proforma.taxAmount).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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">
|
<View className="flex-row justify-between items-center mt-1">
|
||||||
<Text variant="p" className="text-foreground font-semibold">
|
<Text variant="p" className="text-foreground font-bold">
|
||||||
Estimated Total
|
Total Amount
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
variant="h4"
|
variant="h4"
|
||||||
className="text-foreground font-bold tracking-tight"
|
className="text-foreground font-bold tracking-tight"
|
||||||
>
|
>
|
||||||
${MOCK_TOTAL.toLocaleString()}
|
{proforma.currency} {Number(proforma.amount).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Text variant="h4" className="text-foreground mb-2">
|
{/* Notes Section (New) */}
|
||||||
Recent Submissions
|
{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>
|
||||||
|
|
||||||
<Card className="bg-card rounded-[6px] mb-6">
|
|
||||||
<Pressable className="flex-row items-center p-3">
|
|
||||||
<View className="bg-secondary h-9 w-9 rounded-[6px] items-center justify-center mr-3 border border-border/50">
|
|
||||||
<CheckCircle2 className="text-muted-foreground" size={16} />
|
|
||||||
</View>
|
|
||||||
<View className="flex-1 mt-[-10px]">
|
|
||||||
<Text
|
<Text
|
||||||
variant="p"
|
variant="p"
|
||||||
className="text-foreground font-semibold text-sm"
|
className="text-foreground font-medium text-xs leading-5"
|
||||||
>
|
>
|
||||||
Vendor A — $1,450
|
{proforma.notes}
|
||||||
</Text>
|
|
||||||
<Text variant="muted" className="text-xs mt-0.5">
|
|
||||||
Submitted 2 hours ago
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<ChevronRight className="text-muted-foreground/50" size={16} />
|
|
||||||
</Pressable>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 h-11 rounded-[6px] bg-primary"
|
className="flex-1 h-11 rounded-[6px] bg-primary"
|
||||||
|
|
@ -198,7 +283,7 @@ export default function ProformaDetailScreen() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 h-11 rounded-[6px] bg-card border border-border"
|
className="flex-1 h-11 rounded-[6px] bg-card border border-border"
|
||||||
onPress={() => router.back()}
|
onPress={() => nav.back()}
|
||||||
>
|
>
|
||||||
<Text className="text-foreground font-semibold text-[11px] uppercase tracking-widest">
|
<Text className="text-foreground font-semibold text-[11px] uppercase tracking-widest">
|
||||||
Back
|
Back
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,38 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Pressable,
|
Pressable,
|
||||||
TextInput,
|
TextInput,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Platform,
|
ActivityIndicator,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft, Trash2, Send, Plus } from "@/lib/icons";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
Plus,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
CalendarSearch,
|
||||||
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { router, Stack } from "expo-router";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
|
||||||
type Item = { id: number; description: string; qty: string; price: string };
|
type Item = { id: number; description: string; qty: string; price: string };
|
||||||
|
|
||||||
// All TextInput styles are native StyleSheet — NO className on TextInput
|
|
||||||
// NativeWind className on TextInput causes focus loop because it re-processes
|
|
||||||
// styles each render and resets the responder chain.
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
input: {
|
input: {
|
||||||
height: 44,
|
height: 44,
|
||||||
|
|
@ -71,7 +84,10 @@ function Field({
|
||||||
const c = useInputColors();
|
const c = useInputColors();
|
||||||
return (
|
return (
|
||||||
<View style={flex != null ? { flex } : undefined}>
|
<View style={flex != null ? { flex } : undefined}>
|
||||||
<Text variant="muted" className="font-semibold text-xs mb-1.5">
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|
@ -92,17 +108,52 @@ function Field({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CURRENCIES = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
|
||||||
|
|
||||||
export default function CreateProformaScreen() {
|
export default function CreateProformaScreen() {
|
||||||
const [company, setCompany] = useState("");
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const [project, setProject] = useState("");
|
const [loading, setLoading] = useState(false);
|
||||||
const [validity, setValidity] = useState("");
|
|
||||||
const [terms, setTerms] = useState("");
|
// 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[]>([
|
const [items, setItems] = useState<Item[]>([
|
||||||
{ id: 1, description: "", qty: "1", price: "" },
|
{ id: 1, description: "", qty: "1", price: "" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const c = useInputColors();
|
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) =>
|
const updateField = (id: number, field: keyof Item, value: string) =>
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)),
|
prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)),
|
||||||
|
|
@ -119,78 +170,196 @@ export default function CreateProformaScreen() {
|
||||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const total = items.reduce(
|
const subtotal = items.reduce(
|
||||||
(sum, item) =>
|
(sum, item) =>
|
||||||
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
|
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
|
||||||
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<Stack.Screen options={{ headerShown: false }} />
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Create Proforma" showBack />
|
||||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.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">
|
|
||||||
New Proforma
|
|
||||||
</Text>
|
|
||||||
<View className="w-9" />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
contentContainerStyle={{ padding: 16, paddingBottom: 140 }}
|
contentContainerStyle={{ padding: 16, paddingBottom: 30 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
{/* Recipient */}
|
{/* Header Info */}
|
||||||
<Label>Recipient</Label>
|
<Label>General Information</Label>
|
||||||
<ShadowWrapper>
|
<ShadowWrapper>
|
||||||
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
<Field
|
<Field
|
||||||
label="Company / Name"
|
label="Proforma Number"
|
||||||
value={company}
|
value={proformaNumber}
|
||||||
onChangeText={setCompany}
|
onChangeText={setProformaNumber}
|
||||||
placeholder="e.g. Acme Corp"
|
placeholder="e.g. PROF-2024-001"
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Project Title"
|
label="Project Description"
|
||||||
value={project}
|
value={description}
|
||||||
onChangeText={setProject}
|
onChangeText={setDescription}
|
||||||
placeholder="e.g. Website Redesign"
|
placeholder="e.g. Web Development Services"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ShadowWrapper>
|
</ShadowWrapper>
|
||||||
|
|
||||||
{/* Terms */}
|
{/* Recipient */}
|
||||||
<Label>Terms & Validity</Label>
|
<Label>Customer Details</Label>
|
||||||
<ShadowWrapper>
|
<ShadowWrapper>
|
||||||
<View className="bg-card rounded-[6px] p-4 mb-5">
|
<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">
|
<View className="flex-row gap-4">
|
||||||
<Field
|
<Field
|
||||||
label="Validity (days)"
|
label="Email"
|
||||||
value={validity}
|
value={customerEmail}
|
||||||
onChangeText={setValidity}
|
onChangeText={setCustomerEmail}
|
||||||
placeholder="30"
|
placeholder="billing@acme.com"
|
||||||
numeric
|
|
||||||
flex={1}
|
flex={1}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Payment Terms"
|
label="Phone"
|
||||||
value={terms}
|
value={customerPhone}
|
||||||
onChangeText={setTerms}
|
onChangeText={setCustomerPhone}
|
||||||
placeholder="e.g. 50% upfront"
|
placeholder="+251..."
|
||||||
flex={2}
|
flex={1}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ShadowWrapper>
|
</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 */}
|
{/* Items */}
|
||||||
<View className="flex-row items-center justify-between mb-3">
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
<Label noMargin>Billable Items</Label>
|
<Label noMargin>Billable Items</Label>
|
||||||
|
|
@ -207,12 +376,12 @@ export default function CreateProformaScreen() {
|
||||||
|
|
||||||
<View className="gap-3 mb-5">
|
<View className="gap-3 mb-5">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<ShadowWrapper>
|
<ShadowWrapper key={item.id}>
|
||||||
<View key={item.id} className="bg-card rounded-[6px] p-4">
|
<View className="bg-card rounded-[6px] p-4">
|
||||||
<View className="flex-row justify-between items-center mb-3">
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
<Text
|
<Text
|
||||||
variant="muted"
|
variant="muted"
|
||||||
className="text-[12px] font-bold uppercase tracking-wide"
|
className="text-[10px] font-bold uppercase tracking-wide opacity-50"
|
||||||
>
|
>
|
||||||
Item {index + 1}
|
Item {index + 1}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -223,89 +392,43 @@ export default function CreateProformaScreen() {
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text
|
<Field
|
||||||
variant="muted"
|
label="Description"
|
||||||
className="text-[11px] font-semibold mb-1.5"
|
placeholder="e.g. UI Design"
|
||||||
>
|
|
||||||
Description
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
S.input,
|
|
||||||
{
|
|
||||||
backgroundColor: c.bg,
|
|
||||||
borderColor: c.border,
|
|
||||||
color: c.text,
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
placeholder="e.g. Web Design Package"
|
|
||||||
placeholderTextColor={c.placeholder}
|
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChangeText={(v) => updateField(item.id, "description", v)}
|
onChangeText={(v) => updateField(item.id, "description", v)}
|
||||||
autoCorrect={false}
|
|
||||||
autoCapitalize="none"
|
|
||||||
returnKeyType="next"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3 mt-4">
|
||||||
<View className="flex-1">
|
<Field
|
||||||
<Text
|
label="Qty"
|
||||||
variant="muted"
|
|
||||||
className="text-[11px] font-semibold mb-1.5"
|
|
||||||
>
|
|
||||||
Qty
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
S.inputCenter,
|
|
||||||
{
|
|
||||||
backgroundColor: c.bg,
|
|
||||||
borderColor: c.border,
|
|
||||||
color: c.text,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
placeholderTextColor={c.placeholder}
|
numeric
|
||||||
keyboardType="numeric"
|
center
|
||||||
value={item.qty}
|
value={item.qty}
|
||||||
onChangeText={(v) => updateField(item.id, "qty", v)}
|
onChangeText={(v) => updateField(item.id, "qty", v)}
|
||||||
returnKeyType="next"
|
flex={1}
|
||||||
/>
|
/>
|
||||||
</View>
|
<Field
|
||||||
<View className="flex-[2]">
|
label="Price"
|
||||||
<Text
|
|
||||||
variant="muted"
|
|
||||||
className="text-[11px] font-semibold mb-1.5"
|
|
||||||
>
|
|
||||||
Unit Price ($)
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
S.input,
|
|
||||||
{
|
|
||||||
backgroundColor: c.bg,
|
|
||||||
borderColor: c.border,
|
|
||||||
color: c.text,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
placeholderTextColor={c.placeholder}
|
numeric
|
||||||
keyboardType="numeric"
|
|
||||||
value={item.price}
|
value={item.price}
|
||||||
onChangeText={(v) => updateField(item.id, "price", v)}
|
onChangeText={(v) => updateField(item.id, "price", v)}
|
||||||
returnKeyType="done"
|
flex={2}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
<View className="flex-1 items-end justify-end pb-1">
|
<View className="flex-1 items-end justify-end pb-1">
|
||||||
<Text variant="muted" className="text-[10px]">
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[9px] uppercase font-bold opacity-40"
|
||||||
|
>
|
||||||
Total
|
Total
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
variant="p"
|
variant="p"
|
||||||
className="text-foreground font-bold text-sm"
|
className="text-foreground font-bold text-sm"
|
||||||
>
|
>
|
||||||
$
|
{currency}
|
||||||
{(
|
{(
|
||||||
(parseFloat(item.qty) || 0) *
|
(parseFloat(item.qty) || 0) *
|
||||||
(parseFloat(item.price) || 0)
|
(parseFloat(item.price) || 0)
|
||||||
|
|
@ -319,13 +442,74 @@ export default function CreateProformaScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<View className="border border-border/60 rounded-[6px] p-4 bg-secondary/10 mb-6">
|
<Label>Totals & Taxes</Label>
|
||||||
<View className="flex-row justify-between items-center mb-4">
|
<ShadowWrapper>
|
||||||
<Text variant="muted" className="font-semibold text-sm">
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-3">
|
||||||
Estimated Total
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">
|
||||||
|
Subtotal
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="h4" className="text-foreground font-semibold">
|
<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={c.placeholder}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View className="border border-border/60 rounded-[12px] p-5 bg-primary/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", {
|
{total.toLocaleString("en-US", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
|
|
@ -335,22 +519,83 @@ export default function CreateProformaScreen() {
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 h-11 rounded-[6px] border-border bg-card"
|
className="flex-1 h-12 rounded-[6px] border-border bg-card"
|
||||||
onPress={() => router.back()}
|
onPress={() => nav.back()}
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<Text className="text-foreground font-semibold text-[11px] uppercase tracking-widest">
|
<Text className="text-foreground font-bold text-xs uppercase tracking-tighter">
|
||||||
Cancel
|
Discard
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="flex-1 h-11 rounded-[6px] bg-primary">
|
<Button
|
||||||
<Send color="white" size={14} strokeWidth={2.5} />
|
className="flex-1 h-12 rounded-[6px] bg-primary"
|
||||||
<Text className=" text-white font-bold text-[11px] uppercase tracking-widest">
|
onPress={handleSubmit}
|
||||||
Create & Share
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send color="white" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="text-white font-bold text-xs uppercase tracking-tighter">
|
||||||
|
Create Proforma
|
||||||
</Text>
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</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>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -363,7 +608,10 @@ function Label({
|
||||||
noMargin?: boolean;
|
noMargin?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Text variant="muted" className={`font-semibold ${noMargin ? "" : "mb-3"}`}>
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3"}`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
252
app/register.tsx
252
app/register.tsx
|
|
@ -1,40 +1,230 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import React, { useState } from "react";
|
||||||
import { router } from 'expo-router';
|
import {
|
||||||
import { Text } from '@/components/ui/text';
|
View,
|
||||||
import { Button } from '@/components/ui/button';
|
ScrollView,
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
Pressable,
|
||||||
import { Mail, ArrowLeft, UserPlus } from '@/lib/icons';
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Lock,
|
||||||
|
User,
|
||||||
|
Phone,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
TrianglePlanets,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Chrome,
|
||||||
|
} 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";
|
||||||
|
|
||||||
export default function RegisterScreen() {
|
export default function RegisterScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
const { firstName, lastName, email, phone, password } = form;
|
||||||
|
if (!firstName || !lastName || !email || !phone || !password) {
|
||||||
|
toast.error("Required Fields", "Please fill in all fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepend +251 to the phone number for the API
|
||||||
|
const formattedPhone = `+251${phone}`;
|
||||||
|
|
||||||
|
const response = await api.auth.register({
|
||||||
|
body: {
|
||||||
|
...form,
|
||||||
|
phone: formattedPhone,
|
||||||
|
role: "VIEWER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Store user, access token, and refresh token
|
||||||
|
setAuth(response.user, response.accessToken, response.refreshToken);
|
||||||
|
toast.success("Account Created!", "Welcome to Yaltopia.");
|
||||||
|
|
||||||
|
nav.go("(tabs)");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(
|
||||||
|
"Registration Failed",
|
||||||
|
err.message || "Failed to create account",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateForm = (key: keyof typeof form, val: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: val }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScreenWrapper className="bg-background">
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
<KeyboardAvoidingView
|
||||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
showsVerticalScrollIndicator={false}
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
|
<ScrollView
|
||||||
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
|
className="flex-1"
|
||||||
<CardHeader>
|
contentContainerStyle={{ padding: 24, paddingBottom: 60 }}
|
||||||
<CardTitle className="text-lg">Create account</CardTitle>
|
keyboardShouldPersistTaps="handled"
|
||||||
<CardDescription className="mt-1">Register with the same account format as the web app.</CardDescription>
|
>
|
||||||
</CardHeader>
|
<View className="mb-10 mt-10">
|
||||||
<CardContent className="gap-3">
|
<Text
|
||||||
<Button className="min-h-12 rounded-xl bg-primary">
|
variant="h2"
|
||||||
<UserPlus color="#ffffff" size={20} strokeWidth={2} />
|
className="mt-6 font-bold text-foreground text-center"
|
||||||
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
|
>
|
||||||
|
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="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
|
<TextInput
|
||||||
|
className="text-foreground"
|
||||||
|
placeholder="John"
|
||||||
|
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
|
<TextInput
|
||||||
|
className="text-foreground"
|
||||||
|
placeholder="Doe"
|
||||||
|
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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-4"
|
||||||
|
onPress={handleRegister}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="text-white font-bold text-base mr-2">
|
||||||
|
Create Account
|
||||||
|
</Text>
|
||||||
|
<ArrowRight color="white" size={18} strokeWidth={2.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="min-h-12 rounded-xl border-border">
|
</View>
|
||||||
<Text className="font-medium text-gray-700">Continue with Google</Text>
|
|
||||||
</Button>
|
<Pressable
|
||||||
</CardContent>
|
className="mt-10 items-center justify-center py-2"
|
||||||
</Card>
|
onPress={() => nav.go("login")}
|
||||||
<Pressable onPress={() => router.push('/login')} className="mt-2">
|
>
|
||||||
<Text className="text-center text-primary font-medium">Already have an account? Sign in</Text>
|
<Text className="text-muted-foreground">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Text className="text-primary">Sign In</Text>
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</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>
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import { View, ScrollView, Pressable } from "react-native";
|
||||||
import { router } from 'expo-router';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Button } from "@/components/ui/button";
|
||||||
import { FileText, Download, ChevronRight, BarChart3 } from '@/lib/icons';
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { MOCK_REPORTS } from '@/lib/mock-data';
|
import { FileText, Download, ChevronRight, BarChart3 } from "@/lib/icons";
|
||||||
|
import { MOCK_REPORTS } from "@/lib/mock-data";
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
const PRIMARY = "#ea580c";
|
||||||
|
|
||||||
export default function ReportsScreen() {
|
export default function ReportsScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
className="flex-1 bg-[#f5f5f5]"
|
||||||
|
|
@ -24,7 +26,10 @@ export default function ReportsScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{MOCK_REPORTS.map((r) => (
|
{MOCK_REPORTS.map((r) => (
|
||||||
<Card key={r.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
<Card
|
||||||
|
key={r.id}
|
||||||
|
className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"
|
||||||
|
>
|
||||||
<Pressable>
|
<Pressable>
|
||||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
||||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
||||||
|
|
@ -32,8 +37,12 @@ export default function ReportsScreen() {
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="font-semibold text-gray-900">{r.title}</Text>
|
<Text className="font-semibold text-gray-900">{r.title}</Text>
|
||||||
<Text className="text-muted-foreground mt-0.5 text-sm">{r.period}</Text>
|
<Text className="text-muted-foreground mt-0.5 text-sm">
|
||||||
<Text className="text-muted-foreground mt-0.5 text-xs">Generated {r.generatedAt}</Text>
|
{r.period}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
Generated {r.generatedAt}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<Pressable className="rounded-lg bg-primary/10 p-2">
|
<Pressable className="rounded-lg bg-primary/10 p-2">
|
||||||
|
|
@ -46,7 +55,11 @@ export default function ReportsScreen() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4 rounded-xl border-border"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
>
|
||||||
<Text className="font-medium">Back</Text>
|
<Text className="font-medium">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { View, ScrollView, Pressable } from 'react-native';
|
import { View, ScrollView, Pressable } from "react-native";
|
||||||
import { router } from 'expo-router';
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { Text } from '@/components/ui/text';
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Button } from "@/components/ui/button";
|
||||||
import { Settings, Bell, Globe, ChevronRight, Info } from '@/lib/icons';
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Settings, Bell, Globe, ChevronRight, Info } from "@/lib/icons";
|
||||||
|
|
||||||
const PRIMARY = '#ea580c';
|
const PRIMARY = "#ea580c";
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1 bg-[#f5f5f5]"
|
className="flex-1 bg-[#f5f5f5]"
|
||||||
|
|
@ -26,7 +28,7 @@ export default function SettingsScreen() {
|
||||||
<CardContent className="gap-0">
|
<CardContent className="gap-0">
|
||||||
<Pressable
|
<Pressable
|
||||||
className="flex-row items-center justify-between border-b border-border py-3"
|
className="flex-row items-center justify-between border-b border-border py-3"
|
||||||
onPress={() => router.push('/notifications/settings')}
|
onPress={() => nav.go("notifications/settings")}
|
||||||
>
|
>
|
||||||
<View className="flex-row items-center gap-3">
|
<View className="flex-row items-center gap-3">
|
||||||
<Bell color="#71717a" size={20} strokeWidth={2} />
|
<Bell color="#71717a" size={20} strokeWidth={2} />
|
||||||
|
|
@ -60,11 +62,16 @@ export default function SettingsScreen() {
|
||||||
|
|
||||||
<View className="rounded-xl border border-border bg-white p-4">
|
<View className="rounded-xl border border-border bg-white p-4">
|
||||||
<Text className="text-muted-foreground text-xs">
|
<Text className="text-muted-foreground text-xs">
|
||||||
API: Invoices, Proforma, Payments, Reports, Documents, Notifications — see swagger.json and README for integration.
|
API: Invoices, Proforma, Payments, Reports, Documents, Notifications —
|
||||||
|
see swagger.json and README for integration.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button variant="outline" className="mt-6 rounded-xl border-border" onPress={() => router.back()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-6 rounded-xl border-border"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
>
|
||||||
<Text className="font-medium">Back</Text>
|
<Text className="font-medium">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
256
app/sms-scan.tsx
Normal file
256
app/sms-scan.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
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 {
|
||||||
|
SmsAndroid = require("react-native-get-sms-android").default;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
"Package Missing",
|
||||||
|
"Run: npm install react-native-get-sms-android",
|
||||||
|
);
|
||||||
|
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 5 minutes
|
||||||
|
const fiveMinutesAgo = Date.now() - 5 * 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 getBankLabel = (sms: SmsMessage) => {
|
||||||
|
const text = (sms.body + sms.address).toUpperCase();
|
||||||
|
if (text.includes("CBE")) return { name: "CBE", color: "#16a34a" };
|
||||||
|
if (text.includes("DASHEN"))
|
||||||
|
return { name: "Dashen Bank", color: "#1d4ed8" };
|
||||||
|
if (text.includes("127") || text.includes("TELEBIRR"))
|
||||||
|
return { name: "Telebirr", color: "#7c3aed" };
|
||||||
|
return { name: "Bank", color: "#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-3">
|
||||||
|
{messages.map((sms) => {
|
||||||
|
const bank = getBankLabel(sms);
|
||||||
|
return (
|
||||||
|
<Card key={sms._id} className="rounded-[12px] bg-card p-4">
|
||||||
|
<View className="flex-row items-center justify-between mb-2">
|
||||||
|
<View
|
||||||
|
className="px-3 py-1 rounded-full"
|
||||||
|
style={{ backgroundColor: bank.color + "20" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-bold"
|
||||||
|
style={{ color: bank.color }}
|
||||||
|
>
|
||||||
|
{bank.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="muted" className="text-xs">
|
||||||
|
{formatTime(sms.date)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-foreground text-sm leading-5">
|
||||||
|
{sms.body}
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-2">
|
||||||
|
From: {sms.address}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
app/user/create.tsx
Normal file
257
app/user/create.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
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="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
|
<TextInput
|
||||||
|
className="text-foreground"
|
||||||
|
placeholder="First Name"
|
||||||
|
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
|
<TextInput
|
||||||
|
className="text-foreground"
|
||||||
|
placeholder="Last Name"
|
||||||
|
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
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 bg-secondary/30 rounded-xl px-4 border border-border h-12"
|
||||||
|
>
|
||||||
|
<ShieldCheck size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
<Text className="flex-1 ml-3 text-foreground 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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
|
||||||
|
value={form.password}
|
||||||
|
onChangeText={(v) => updateForm("password", v)}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="h-14 bg-primary rounded-[10px] shadow-lg shadow-primary/30 mt-6"
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="text-white font-bold text-base mr-2">
|
||||||
|
Create User
|
||||||
|
</Text>
|
||||||
|
<ArrowRight color="white" size={18} strokeWidth={2.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showRolePicker}
|
||||||
|
onClose={() => setShowRolePicker(false)}
|
||||||
|
title="Select System Role"
|
||||||
|
>
|
||||||
|
{ROLES.map((role) => (
|
||||||
|
<SelectOption
|
||||||
|
key={role}
|
||||||
|
label={role}
|
||||||
|
value={role}
|
||||||
|
selected={form.role === role}
|
||||||
|
onSelect={(v) => {
|
||||||
|
updateForm("role", v);
|
||||||
|
setShowRolePicker(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
assets/google-logo.png
Normal file
BIN
assets/google-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
160
components/CalendarGrid.tsx
Normal file
160
components/CalendarGrid.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, Pressable, StyleSheet } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { ArrowLeft, ArrowRight, ChevronDown } from "@/lib/icons";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
const MONTHS = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||||
|
|
||||||
|
interface CalendarGridProps {
|
||||||
|
onSelect: (v: string) => void;
|
||||||
|
selectedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarGrid({ onSelect, selectedDate }: CalendarGridProps) {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const initialDate = selectedDate ? new Date(selectedDate) : new Date();
|
||||||
|
const [viewDate, setViewDate] = useState(
|
||||||
|
new Date(initialDate.getFullYear(), initialDate.getMonth(), 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const year = viewDate.getFullYear();
|
||||||
|
const month = viewDate.getMonth();
|
||||||
|
|
||||||
|
// Days in current month
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
// Starting day of the week (0-6), where 0 is Sunday
|
||||||
|
let firstDayOfMonth = new Date(year, month, 1).getDay();
|
||||||
|
// Adjust for Monday start: Mon=0 ... Sun=6
|
||||||
|
firstDayOfMonth = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
|
||||||
|
|
||||||
|
// Days in previous month to fill the start
|
||||||
|
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
const changeMonth = (delta: number) => {
|
||||||
|
setViewDate(new Date(year, month + delta, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
// Fill previous month days (muted)
|
||||||
|
for (let i = firstDayOfMonth - 1; i >= 0; i--) {
|
||||||
|
days.push({
|
||||||
|
date: new Date(year, month - 1, prevMonthLastDay - i),
|
||||||
|
currentMonth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill current month days
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push({
|
||||||
|
date: new Date(year, month, i),
|
||||||
|
currentMonth: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill next month days (muted) to complete the grid (usually 42 cells for 6 weeks)
|
||||||
|
const remaining = 42 - days.length;
|
||||||
|
for (let i = 1; i <= remaining; i++) {
|
||||||
|
days.push({
|
||||||
|
date: new Date(year, month + 1, i),
|
||||||
|
currentMonth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-card px-2 pb-6">
|
||||||
|
<View className="flex-row justify-between items-center mb-10 mt-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => changeMonth(-1)}
|
||||||
|
className="h-12 w-12 bg-white rounded-[12px] items-center justify-center border border-border"
|
||||||
|
style={isDark ? { backgroundColor: "#1e1e1e" } : undefined}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} color="#64748b" strokeWidth={2} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Text className="text-foreground text-base font-medium tracking-tight">
|
||||||
|
{MONTHS[month]} {year}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => changeMonth(1)}
|
||||||
|
className="h-12 w-12 bg-white rounded-[12px] items-center justify-center border border-border"
|
||||||
|
style={isDark ? { backgroundColor: "#1e1e1e" } : undefined}
|
||||||
|
>
|
||||||
|
<ArrowRight size={18} color="#64748b" strokeWidth={2} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* WeekDays Header: Mo Tu We Th Fr Sa Su */}
|
||||||
|
<View className="flex-row mb-6">
|
||||||
|
{WEEKDAYS.map((day, idx) => (
|
||||||
|
<View key={idx} className="w-[14.28%] items-center">
|
||||||
|
<Text className="text-[13px] font-semibold text-slate-400 opacity-80">
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<View className="flex-row flex-wrap">
|
||||||
|
{days.map((item, i) => {
|
||||||
|
const d = item.date;
|
||||||
|
const iso = d.toISOString().split("T")[0];
|
||||||
|
const isSelected = iso === selectedDate && item.currentMonth;
|
||||||
|
const isToday = iso === new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
className="w-[14.28%] aspect-square items-center justify-center mb-1"
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onSelect(iso)}
|
||||||
|
className={`w-11 h-11 items-center justify-center rounded-full ${
|
||||||
|
isSelected ? "bg-primary" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-[15px] ${
|
||||||
|
isSelected
|
||||||
|
? "text-white font-bold"
|
||||||
|
: item.currentMonth
|
||||||
|
? "text-foreground font-medium"
|
||||||
|
: "text-slate-300 font-medium"
|
||||||
|
} ${isToday && !isSelected ? "text-primary" : ""}`}
|
||||||
|
>
|
||||||
|
{d.getDate()}
|
||||||
|
</Text>
|
||||||
|
{isToday && !isSelected && (
|
||||||
|
<View className="absolute bottom-1 w-2 h-2 bg-primary rounded-full" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
components/PickerModal.tsx
Normal file
123
components/PickerModal.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Dimensions,
|
||||||
|
StyleSheet,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { X, Check } from "@/lib/icons";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
interface PickerModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PickerModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: PickerModalProps) {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
|
||||||
|
<View className="flex-1 justify-end">
|
||||||
|
<Pressable
|
||||||
|
className="bg-card rounded-t-[36px] overflow-hidden border-t border-border/5"
|
||||||
|
style={{
|
||||||
|
maxHeight: SCREEN_HEIGHT * 0.8,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: -10 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 20,
|
||||||
|
}}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
<View className="items-center pt-3 pb-1">
|
||||||
|
<View className="w-10 h-1 bg-border/20 rounded-full" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View className="px-6 pb-4 pt-2 flex-row justify-between items-center">
|
||||||
|
<View className="w-10" />
|
||||||
|
<Text variant="h4" className="text-foreground tracking-tight">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
className="h-10 w-10 bg-secondary/80 rounded-full items-center justify-center border border-border/10"
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
size={16}
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="p-5 pt-0"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectOption({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onSelect(value)}
|
||||||
|
className={`flex-row items-center justify-between p-4 mb-3 rounded-[6px] border ${
|
||||||
|
selected
|
||||||
|
? "bg-primary/5 border-primary/20"
|
||||||
|
: "bg-secondary/20 border-border/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`font-bold text-[15px] ${selected ? "text-primary" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
className={`h-5 w-5 rounded-full items-center justify-center ${selected ? "bg-primary" : "border border-border/20"}`}
|
||||||
|
>
|
||||||
|
{selected && <Check size={12} color="white" strokeWidth={4} />}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,49 @@
|
||||||
import React from "react";
|
import { View, Image, Pressable, useColorScheme } from "react-native";
|
||||||
import { View, Image, Pressable } from "react-native";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Bell } from "@/lib/icons";
|
import { ArrowLeft, Bell } from "@/lib/icons";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
import { MOCK_USER } from "@/lib/mock-data";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
|
|
||||||
export function StandardHeader() {
|
interface StandardHeaderProps {
|
||||||
|
title?: string;
|
||||||
|
showBack?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StandardHeader({ title, showBack }: StandardHeaderProps) {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<View className="px-5 pt-4 pb-2 flex-row justify-between items-center bg-background">
|
<View className="px-5 pt-4 pb-2 flex-row justify-between items-center bg-background">
|
||||||
<View className="flex-row items-center gap-3">
|
<View className="flex-1 flex-row items-center gap-3">
|
||||||
<ShadowWrapper level="xs">
|
{showBack && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.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
|
<Pressable
|
||||||
onPress={() => router.push("/profile")}
|
onPress={() => router.push("/profile")}
|
||||||
className="h-[40px] w-[40px] rounded-full border-2 border-primary/20 overflow-hidden"
|
className="h-[40px] w-[40px] rounded-full overflow-hidden"
|
||||||
>
|
>
|
||||||
<Image
|
<Image source={{ uri: avatarUri }} className="h-full w-full" />
|
||||||
source={{
|
|
||||||
uri: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&q=80&w=200&h=200",
|
|
||||||
}}
|
|
||||||
className="h-full w-full"
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ShadowWrapper>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
variant="muted"
|
variant="muted"
|
||||||
|
|
@ -31,16 +52,32 @@ export function StandardHeader() {
|
||||||
Welcome back,
|
Welcome back,
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="h4" className="text-foreground leading-tight">
|
<Text variant="h4" className="text-foreground leading-tight">
|
||||||
{MOCK_USER.name}
|
{user?.firstName + " " + user?.lastName || "User"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="flex-1 items-center mr-10">
|
||||||
|
<Text variant="h4" className="text-foreground font-semibold">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!title && (
|
||||||
<ShadowWrapper level="xs">
|
<ShadowWrapper level="xs">
|
||||||
<Pressable className="rounded-full p-2.5 border border-border">
|
<Pressable className="rounded-full p-2.5 border border-border">
|
||||||
<Bell color="#000" size={20} strokeWidth={2} />
|
<Bell
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ShadowWrapper>
|
</ShadowWrapper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{title && <View className="w-0" />}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
147
components/Toast.tsx
Normal file
147
components/Toast.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
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: "#f0fdf4",
|
||||||
|
border: "#22c55e",
|
||||||
|
icon: <CheckCircle2 size={24} color="#22c55e" />,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: "#f0f9ff",
|
||||||
|
border: "#0ea5e9",
|
||||||
|
icon: <Lightbulb size={24} color="#0ea5e9" />,
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: "#fffbeb",
|
||||||
|
border: "#f59e0b",
|
||||||
|
icon: <AlertTriangle size={24} color="#f59e0b" />,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: "#fef2f2",
|
||||||
|
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,
|
||||||
|
backgroundColor: variant.bg,
|
||||||
|
borderColor: variant.border,
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
]}
|
||||||
|
className="border-2 rounded-2xl shadow-xl 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
119
lib/api-middlewares.ts
Normal file
119
lib/api-middlewares.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { Middleware } from "@simple-api/core";
|
||||||
|
import { useAuthStore } from "./auth-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to inject the authentication token into requests.
|
||||||
|
* Skips login, register, and refresh endpoints.
|
||||||
|
*/
|
||||||
|
export const authMiddleware: Middleware = async ({ config, options }, next) => {
|
||||||
|
const { token } = useAuthStore.getState();
|
||||||
|
|
||||||
|
// Don't send Authorization header for sensitive auth-related endpoints,
|
||||||
|
// EXCEPT for logout which needs to identify the session.
|
||||||
|
const isAuthPath =
|
||||||
|
config.path === "auth/login" ||
|
||||||
|
config.path === "auth/register" ||
|
||||||
|
config.path === "auth/refresh";
|
||||||
|
|
||||||
|
if (token && !isAuthPath) {
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to handle token refreshment on 401 Unauthorized errors.
|
||||||
|
*/
|
||||||
|
export const refreshMiddleware: Middleware = async (
|
||||||
|
{ config, options },
|
||||||
|
next,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
return await next(options);
|
||||||
|
} catch (error: any) {
|
||||||
|
const status = error.status || error.statusCode;
|
||||||
|
const { refreshToken, setAuth, logout } = useAuthStore.getState();
|
||||||
|
|
||||||
|
// Skip refresh logic for the login/refresh endpoints themselves
|
||||||
|
const isAuthPath =
|
||||||
|
config.path?.includes("auth/login") ||
|
||||||
|
config.path?.includes("auth/refresh");
|
||||||
|
|
||||||
|
if (status === 401 && refreshToken && !isAuthPath) {
|
||||||
|
console.log(
|
||||||
|
`[API Refresh] 401 detected for ${config.path}. Attempting refresh...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We call the refresh endpoint manually here to avoid circular dependencies with the 'api' object
|
||||||
|
const refreshUrl = `${config.baseUrl}auth/refresh`;
|
||||||
|
|
||||||
|
const response = await fetch(refreshUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
refreshToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const refreshErr = new Error(
|
||||||
|
errorData.message ||
|
||||||
|
`Refresh failed with status ${response.status}`,
|
||||||
|
) as any;
|
||||||
|
refreshErr.status = response.status;
|
||||||
|
throw refreshErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Backend might return snake_case (access_token) or camelCase (accessToken)
|
||||||
|
// We handle both to be safe when using raw fetch
|
||||||
|
const accessToken = data.accessToken || data.access_token;
|
||||||
|
const newRefreshToken = data.refreshToken || data.refresh_token;
|
||||||
|
const user = data.user;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error("No access token returned from refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuth(user, accessToken, newRefreshToken);
|
||||||
|
|
||||||
|
console.log("[API Refresh] Success. Retrying original request...");
|
||||||
|
|
||||||
|
// Update headers and retry
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await next(options);
|
||||||
|
} catch (refreshError: any) {
|
||||||
|
// Only logout if the refresh token itself is invalid (400, 401, 403)
|
||||||
|
// If it's a network error, we should NOT logout the user.
|
||||||
|
const refreshStatus = refreshError.status || refreshError.statusCode;
|
||||||
|
const isAuthError = refreshStatus === 401;
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
console.error("[API Refresh] Invalid refresh token. Logging out.");
|
||||||
|
logout();
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"[API Refresh] Network error or server issues during refresh. Staying logged in.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw refreshError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
97
lib/api.ts
Normal file
97
lib/api.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
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/api/v1/";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central API client using simple-api
|
||||||
|
*/
|
||||||
|
export const api = createApi({
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
middleware: [
|
||||||
|
createLoggerMiddleware(),
|
||||||
|
createTransformerMiddleware(),
|
||||||
|
refreshMiddleware,
|
||||||
|
],
|
||||||
|
services: {
|
||||||
|
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/register-owner" },
|
||||||
|
refresh: { method: "POST", path: "auth/refresh" },
|
||||||
|
logout: { method: "POST", path: "auth/logout" },
|
||||||
|
profile: { method: "GET", path: "auth/profile" },
|
||||||
|
google: { method: "GET", path: "auth/google" },
|
||||||
|
callback: { method: "GET", path: "auth/google/callback" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proforma: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
getAll: { method: "GET", path: "proforma" },
|
||||||
|
getById: { method: "GET", path: "proforma/:id" },
|
||||||
|
create: { method: "POST", path: "proforma" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
46
lib/auth-guards.ts
Normal file
46
lib/auth-guards.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { RouteGuard, GuardResult } from "@sirou/core";
|
||||||
|
import { useAuthStore } from "./auth-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication Guard
|
||||||
|
* Prevents unauthenticated users from accessing protected routes.
|
||||||
|
*/
|
||||||
|
export const authGuard: RouteGuard = {
|
||||||
|
name: "auth",
|
||||||
|
execute: async ({ route, meta }): Promise<GuardResult> => {
|
||||||
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
|
const requiresAuth = meta?.requiresAuth ?? false;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AUTH_GUARD] checking: "${route}" (requiresAuth: ${requiresAuth}, auth: ${isAuthenticated})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requiresAuth && !isAuthenticated) {
|
||||||
|
console.log(`[AUTH_GUARD] DENIED -> redirect /login`);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
redirect: "login", // Use name, not path
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guestGuard: RouteGuard = {
|
||||||
|
name: "guest",
|
||||||
|
execute: async ({ meta }): Promise<GuardResult> => {
|
||||||
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
|
const guestOnly = meta?.guestOnly ?? false;
|
||||||
|
|
||||||
|
if (guestOnly && isAuthenticated) {
|
||||||
|
console.log(`[GUEST_GUARD] Authenticated user blocked -> redirect /`);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
redirect: "(tabs)", // Redirect to home if already logged in
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
89
lib/auth-store.ts
Normal file
89
lib/auth-store.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setAuth: (user: User, token: string, refreshToken?: string) => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
updateUser: (user: Partial<User>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
setAuth: (user, token, refreshToken = undefined) => {
|
||||||
|
console.log("[AuthStore] Setting auth state:", {
|
||||||
|
hasUser: !!user,
|
||||||
|
hasToken: !!token,
|
||||||
|
hasRefreshToken: !!refreshToken,
|
||||||
|
});
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
refreshToken: refreshToken ?? null,
|
||||||
|
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,
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -54,4 +54,22 @@ export {
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Lock,
|
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";
|
||||||
|
|
|
||||||
133
lib/routes.ts
Normal file
133
lib/routes.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
// Stacks
|
||||||
|
"proforma/[id]": {
|
||||||
|
path: "/proforma/:id",
|
||||||
|
params: { id: "string" },
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"proforma/create": {
|
||||||
|
path: "/proforma/create",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
"payments/[id]": {
|
||||||
|
path: "/payments/:id",
|
||||||
|
params: { id: "string" },
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
"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;
|
||||||
46
lib/toast-store.ts
Normal file
46
lib/toast-store.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type ToastType = "success" | "error" | "warning" | "info";
|
||||||
|
|
||||||
|
export interface ToastState {
|
||||||
|
visible: boolean;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
show: (params: {
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
}) => void;
|
||||||
|
hide: () => void;
|
||||||
|
showToast: (message: string, type?: ToastType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToast = create<ToastState>((set) => ({
|
||||||
|
visible: false,
|
||||||
|
type: "info",
|
||||||
|
title: "",
|
||||||
|
message: "",
|
||||||
|
duration: 4000,
|
||||||
|
show: ({ type, title, message, duration = 4000 }) => {
|
||||||
|
set({ visible: true, type, title, message, duration });
|
||||||
|
},
|
||||||
|
hide: () => set({ visible: false }),
|
||||||
|
showToast: (message, type = "info") => {
|
||||||
|
const title = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
set({ visible: true, type, title, message, duration: 4000 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const toast = {
|
||||||
|
success: (title: string, message: string) =>
|
||||||
|
useToast.getState().show({ type: "success", title, message }),
|
||||||
|
error: (title: string, message: string) =>
|
||||||
|
useToast.getState().show({ type: "error", title, message }),
|
||||||
|
warning: (title: string, message: string) =>
|
||||||
|
useToast.getState().show({ type: "warning", title, message }),
|
||||||
|
info: (title: string, message: string) =>
|
||||||
|
useToast.getState().show({ type: "info", title, message }),
|
||||||
|
};
|
||||||
790
package-lock.json
generated
790
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -12,9 +12,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~4.0.1",
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
|
"@react-native-community/datetimepicker": "8.2.0",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@rn-primitives/portal": "^1.1.0",
|
"@rn-primitives/portal": "^1.1.0",
|
||||||
"@rn-primitives/slot": "^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",
|
"babel-preset-expo": "~11.0.15",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -26,19 +31,22 @@
|
||||||
"expo-router": "~4.0.17",
|
"expo-router": "~4.0.17",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-system-ui": "~4.0.9",
|
"expo-system-ui": "~4.0.9",
|
||||||
|
"expo-web-browser": "~14.0.2",
|
||||||
"lucide-react-native": "^0.471.0",
|
"lucide-react-native": "^0.471.0",
|
||||||
"nativewind": "^4.1.23",
|
"nativewind": "^4.1.23",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "0.76.7",
|
"react-native": "0.76.7",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
|
"react-native-get-sms-android": "^2.1.0",
|
||||||
"react-native-reanimated": "~3.16.1",
|
"react-native-reanimated": "~3.16.1",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
"react-native-svg": "15.8.0",
|
"react-native-svg": "15.8.0",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~18.3.12",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user