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
|
||||
/ios
|
||||
/android
|
||||
|
||||
*.apk
|
||||
*.aab
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||
|
||||
|
|
@ -98,12 +98,12 @@ export default function TabsLayout() {
|
|||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
name="news"
|
||||
options={{
|
||||
tabBarLabel: "History",
|
||||
tabBarLabel: "News",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}>
|
||||
<History
|
||||
<Newspaper
|
||||
color={color}
|
||||
size={18}
|
||||
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 { 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 { Card, CardContent } from "@/components/ui/card";
|
||||
import { EARNINGS_SUMMARY, MOCK_INVOICES } from "@/lib/mock-data";
|
||||
import { router } from "expo-router";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import {
|
||||
Plus,
|
||||
Send,
|
||||
History as HistoryIcon,
|
||||
BarChart3,
|
||||
Briefcase,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
DollarSign,
|
||||
|
|
@ -17,21 +18,62 @@ import {
|
|||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||
import { StandardHeader } from "@/components/StandardHeader";
|
||||
|
||||
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",
|
||||
};
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [activeFilter, setActiveFilter] = useState("All");
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
paid: 0,
|
||||
pending: 0,
|
||||
overdue: 0,
|
||||
totalRevenue: 0,
|
||||
});
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
|
||||
const filteredInvoices =
|
||||
activeFilter === "All"
|
||||
? MOCK_INVOICES
|
||||
: MOCK_INVOICES.filter((inv) => inv.status === activeFilter);
|
||||
React.useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchInvoices();
|
||||
}, [activeFilter]);
|
||||
|
||||
const fetchStats = async () => {
|
||||
const { isAuthenticated } = useAuthStore.getState();
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
const data = await api.invoices.stats();
|
||||
setStats(data);
|
||||
} catch (e) {
|
||||
console.error("[HomeScreen] Failed to fetch stats:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInvoices = async () => {
|
||||
const { isAuthenticated } = useAuthStore.getState();
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const statusParam =
|
||||
activeFilter === "All" ? undefined : activeFilter.toUpperCase();
|
||||
const response = await api.invoices.getAll({
|
||||
query: {
|
||||
limit: 5,
|
||||
status: statusParam,
|
||||
},
|
||||
});
|
||||
setInvoices(response.data || []);
|
||||
} catch (e) {
|
||||
console.error("[HomeScreen] Failed to fetch invoices:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
|
|
@ -61,7 +103,7 @@ export default function HomeScreen() {
|
|||
<View className="mt-2 flex-row items-baseline">
|
||||
<Text className="text-white text-2xl font-medium">$</Text>
|
||||
<Text className="ml-1 text-4xl font-bold text-white">
|
||||
{EARNINGS_SUMMARY.balance.toLocaleString()}
|
||||
{stats.total.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
|
@ -76,7 +118,7 @@ export default function HomeScreen() {
|
|||
</Text>
|
||||
</View>
|
||||
<Text className="text-white font-bold text-xl mt-2">
|
||||
${EARNINGS_SUMMARY.waitingAmount.toLocaleString()}
|
||||
${stats.pending.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<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>
|
||||
</View>
|
||||
<Text className="text-white font-bold text-xl mt-2">
|
||||
${EARNINGS_SUMMARY.paidThisMonth.toLocaleString()}
|
||||
${stats.totalRevenue.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -101,23 +143,24 @@ export default function HomeScreen() {
|
|||
{/* Circular Quick Actions Section */}
|
||||
<View className="mb-4 flex-row justify-around items-center px-2">
|
||||
<QuickAction
|
||||
icon={<Plus color="#000" size={20} strokeWidth={1.5} />}
|
||||
label="Scan"
|
||||
onPress={() => router.push("/(tabs)/scan")}
|
||||
icon={<Briefcase color="#000" size={20} strokeWidth={1.5} />}
|
||||
label="Company"
|
||||
onPress={() => nav.go("company")}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Send color="#000" size={20} strokeWidth={1.5} />}
|
||||
label="Send"
|
||||
onPress={() => router.push("/(tabs)/proforma")}
|
||||
onPress={() => nav.go("(tabs)/proforma")}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<HistoryIcon color="#000" size={20} strokeWidth={1.5} />}
|
||||
label="History"
|
||||
onPress={() => router.push("/(tabs)/history")}
|
||||
onPress={() => nav.go("history")}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<BarChart3 color="#000" size={20} strokeWidth={1.5} />}
|
||||
label="Analytics"
|
||||
icon={<Plus color="#000" size={20} strokeWidth={1.5} />}
|
||||
label="Create Proforma"
|
||||
onPress={() => nav.go("proforma/create")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
@ -126,7 +169,10 @@ export default function HomeScreen() {
|
|||
<Text variant="h4" className="text-foreground tracking-tight">
|
||||
Recent Activity
|
||||
</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>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
|
@ -138,11 +184,16 @@ export default function HomeScreen() {
|
|||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8 }}
|
||||
>
|
||||
{["All", "Paid", "Waiting", "Unpaid"].map((filter) => (
|
||||
{["All", "Draft", "Pending", "Paid", "Overdue", "Cancelled"].map(
|
||||
(filter) => (
|
||||
<Pressable
|
||||
key={filter}
|
||||
onPress={() => setActiveFilter(filter)}
|
||||
className={`rounded-[4px] px-4 py-1.5 ${activeFilter === filter ? "bg-primary" : "bg-card border border-border"}`}
|
||||
className={`rounded-[4px] px-4 py-1.5 ${
|
||||
activeFilter === filter
|
||||
? "bg-primary"
|
||||
: "bg-card border border-border"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs font-bold ${
|
||||
|
|
@ -154,18 +205,22 @@ export default function HomeScreen() {
|
|||
{filter}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Transactions List */}
|
||||
<View className="gap-2">
|
||||
{filteredInvoices.length > 0 ? (
|
||||
filteredInvoices.map((inv) => (
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#ea580c" className="py-20" />
|
||||
) : invoices.length > 0 ? (
|
||||
invoices.map((inv) => (
|
||||
<Pressable
|
||||
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">
|
||||
<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">
|
||||
|
|
@ -180,13 +235,14 @@ export default function HomeScreen() {
|
|||
variant="p"
|
||||
className="text-foreground font-semibold"
|
||||
>
|
||||
{inv.recipient}
|
||||
{inv.customerName}
|
||||
</Text>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="mt-1 text-[11px] font-medium opacity-70"
|
||||
>
|
||||
{inv.dueDate} · Proforma
|
||||
{new Date(inv.issueDate).toLocaleDateString()} ·
|
||||
Proforma
|
||||
</Text>
|
||||
</View>
|
||||
<View className="items-end mt-[-20px]">
|
||||
|
|
@ -194,10 +250,18 @@ export default function HomeScreen() {
|
|||
variant="p"
|
||||
className="text-foreground font-semibold"
|
||||
>
|
||||
${inv.amount.toLocaleString()}
|
||||
${Number(inv.amount).toLocaleString()}
|
||||
</Text>
|
||||
<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">
|
||||
{inv.status}
|
||||
|
|
@ -206,6 +270,7 @@ export default function HomeScreen() {
|
|||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ShadowWrapper>
|
||||
</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 { View, ScrollView, Pressable } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
ListRenderItem,
|
||||
} from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { MOCK_PAYMENTS } from "@/lib/mock-data";
|
||||
import { ScanLine, CheckCircle2, Wallet, ChevronRight } from "@/lib/icons";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
ScanLine,
|
||||
CheckCircle2,
|
||||
Wallet,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
} from "@/lib/icons";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
import { StandardHeader } from "@/components/StandardHeader";
|
||||
import { toast } from "@/lib/toast-store";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
|
||||
const PRIMARY = "#ea580c";
|
||||
|
||||
interface Payment {
|
||||
id: string;
|
||||
transactionId: string;
|
||||
amount:
|
||||
| {
|
||||
value: number;
|
||||
currency: string;
|
||||
}
|
||||
| number;
|
||||
currency: string;
|
||||
paymentDate: string;
|
||||
paymentMethod: string;
|
||||
notes: string;
|
||||
isFlagged: boolean;
|
||||
flagReason: string;
|
||||
flagNotes: string;
|
||||
receiptPath: string;
|
||||
userId: string;
|
||||
invoiceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function PaymentsScreen() {
|
||||
const matched = MOCK_PAYMENTS.filter((p) => p.matched);
|
||||
const pending = MOCK_PAYMENTS.filter((p) => !p.matched);
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const [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 (
|
||||
<ScreenWrapper className="bg-background">
|
||||
|
|
@ -22,84 +204,78 @@ export default function PaymentsScreen() {
|
|||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
|
||||
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} />
|
||||
<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
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{/* Flagged Section */}
|
||||
{categorized.flagged.length > 0 && (
|
||||
<>
|
||||
<View className="mb-4 flex-row items-center gap-3">
|
||||
<Text variant="h4" className="text-red-600">
|
||||
Flagged Payments
|
||||
</Text>
|
||||
</View>
|
||||
<View className="gap-2 mb-6">
|
||||
{categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pending Section */}
|
||||
<View className="mb-4 flex-row items-center gap-3">
|
||||
<Text variant="h4" className="text-foreground">
|
||||
Pending Match
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
{pending.map((pay) => (
|
||||
<Pressable
|
||||
key={pay.id}
|
||||
onPress={() => router.push(`/payments/${pay.id}`)}
|
||||
>
|
||||
<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()}
|
||||
<View className="gap-2 mb-6">
|
||||
{categorized.pending.length > 0 ? (
|
||||
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
|
||||
) : (
|
||||
<Text variant="muted" className="text-center py-4">
|
||||
No pending matches.
|
||||
</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 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">
|
||||
Reconciled
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
{matched.map((pay) => (
|
||||
<Card
|
||||
key={pay.id}
|
||||
className="rounded-[10px] bg-card overflow-hidden opacity-80"
|
||||
>
|
||||
<View className="flex-row items-center p-3">
|
||||
<View className="mr-2 rounded-[6px] bg-emerald-500/10 p-2 border border-emerald-500/5">
|
||||
<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}
|
||||
{categorized.reconciled.length > 0 ? (
|
||||
categorized.reconciled.map((p) =>
|
||||
renderPaymentItem(p, "reconciled"),
|
||||
)
|
||||
) : (
|
||||
<Text variant="muted" className="text-center py-4">
|
||||
No reconciled payments.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<ChevronRight
|
||||
className="text-foreground"
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
color="#000"
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{loadingMore && (
|
||||
<View className="py-4">
|
||||
<ActivityIndicator color={PRIMARY} />
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,132 +1,209 @@
|
|||
import React, { useState } from "react";
|
||||
import { View, ScrollView, Pressable } from "react-native";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
ListRenderItem,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { MOCK_PROFORMA } from "@/lib/mock-data";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
Plus,
|
||||
Send,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
History,
|
||||
DraftingCompass,
|
||||
} from "@/lib/icons";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Plus, Send, FileText, Clock } from "@/lib/icons";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||
import { StandardHeader } from "@/components/StandardHeader";
|
||||
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() {
|
||||
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 (
|
||||
<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
|
||||
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">
|
||||
<View className="p-3">
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="bg-secondary/50 p-2 rounded-[10px]">
|
||||
<FileText color="#000" size={18} />
|
||||
<Card className="rounded-[10px] bg-card overflow-hidden">
|
||||
<View className="p-4">
|
||||
<View className="flex-row justify-between items-start mb-3">
|
||||
<View className="bg-primary/10 p-2.5 rounded-[12px] border border-primary/5">
|
||||
<FileText color="#ea580c" size={20} strokeWidth={2.5} />
|
||||
</View>
|
||||
<View className="bg-emerald-500/10 px-3 py-1 rounded-[6px] border border-emerald-500/20">
|
||||
<Text className="text-emerald-600 text-[10px] font-bold uppercase tracking-tighter">
|
||||
{item.sentCount} Shared
|
||||
<View className="items-end">
|
||||
<Text variant="p" className="text-foreground font-bold text-lg">
|
||||
{item.currency || "$"}
|
||||
{amountVal?.toLocaleString()}
|
||||
</Text>
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[10px] font-bold uppercase tracking-widest mt-0.5"
|
||||
>
|
||||
{item.proformaNumber}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text variant="p" className="text-foreground font-semibold">
|
||||
{item.title}
|
||||
<Text variant="p" className="text-foreground font-bold mb-1">
|
||||
{item.customerName}
|
||||
</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}
|
||||
</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 gap-4">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Clock
|
||||
className="text-muted-foreground"
|
||||
color="#000"
|
||||
size={12}
|
||||
/>
|
||||
<Text variant="muted" className="text-xs">
|
||||
{item.deadline}
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="p-1 bg-secondary/80 rounded-md">
|
||||
<Clock color="#64748b" size={12} strokeWidth={2.5} />
|
||||
</View>
|
||||
<Text variant="muted" className="text-[11px] font-medium">
|
||||
Issued: {dateStr}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Pressable className="bg-secondary px-2 py-1 rounded-[6px] border border-border/50 flex-row items-center gap-1">
|
||||
<Send color="#000" size={12} />
|
||||
<Text variant="muted" className="text-xs">
|
||||
<Pressable
|
||||
className="bg-primary/10 px-3.5 py-1.5 rounded-full border border-primary/20 flex-row items-center gap-1.5"
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
// Handle share
|
||||
}}
|
||||
>
|
||||
<Send color="#ea580c" size={12} strokeWidth={2.5} />
|
||||
<Text className="text-primary text-[11px] font-bold uppercase tracking-tight">
|
||||
Share
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</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>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View className="py-20">
|
||||
<ActivityIndicator size="large" color="#ea580c" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,36 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Platform,
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
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 { 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");
|
||||
const NAV_BG = "#ffffff";
|
||||
|
||||
export default function ScanScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [torch, setTorch] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const navigation = useNavigation();
|
||||
const NAV_BG = "#ffffff";
|
||||
const token = useAuthStore((s) => s.token);
|
||||
|
||||
// Hide tab bar when on this screen (since it's a dedicated camera view)
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
tabBarStyle: {
|
||||
display: "none",
|
||||
},
|
||||
});
|
||||
navigation.setOptions({ tabBarStyle: { display: "none" } });
|
||||
return () => {
|
||||
navigation.setOptions({
|
||||
tabBarStyle: {
|
||||
|
|
@ -54,34 +56,88 @@ export default function ScanScreen() {
|
|||
};
|
||||
}, [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) {
|
||||
// Camera permissions are still loading.
|
||||
return <View className="flex-1 bg-black" />;
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
// Camera permissions are not granted yet.
|
||||
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">
|
||||
<CameraIcon className="text-primary" size={48} strokeWidth={1.5} />
|
||||
</View>
|
||||
<Text variant="h2" className="text-center mb-2">
|
||||
Camera Access
|
||||
</Text>
|
||||
<Text variant="muted" className="text-center mb-10 leading-6">
|
||||
<Text variant="muted" className="text-center mb-10 leading-6 px-10">
|
||||
We need your permission to use the camera to scan invoices and
|
||||
receipts automatically.
|
||||
</Text>
|
||||
<Button
|
||||
className="w-full h-14 rounded-[12px] bg-primary"
|
||||
className="w-3/4 h-14 rounded-[12px] bg-primary px-10"
|
||||
onPress={requestPermission}
|
||||
>
|
||||
<Text className="text-white font-bold uppercase tracking-widest">
|
||||
Enable Camera
|
||||
</Text>
|
||||
</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>
|
||||
</Pressable>
|
||||
</ScreenWrapper>
|
||||
|
|
@ -91,11 +147,13 @@ export default function ScanScreen() {
|
|||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
<CameraView
|
||||
style={StyleSheet.absoluteFill}
|
||||
ref={cameraRef}
|
||||
style={{ flex: 1 }}
|
||||
facing="back"
|
||||
enableTorch={torch}
|
||||
>
|
||||
<View className="flex-1 justify-between p-10 pt-16">
|
||||
{/* Top bar */}
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Pressable
|
||||
onPress={() => setTorch(!torch)}
|
||||
|
|
@ -109,31 +167,41 @@ export default function ScanScreen() {
|
|||
</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"
|
||||
>
|
||||
<X color="white" size={24} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Scan Frame */}
|
||||
<View className="items-center">
|
||||
{/* Scanning Frame */}
|
||||
<View className="w-72 h-72 border-2 border-primary rounded-3xl border-dashed opacity-50 items-center justify-center">
|
||||
<View className="w-72 h-72 border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
|
||||
<View className="w-64 h-64 border border-white/10 rounded-2xl" />
|
||||
</View>
|
||||
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
|
||||
Align Invoice
|
||||
Align Invoice Within Frame
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center pb-10">
|
||||
<View className="bg-black/40 px-6 py-3 rounded-full border border-white/10">
|
||||
<Text className="text-white/60 text-[10px] font-black uppercase tracking-widest">
|
||||
AI Auto-detecting...
|
||||
{/* Capture Button */}
|
||||
<View className="items-center pb-10 gap-4">
|
||||
<Pressable
|
||||
onPress={handleScan}
|
||||
disabled={scanning}
|
||||
className="h-20 w-20 rounded-full bg-primary items-center justify-center border-4 border-white/30"
|
||||
>
|
||||
{scanning ? (
|
||||
<ActivityIndicator color="white" size="large" />
|
||||
) : (
|
||||
<ScanLine color="white" size={32} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Text className="text-white/50 text-[10px] font-black uppercase tracking-widest">
|
||||
{scanning ? "Extracting Data..." : "Tap to Scan"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
);
|
||||
|
|
|
|||
135
app/_layout.tsx
135
app/_layout.tsx
|
|
@ -1,24 +1,127 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import "../global.css";
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { PortalHost } from "@rn-primitives/portal";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import "@/global.css";
|
||||
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 { 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() {
|
||||
useRestoreTheme();
|
||||
import { useSegments, router as expoRouter } from "expo-router";
|
||||
|
||||
function BackupGuard() {
|
||||
const segments = useSegments();
|
||||
const isAuthed = useAuthStore((s) => s.isAuthenticated);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
||||
<SirouBridge />
|
||||
<BackupGuard />
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<View className="flex-1 bg-background">
|
||||
|
|
@ -31,11 +134,15 @@ export default function RootLayout() {
|
|||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="sms-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="proforma/[id]"
|
||||
options={{ title: "Proforma request" }}
|
||||
/>
|
||||
<Stack.Screen name="payments/[id]" options={{ title: "Payment" }} />
|
||||
<Stack.Screen
|
||||
name="payments/[id]"
|
||||
options={{ title: "Payment" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="notifications/index"
|
||||
options={{ title: "Notifications" }}
|
||||
|
|
@ -52,18 +159,30 @@ export default function RootLayout() {
|
|||
name="register"
|
||||
options={{ title: "Create account", headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen name="invoices/[id]" options={{ title: "Invoice" }} />
|
||||
<Stack.Screen name="reports/index" options={{ title: "Reports" }} />
|
||||
<Stack.Screen
|
||||
name="invoices/[id]"
|
||||
options={{ title: "Invoice" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="reports/index"
|
||||
options={{ title: "Reports" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="documents/index"
|
||||
options={{ title: "Documents" }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
||||
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="edit-profile"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
</Stack>
|
||||
<PortalHost />
|
||||
<Toast />
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</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 { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText, ChevronRight, FolderOpen, Upload } from '@/lib/icons';
|
||||
import { MOCK_DOCUMENTS } from '@/lib/mock-data';
|
||||
import { View, ScrollView, Pressable } from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileText, ChevronRight, FolderOpen, Upload } from "@/lib/icons";
|
||||
import { MOCK_DOCUMENTS } from "@/lib/mock-data";
|
||||
|
||||
const PRIMARY = '#ea580c';
|
||||
const PRIMARY = "#ea580c";
|
||||
|
||||
export default function DocumentsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
|
|
@ -23,21 +25,32 @@ export default function DocumentsScreen() {
|
|||
Uploaded invoices, scans, and attachments. Synced with your account.
|
||||
</Text>
|
||||
|
||||
<Button variant="outline" className="mb-5 min-h-12 rounded-xl border-border" onPress={() => {}}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-5 min-h-12 rounded-xl border-border"
|
||||
onPress={() => {}}
|
||||
>
|
||||
<Upload color={PRIMARY} size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 font-medium text-gray-700">Upload document</Text>
|
||||
</Button>
|
||||
|
||||
{MOCK_DOCUMENTS.map((d) => (
|
||||
<Card key={d.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
||||
<Card
|
||||
key={d.id}
|
||||
className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"
|
||||
>
|
||||
<Pressable>
|
||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
||||
<FileText color={PRIMARY} size={22} strokeWidth={2} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium text-gray-900" numberOfLines={1}>{d.name}</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-sm">{d.size} · {d.uploadedAt}</Text>
|
||||
<Text className="font-medium text-gray-900" numberOfLines={1}>
|
||||
{d.name}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-sm">
|
||||
{d.size} · {d.uploadedAt}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight color="#71717a" size={20} strokeWidth={2} />
|
||||
</CardContent>
|
||||
|
|
@ -45,7 +58,11 @@ export default function DocumentsScreen() {
|
|||
</Card>
|
||||
))}
|
||||
|
||||
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 rounded-xl border-border"
|
||||
onPress={() => nav.back()}
|
||||
>
|
||||
<Text className="font-medium">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
|
|
|
|||
162
app/edit-profile.tsx
Normal file
162
app/edit-profile.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
import { ArrowLeft, User, Check, X } from "@/lib/icons";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/lib/toast-store";
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
export default function EditProfileScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const { user, updateUser } = useAuthStore();
|
||||
const { showToast } = useToast();
|
||||
const { colorScheme } = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [firstName, setFirstName] = useState(user?.firstName || "");
|
||||
const [lastName, setLastName] = useState(user?.lastName || "");
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!firstName.trim() || !lastName.trim()) {
|
||||
showToast("First and last name are required", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.users.updateProfile({
|
||||
body: {
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update local store with the returned user data
|
||||
updateUser(response);
|
||||
|
||||
showToast("Profile updated successfully", "success");
|
||||
nav.back();
|
||||
} catch (e: any) {
|
||||
console.error("[EditProfile] Update failed:", e);
|
||||
showToast(e.message || "Failed to update profile", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
{/* Header */}
|
||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||
<Pressable
|
||||
onPress={() => nav.back()}
|
||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||
>
|
||||
<ArrowLeft color={isDark ? "#fff" : "#0f172a"} size={20} />
|
||||
</Pressable>
|
||||
<Text variant="h4" className="text-foreground font-semibold">
|
||||
Edit Profile
|
||||
</Text>
|
||||
<View className="w-10" /> {/* Spacer */}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 32,
|
||||
paddingBottom: 40,
|
||||
}}
|
||||
>
|
||||
<View className="gap-6">
|
||||
{/* First Name */}
|
||||
<View>
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-semibold mb-2 ml-1 text-foreground/70"
|
||||
>
|
||||
First Name
|
||||
</Text>
|
||||
<View className="flex-row items-center 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 { View, ScrollView, Pressable } from "react-native";
|
||||
import { useLocalSearchParams, router, Stack } from "expo-router";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
|
@ -10,52 +12,66 @@ import {
|
|||
Share2,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
Tag,
|
||||
CreditCard,
|
||||
Building2,
|
||||
ExternalLink,
|
||||
} from "@/lib/icons";
|
||||
import { MOCK_INVOICES } from "@/lib/mock-data";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{
|
||||
description: "Marketing Landing Page Package",
|
||||
qty: 1,
|
||||
unitPrice: 1000,
|
||||
total: 1000,
|
||||
},
|
||||
{
|
||||
description: "Instagram Post Initial Design",
|
||||
qty: 4,
|
||||
unitPrice: 100,
|
||||
total: 400,
|
||||
},
|
||||
];
|
||||
import { StandardHeader } from "@/components/StandardHeader";
|
||||
import { api } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast-store";
|
||||
|
||||
export default function InvoiceDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const invoice = MOCK_INVOICES.find((i) => i.id === id);
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const { id } = useLocalSearchParams();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [invoice, setInvoice] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice();
|
||||
}, [id]);
|
||||
|
||||
const fetchInvoice = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.invoices.getById({ params: { id: id as string } });
|
||||
setInvoice(data);
|
||||
} catch (error: any) {
|
||||
console.error("[InvoiceDetail] Error:", error);
|
||||
toast.error("Error", "Failed to load invoice details");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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 (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<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>
|
||||
<StandardHeader title="Invoice Details" showBack />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
|
|
@ -70,12 +86,12 @@ export default function InvoiceDetailScreen() {
|
|||
<FileText color="white" size={16} strokeWidth={2.5} />
|
||||
</View>
|
||||
<View
|
||||
className={`rounded-[6px] px-3 py-1 ${invoice?.status === "Paid" ? "bg-emerald-500/20" : "bg-white/15"}`}
|
||||
className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-[10px] font-bold ${invoice?.status === "Paid" ? "text-emerald-400" : "text-white"}`}
|
||||
className={`text-[10px] font-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
|
||||
>
|
||||
{invoice?.status || "Pending"}
|
||||
{invoice.status || "Pending"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -84,19 +100,19 @@ export default function InvoiceDetailScreen() {
|
|||
Total Amount
|
||||
</Text>
|
||||
<Text variant="h3" className="text-white font-bold mb-3">
|
||||
${invoice?.amount.toLocaleString() ?? "—"}
|
||||
${Number(invoice.amount).toLocaleString()}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Calendar color="rgba(255,255,255,0.9)" size={12} />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
Due {invoice?.dueDate || "—"}
|
||||
Due {new Date(invoice.dueDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-3 w-[1px] bg-white/60" />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
#{invoice?.invoiceNumber || id}
|
||||
#{invoice.invoiceNumber || id}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -107,26 +123,30 @@ export default function InvoiceDetailScreen() {
|
|||
<View className="flex-row px-4 py-2">
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<View className="flex-col">
|
||||
<Text className="text-foreground text-xs">Recipient</Text>
|
||||
<Text className="text-foreground text-xs opacity-60">
|
||||
Recipient
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{invoice?.recipient || "—"}
|
||||
{invoice.customerName || "—"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="w-[1px] bg-border/70 mx-3" />
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<View className="flex-col">
|
||||
<Text className="text-foreground text-xs">Category</Text>
|
||||
<Text className="text-foreground text-xs opacity-60">
|
||||
Category
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
Subscription
|
||||
General
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -136,33 +156,47 @@ export default function InvoiceDetailScreen() {
|
|||
{/* Items / Billing Summary */}
|
||||
<Card className="mb-4 bg-card rounded-[6px]">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text variant="small" className="">
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-bold opacity-60 uppercase text-[10px] tracking-widest"
|
||||
>
|
||||
Billing Summary
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{MOCK_ITEMS.map((item, i) => (
|
||||
<View
|
||||
key={i}
|
||||
className={`flex-row justify-between py-3 ${i < MOCK_ITEMS.length - 1 ? "border-b border-border/70" : ""}`}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text variant="muted" className="text-[10px] mt-0.5">
|
||||
QTY: {item.qty} · ${item.unitPrice}/unit
|
||||
Subtotal
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||
${item.total.toLocaleString()}
|
||||
$
|
||||
{(
|
||||
Number(invoice.amount) - (Number(invoice.taxAmount) || 0)
|
||||
).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{Number(invoice.taxAmount) > 0 && (
|
||||
<View className="flex-row justify-between py-3 border-b border-border/70">
|
||||
<View className="flex-1 pr-4">
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold text-sm"
|
||||
>
|
||||
Tax
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||
+ ${Number(invoice.taxAmount).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="mt-3 pt-3 flex-row justify-between items-center border-t border-border/70">
|
||||
<Text variant="muted" className="font-semibold text-sm">
|
||||
|
|
@ -172,7 +206,7 @@ export default function InvoiceDetailScreen() {
|
|||
variant="h3"
|
||||
className="text-foreground font-semibold text-xl tracking-tight"
|
||||
>
|
||||
${invoice?.amount.toLocaleString() || "0"}
|
||||
${Number(invoice.amount).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -181,22 +215,22 @@ export default function InvoiceDetailScreen() {
|
|||
{/* Actions */}
|
||||
<View className="flex-row gap-3">
|
||||
<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={() => {}}
|
||||
>
|
||||
<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
|
||||
</Text>
|
||||
</Button>
|
||||
<ShadowWrapper>
|
||||
<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={() => {}}
|
||||
>
|
||||
<Download color="#000" size={14} strokeWidth={2.5} />
|
||||
<Text className="text-black text-xs font-semibold uppercase tracking-widest">
|
||||
Download PDF
|
||||
<Download color="#0f172a" size={14} strokeWidth={2.5} />
|
||||
<Text className="ml-2 text-foreground text-[11px] font-bold uppercase tracking-widest">
|
||||
PDF
|
||||
</Text>
|
||||
</Button>
|
||||
</ShadowWrapper>
|
||||
|
|
|
|||
242
app/login.tsx
242
app/login.tsx
|
|
@ -1,40 +1,220 @@
|
|||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Mail, ArrowLeft } from '@/lib/icons';
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Image,
|
||||
} from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User } 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() {
|
||||
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 (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
<ScreenWrapper className="bg-background">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1"
|
||||
>
|
||||
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
|
||||
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Sign in</CardTitle>
|
||||
<CardDescription className="mt-1">Use the same account as the web app.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="gap-3">
|
||||
<Button className="min-h-12 rounded-xl bg-primary">
|
||||
<Mail color="#ffffff" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
|
||||
</Button>
|
||||
<Button variant="outline" className="min-h-12 rounded-xl border-border">
|
||||
<Text className="font-medium text-gray-700">Continue with Google</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Pressable onPress={() => router.push('/register')} className="mt-4">
|
||||
<Text className="text-center text-primary font-medium">Create account</Text>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 24, paddingTop: 60 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Logo / Branding */}
|
||||
<View className="items-center mb-10">
|
||||
<Text variant="h2" className="mt-6 font-bold text-foreground">
|
||||
Login
|
||||
</Text>
|
||||
<Text variant="muted" className="mt-2 text-center">
|
||||
Sign in to manage your tickets & invoices
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Form */}
|
||||
<View className="gap-5">
|
||||
<View>
|
||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||
Email or Phone Number
|
||||
</Text>
|
||||
<View className="flex-row items-center 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>
|
||||
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
|
||||
<ArrowLeft color="#71717a" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</KeyboardAvoidingView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,62 @@
|
|||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Bell, Settings, ChevronRight } from '@/lib/icons';
|
||||
import { View, ScrollView, Pressable } from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Bell, Settings, ChevronRight } from "@/lib/icons";
|
||||
|
||||
const MOCK_NOTIFICATIONS = [
|
||||
{ id: '1', title: 'Invoice reminder', body: 'Invoice #2 to Robin Murray is due in 2 days.', time: '2h ago', read: false },
|
||||
{ id: '2', title: 'Payment received', body: 'Payment of $500 received for Invoice #4.', time: '1d ago', read: true },
|
||||
{ id: '3', title: 'Proforma submission', body: 'Vendor A submitted a quote for Marketing Landing Page.', time: '2d ago', read: true },
|
||||
{
|
||||
id: "1",
|
||||
title: "Invoice reminder",
|
||||
body: "Invoice #2 to Robin Murray is due in 2 days.",
|
||||
time: "2h ago",
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Payment received",
|
||||
body: "Payment of $500 received for Invoice #4.",
|
||||
time: "1d ago",
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Proforma submission",
|
||||
body: "Vendor A submitted a quote for Marketing Landing Page.",
|
||||
time: "2d ago",
|
||||
read: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
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="flex-row items-center gap-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>
|
||||
<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} />
|
||||
<Text className="text-primary font-medium">Settings</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{MOCK_NOTIFICATIONS.map((n) => (
|
||||
<Card key={n.id} className={`mb-2 ${!n.read ? 'border-primary/30' : ''}`}>
|
||||
<Card
|
||||
key={n.id}
|
||||
className={`mb-2 ${!n.read ? "border-primary/30" : ""}`}
|
||||
>
|
||||
<CardContent className="py-3">
|
||||
<Text className="font-semibold text-gray-900">{n.title}</Text>
|
||||
<Text className="text-muted-foreground mt-1 text-sm">{n.body}</Text>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { View, ScrollView, Switch } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { View, ScrollView, Switch } from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function NotificationSettingsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const [invoiceReminders, setInvoiceReminders] = useState(true);
|
||||
const [daysBeforeDue, setDaysBeforeDue] = useState(2);
|
||||
const [newsAlerts, setNewsAlerts] = useState(true);
|
||||
const [reportReady, setReportReady] = useState(true);
|
||||
|
||||
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">
|
||||
<CardHeader>
|
||||
<CardTitle>Notification settings</CardTitle>
|
||||
|
|
@ -20,7 +25,10 @@ export default function NotificationSettingsScreen() {
|
|||
<CardContent className="gap-4">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-900">Invoice reminders</Text>
|
||||
<Switch value={invoiceReminders} onValueChange={setInvoiceReminders} />
|
||||
<Switch
|
||||
value={invoiceReminders}
|
||||
onValueChange={setInvoiceReminders}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-900">News & announcements</Text>
|
||||
|
|
@ -33,7 +41,7 @@ export default function NotificationSettingsScreen() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button variant="outline" onPress={() => router.back()}>
|
||||
<Button variant="outline" onPress={() => nav.back()}>
|
||||
<Text className="font-medium">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
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 { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
|
@ -7,7 +9,8 @@ import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons";
|
|||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
|
||||
export default function PaymentDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const { id } = useSirouParams<AppRoutes, "payments/[id]">();
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
|
|
@ -15,7 +18,7 @@ export default function PaymentDetailScreen() {
|
|||
|
||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
onPress={() => nav.back()}
|
||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||
>
|
||||
<ArrowLeft color="#0f172a" size={20} />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import {
|
|||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
} 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 {
|
||||
ArrowLeft,
|
||||
|
|
@ -29,6 +30,11 @@ import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { saveTheme, AppTheme } from "@/lib/theme";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
const AVATAR_FALLBACK_BASE =
|
||||
"https://ui-avatars.com/api/?background=ea580c&color=fff&name=";
|
||||
|
||||
// ── Theme bottom sheet ────────────────────────────────────────────
|
||||
const THEME_OPTIONS = [
|
||||
|
|
@ -168,6 +174,8 @@ function MenuItem({
|
|||
|
||||
// ── Screen ────────────────────────────────────────────────────────
|
||||
export default function ProfileScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { setColorScheme, colorScheme } = useColorScheme();
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [themeSheetVisible, setThemeSheetVisible] = useState(false);
|
||||
|
|
@ -184,7 +192,7 @@ export default function ProfileScreen() {
|
|||
{/* Header */}
|
||||
<View className="px-6 pt-4 flex-row justify-between items-center">
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
onPress={() => nav.back()}
|
||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||
>
|
||||
<ArrowLeft color="#0f172a" size={20} />
|
||||
|
|
@ -194,10 +202,10 @@ export default function ProfileScreen() {
|
|||
</Text>
|
||||
{/* Edit Profile shortcut */}
|
||||
<Pressable
|
||||
onPress={() => {}}
|
||||
onPress={() => nav.go("edit-profile")}
|
||||
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>
|
||||
</View>
|
||||
|
||||
|
|
@ -211,19 +219,21 @@ export default function ProfileScreen() {
|
|||
>
|
||||
{/* Avatar */}
|
||||
<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
|
||||
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"
|
||||
/>
|
||||
</View>
|
||||
<Text variant="h4" className="text-foreground font-bold">
|
||||
Ms. Charlotte
|
||||
<Text variant="h4" className="text-foreground">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</Text>
|
||||
<Text variant="muted" className="text-sm mt-0.5">
|
||||
charlotte@example.com
|
||||
{user?.email}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
|
@ -301,7 +311,7 @@ export default function ProfileScreen() {
|
|||
icon={<LogOut color="#ef4444" size={17} />}
|
||||
label="Log Out"
|
||||
destructive
|
||||
onPress={() => {}}
|
||||
onPress={logout}
|
||||
right={null}
|
||||
isLast
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React from "react";
|
||||
import { View, ScrollView, Pressable } from "react-native";
|
||||
import { useLocalSearchParams, router, Stack } from "expo-router";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
|
@ -8,104 +10,169 @@ import {
|
|||
ArrowLeft,
|
||||
DraftingCompass,
|
||||
Clock,
|
||||
Tag,
|
||||
Send,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
} from "@/lib/icons";
|
||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{
|
||||
description: "Marketing Landing Page Package",
|
||||
qty: 1,
|
||||
unitPrice: 1000,
|
||||
total: 1000,
|
||||
},
|
||||
{
|
||||
description: "Instagram Post Initial Design",
|
||||
qty: 4,
|
||||
unitPrice: 100,
|
||||
total: 400,
|
||||
},
|
||||
];
|
||||
const MOCK_SUBTOTAL = 1400;
|
||||
const MOCK_TAX = 140;
|
||||
const MOCK_TOTAL = 1540;
|
||||
import { StandardHeader } from "@/components/StandardHeader";
|
||||
import { api } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast-store";
|
||||
|
||||
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 (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<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">
|
||||
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>
|
||||
{/* Header */}
|
||||
<StandardHeader title="Proforma" showBack />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
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="flex-row items-center justify-between mb-3">
|
||||
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
||||
<DraftingCompass color="white" size={16} strokeWidth={2.5} />
|
||||
</View>
|
||||
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
|
||||
<Text className={`text-[10px] font-bold text-white`}>
|
||||
Open Request
|
||||
<Text className="text-[10px] font-bold text-white uppercase tracking-widest">
|
||||
ACTIVE
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text variant="small" className="text-white/70 mb-0.5">
|
||||
Target Package
|
||||
Customer: {proforma.customerName}
|
||||
</Text>
|
||||
<Text variant="h3" className="text-white font-bold mb-3">
|
||||
Marketing Landing Page
|
||||
{proforma.description || "Proforma Request"}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Clock color="rgba(255,255,255,0.9)" size={12} />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
Expires in 5 days
|
||||
Due {new Date(proforma.dueDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-3 w-[1px] bg-white/60" />
|
||||
<Text className="text-white/90 text-xs font-semibold">
|
||||
REQ-{id || "002"}
|
||||
{proforma.proformaNumber}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</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">
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text variant="small" className="font-semibold">
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-bold uppercase tracking-widest text-[10px] opacity-60"
|
||||
>
|
||||
Line Items
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{MOCK_ITEMS.map((item, i) => (
|
||||
{proforma.items?.map((item: any, i: number) => (
|
||||
<View
|
||||
key={i}
|
||||
className={`flex-row justify-between py-3 ${i < MOCK_ITEMS.length - 1 ? "border-b border-border/40" : ""}`}
|
||||
key={item.id || i}
|
||||
className={`flex-row justify-between py-3 ${i < proforma.items.length - 1 ? "border-b border-border/40" : ""}`}
|
||||
>
|
||||
<View className="flex-1 pr-4">
|
||||
<Text
|
||||
|
|
@ -115,11 +182,12 @@ export default function ProformaDetailScreen() {
|
|||
{item.description}
|
||||
</Text>
|
||||
<Text variant="muted" className="text-[10px] mt-0.5">
|
||||
{item.qty} × ${item.unitPrice.toLocaleString()}
|
||||
{item.quantity} × {proforma.currency}{" "}
|
||||
{Number(item.unitPrice).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||
${item.total.toLocaleString()}
|
||||
{proforma.currency} {Number(item.total).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
|
|
@ -133,59 +201,76 @@ export default function ProformaDetailScreen() {
|
|||
Subtotal
|
||||
</Text>
|
||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||
${MOCK_SUBTOTAL.toLocaleString()}
|
||||
{proforma.currency} {subtotal.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
{Number(proforma.taxAmount) > 0 && (
|
||||
<View className="flex-row justify-between">
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-semibold text-sm"
|
||||
>
|
||||
Tax (10%)
|
||||
Tax
|
||||
</Text>
|
||||
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||
${MOCK_TAX.toLocaleString()}
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-bold text-sm"
|
||||
>
|
||||
{proforma.currency}{" "}
|
||||
{Number(proforma.taxAmount).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{Number(proforma.discountAmount) > 0 && (
|
||||
<View className="flex-row justify-between">
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-red-500 font-semibold text-sm"
|
||||
>
|
||||
Discount
|
||||
</Text>
|
||||
<Text variant="p" className="text-red-500 font-bold text-sm">
|
||||
-{proforma.currency}{" "}
|
||||
{Number(proforma.discountAmount).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="flex-row justify-between items-center mt-1">
|
||||
<Text variant="p" className="text-foreground font-semibold">
|
||||
Estimated Total
|
||||
<Text variant="p" className="text-foreground font-bold">
|
||||
Total Amount
|
||||
</Text>
|
||||
<Text
|
||||
variant="h4"
|
||||
className="text-foreground font-bold tracking-tight"
|
||||
>
|
||||
${MOCK_TOTAL.toLocaleString()}
|
||||
{proforma.currency} {Number(proforma.amount).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Text variant="h4" className="text-foreground mb-2">
|
||||
Recent Submissions
|
||||
{/* Notes Section (New) */}
|
||||
{proforma.notes && (
|
||||
<Card className="bg-card rounded-[6px] mb-4">
|
||||
<View className="p-4">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-bold uppercase tracking-widest text-[10px] opacity-60 mb-2"
|
||||
>
|
||||
Additional Notes
|
||||
</Text>
|
||||
|
||||
<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
|
||||
variant="p"
|
||||
className="text-foreground font-semibold text-sm"
|
||||
className="text-foreground font-medium text-xs leading-5"
|
||||
>
|
||||
Vendor A — $1,450
|
||||
</Text>
|
||||
<Text variant="muted" className="text-xs mt-0.5">
|
||||
Submitted 2 hours ago
|
||||
{proforma.notes}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight className="text-muted-foreground/50" size={16} />
|
||||
</Pressable>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
className="flex-1 h-11 rounded-[6px] bg-primary"
|
||||
|
|
@ -198,7 +283,7 @@ export default function ProformaDetailScreen() {
|
|||
</Button>
|
||||
<Button
|
||||
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">
|
||||
Back
|
||||
|
|
|
|||
|
|
@ -1,25 +1,38 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
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 { 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 { 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 };
|
||||
|
||||
// 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({
|
||||
input: {
|
||||
height: 44,
|
||||
|
|
@ -71,7 +84,10 @@ function Field({
|
|||
const c = useInputColors();
|
||||
return (
|
||||
<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}
|
||||
</Text>
|
||||
<TextInput
|
||||
|
|
@ -92,17 +108,52 @@ function Field({
|
|||
);
|
||||
}
|
||||
|
||||
const CURRENCIES = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
|
||||
|
||||
export default function CreateProformaScreen() {
|
||||
const [company, setCompany] = useState("");
|
||||
const [project, setProject] = useState("");
|
||||
const [validity, setValidity] = useState("");
|
||||
const [terms, setTerms] = useState("");
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Fields
|
||||
const [proformaNumber, setProformaNumber] = useState("");
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [customerEmail, setCustomerEmail] = useState("");
|
||||
const [customerPhone, setCustomerPhone] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [currency, setCurrency] = useState("USD");
|
||||
const [taxAmount, setTaxAmount] = useState("0");
|
||||
const [discountAmount, setDiscountAmount] = useState("0");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
// Dates
|
||||
const [issueDate, setIssueDate] = useState(
|
||||
new Date().toISOString().split("T")[0],
|
||||
);
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
|
||||
const [items, setItems] = useState<Item[]>([
|
||||
{ id: 1, description: "", qty: "1", price: "" },
|
||||
]);
|
||||
|
||||
const c = useInputColors();
|
||||
|
||||
// Modal States
|
||||
const [showCurrency, setShowCurrency] = useState(false);
|
||||
const [showIssueDate, setShowIssueDate] = useState(false);
|
||||
const [showDueDate, setShowDueDate] = useState(false);
|
||||
|
||||
// Auto-generate Proforma Number and set default dates on mount
|
||||
useEffect(() => {
|
||||
const year = new Date().getFullYear();
|
||||
const random = Math.floor(1000 + Math.random() * 9000);
|
||||
setProformaNumber(`PROF-${year}-${random}`);
|
||||
|
||||
// Default Due Date: 30 days from now
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30);
|
||||
setDueDate(d.toISOString().split("T")[0]);
|
||||
}, []);
|
||||
|
||||
const updateField = (id: number, field: keyof Item, value: string) =>
|
||||
setItems((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)),
|
||||
|
|
@ -119,78 +170,196 @@ export default function CreateProformaScreen() {
|
|||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const total = items.reduce(
|
||||
const subtotal = items.reduce(
|
||||
(sum, item) =>
|
||||
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const total =
|
||||
subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!customerName) {
|
||||
toast.error("Validation Error", "Please enter a customer name");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Handle Phone Formatting (Auto-prepend +251 if needed)
|
||||
const formattedPhone = customerPhone.startsWith("+")
|
||||
? customerPhone
|
||||
: customerPhone.length > 0
|
||||
? `+251${customerPhone}`
|
||||
: "";
|
||||
|
||||
const payload = {
|
||||
proformaNumber,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone: formattedPhone,
|
||||
amount: Number(total.toFixed(2)),
|
||||
currency,
|
||||
issueDate: new Date(issueDate).toISOString(),
|
||||
dueDate: new Date(dueDate).toISOString(),
|
||||
description: description || `Proforma for ${customerName}`,
|
||||
notes,
|
||||
taxAmount: parseFloat(taxAmount) || 0,
|
||||
discountAmount: parseFloat(discountAmount) || 0,
|
||||
items: items.map((i) => ({
|
||||
description: i.description || "Item",
|
||||
quantity: parseFloat(i.qty) || 0,
|
||||
unitPrice: parseFloat(i.price) || 0,
|
||||
total: Number(
|
||||
((parseFloat(i.qty) || 0) * (parseFloat(i.price) || 0)).toFixed(2),
|
||||
),
|
||||
})),
|
||||
};
|
||||
|
||||
await api.proforma.create({ body: payload });
|
||||
toast.success("Success", "Proforma created successfully!");
|
||||
nav.back();
|
||||
} catch (err: any) {
|
||||
console.error("[ProformaCreate] Error:", err);
|
||||
toast.error("Error", err.message || "Failed to create proforma");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper className="bg-background">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<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>
|
||||
<StandardHeader title="Create Proforma" showBack />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 140 }}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 30 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Recipient */}
|
||||
<Label>Recipient</Label>
|
||||
{/* Header Info */}
|
||||
<Label>General Information</Label>
|
||||
<ShadowWrapper>
|
||||
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||
<Field
|
||||
label="Company / Name"
|
||||
value={company}
|
||||
onChangeText={setCompany}
|
||||
placeholder="e.g. Acme Corp"
|
||||
label="Proforma Number"
|
||||
value={proformaNumber}
|
||||
onChangeText={setProformaNumber}
|
||||
placeholder="e.g. PROF-2024-001"
|
||||
/>
|
||||
<Field
|
||||
label="Project Title"
|
||||
value={project}
|
||||
onChangeText={setProject}
|
||||
placeholder="e.g. Website Redesign"
|
||||
label="Project Description"
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="e.g. Web Development Services"
|
||||
/>
|
||||
</View>
|
||||
</ShadowWrapper>
|
||||
|
||||
{/* Terms */}
|
||||
<Label>Terms & Validity</Label>
|
||||
{/* Recipient */}
|
||||
<Label>Customer Details</Label>
|
||||
<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">
|
||||
<Field
|
||||
label="Validity (days)"
|
||||
value={validity}
|
||||
onChangeText={setValidity}
|
||||
placeholder="30"
|
||||
numeric
|
||||
label="Email"
|
||||
value={customerEmail}
|
||||
onChangeText={setCustomerEmail}
|
||||
placeholder="billing@acme.com"
|
||||
flex={1}
|
||||
/>
|
||||
<Field
|
||||
label="Payment Terms"
|
||||
value={terms}
|
||||
onChangeText={setTerms}
|
||||
placeholder="e.g. 50% upfront"
|
||||
flex={2}
|
||||
label="Phone"
|
||||
value={customerPhone}
|
||||
onChangeText={setCustomerPhone}
|
||||
placeholder="+251..."
|
||||
flex={1}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ShadowWrapper>
|
||||
|
||||
{/* Schedule */}
|
||||
<Label>Schedule & Currency</Label>
|
||||
<ShadowWrapper>
|
||||
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||
<View className="flex-row gap-4">
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||
>
|
||||
Issue Date
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setShowIssueDate(true)}
|
||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-medium"
|
||||
style={{ color: c.text }}
|
||||
>
|
||||
{issueDate}
|
||||
</Text>
|
||||
<CalendarSearch size={14} color="#ea580c" strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||
>
|
||||
Due Date
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setShowDueDate(true)}
|
||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-medium"
|
||||
style={{ color: c.text }}
|
||||
>
|
||||
{dueDate || "Select Date"}
|
||||
</Text>
|
||||
<Calendar size={14} color="#ea580c" strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row gap-4">
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||
>
|
||||
Currency
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setShowCurrency(true)}
|
||||
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||
>
|
||||
<Text className="text-xs font-bold" style={{ color: c.text }}>
|
||||
{currency}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="flex-[2]" />
|
||||
</View>
|
||||
</View>
|
||||
</ShadowWrapper>
|
||||
|
||||
{/* Items */}
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<Label noMargin>Billable Items</Label>
|
||||
|
|
@ -207,12 +376,12 @@ export default function CreateProformaScreen() {
|
|||
|
||||
<View className="gap-3 mb-5">
|
||||
{items.map((item, index) => (
|
||||
<ShadowWrapper>
|
||||
<View key={item.id} className="bg-card rounded-[6px] p-4">
|
||||
<ShadowWrapper key={item.id}>
|
||||
<View className="bg-card rounded-[6px] p-4">
|
||||
<View className="flex-row justify-between items-center mb-3">
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[12px] font-bold uppercase tracking-wide"
|
||||
className="text-[10px] font-bold uppercase tracking-wide opacity-50"
|
||||
>
|
||||
Item {index + 1}
|
||||
</Text>
|
||||
|
|
@ -223,89 +392,43 @@ export default function CreateProformaScreen() {
|
|||
)}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
variant="muted"
|
||||
className="text-[11px] font-semibold mb-1.5"
|
||||
>
|
||||
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}
|
||||
<Field
|
||||
label="Description"
|
||||
placeholder="e.g. UI Design"
|
||||
value={item.description}
|
||||
onChangeText={(v) => updateField(item.id, "description", v)}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
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,
|
||||
},
|
||||
]}
|
||||
<View className="flex-row gap-3 mt-4">
|
||||
<Field
|
||||
label="Qty"
|
||||
placeholder="1"
|
||||
placeholderTextColor={c.placeholder}
|
||||
keyboardType="numeric"
|
||||
numeric
|
||||
center
|
||||
value={item.qty}
|
||||
onChangeText={(v) => updateField(item.id, "qty", v)}
|
||||
returnKeyType="next"
|
||||
flex={1}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-[2]">
|
||||
<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,
|
||||
},
|
||||
]}
|
||||
<Field
|
||||
label="Price"
|
||||
placeholder="0.00"
|
||||
placeholderTextColor={c.placeholder}
|
||||
keyboardType="numeric"
|
||||
numeric
|
||||
value={item.price}
|
||||
onChangeText={(v) => updateField(item.id, "price", v)}
|
||||
returnKeyType="done"
|
||||
flex={2}
|
||||
/>
|
||||
</View>
|
||||
<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
|
||||
</Text>
|
||||
<Text
|
||||
variant="p"
|
||||
className="text-foreground font-bold text-sm"
|
||||
>
|
||||
$
|
||||
{currency}
|
||||
{(
|
||||
(parseFloat(item.qty) || 0) *
|
||||
(parseFloat(item.price) || 0)
|
||||
|
|
@ -319,13 +442,74 @@ export default function CreateProformaScreen() {
|
|||
</View>
|
||||
|
||||
{/* Summary */}
|
||||
<View className="border border-border/60 rounded-[6px] p-4 bg-secondary/10 mb-6">
|
||||
<View className="flex-row justify-between items-center mb-4">
|
||||
<Text variant="muted" className="font-semibold text-sm">
|
||||
Estimated Total
|
||||
<Label>Totals & Taxes</Label>
|
||||
<ShadowWrapper>
|
||||
<View className="bg-card rounded-[6px] p-4 mb-5 gap-3">
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text variant="muted" className="text-xs font-medium">
|
||||
Subtotal
|
||||
</Text>
|
||||
<Text variant="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", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
|
|
@ -335,22 +519,83 @@ export default function CreateProformaScreen() {
|
|||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-11 rounded-[6px] border-border bg-card"
|
||||
onPress={() => router.back()}
|
||||
className="flex-1 h-12 rounded-[6px] border-border bg-card"
|
||||
onPress={() => nav.back()}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text className="text-foreground font-semibold text-[11px] uppercase tracking-widest">
|
||||
Cancel
|
||||
<Text className="text-foreground font-bold text-xs uppercase tracking-tighter">
|
||||
Discard
|
||||
</Text>
|
||||
</Button>
|
||||
<Button className="flex-1 h-11 rounded-[6px] bg-primary">
|
||||
<Send color="white" size={14} strokeWidth={2.5} />
|
||||
<Text className=" text-white font-bold text-[11px] uppercase tracking-widest">
|
||||
Create & Share
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-[6px] bg-primary"
|
||||
onPress={handleSubmit}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Currency Modal */}
|
||||
<PickerModal
|
||||
visible={showCurrency}
|
||||
onClose={() => setShowCurrency(false)}
|
||||
title="Select Currency"
|
||||
>
|
||||
{CURRENCIES.map((curr) => (
|
||||
<SelectOption
|
||||
key={curr}
|
||||
label={curr}
|
||||
value={curr}
|
||||
selected={currency === curr}
|
||||
onSelect={(v) => {
|
||||
setCurrency(v);
|
||||
setShowCurrency(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</PickerModal>
|
||||
|
||||
{/* Issue Date Modal */}
|
||||
<PickerModal
|
||||
visible={showIssueDate}
|
||||
onClose={() => setShowIssueDate(false)}
|
||||
title="Select Issue Date"
|
||||
>
|
||||
<CalendarGrid
|
||||
selectedDate={issueDate}
|
||||
onSelect={(v) => {
|
||||
setIssueDate(v);
|
||||
setShowIssueDate(false);
|
||||
}}
|
||||
/>
|
||||
</PickerModal>
|
||||
|
||||
{/* Due Date Modal */}
|
||||
<PickerModal
|
||||
visible={showDueDate}
|
||||
onClose={() => setShowDueDate(false)}
|
||||
title="Select Due Date"
|
||||
>
|
||||
<CalendarGrid
|
||||
selectedDate={dueDate}
|
||||
onSelect={(v) => {
|
||||
setDueDate(v);
|
||||
setShowDueDate(false);
|
||||
}}
|
||||
/>
|
||||
</PickerModal>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -363,7 +608,10 @@ function Label({
|
|||
noMargin?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Text variant="muted" className={`font-semibold ${noMargin ? "" : "mb-3"}`}>
|
||||
<Text
|
||||
variant="small"
|
||||
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3"}`}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
|
|
|||
252
app/register.tsx
252
app/register.tsx
|
|
@ -1,40 +1,230 @@
|
|||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Mail, ArrowLeft, UserPlus } from '@/lib/icons';
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
User,
|
||||
Phone,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
TrianglePlanets,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Chrome,
|
||||
} 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() {
|
||||
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 (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
<ScreenWrapper className="bg-background">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1"
|
||||
>
|
||||
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
|
||||
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Create account</CardTitle>
|
||||
<CardDescription className="mt-1">Register with the same account format as the web app.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="gap-3">
|
||||
<Button className="min-h-12 rounded-xl bg-primary">
|
||||
<UserPlus color="#ffffff" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 24, paddingBottom: 60 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="mb-10 mt-10">
|
||||
<Text
|
||||
variant="h2"
|
||||
className="mt-6 font-bold text-foreground text-center"
|
||||
>
|
||||
Create Account
|
||||
</Text>
|
||||
<Text variant="muted" className="mt-2 text-center">
|
||||
Join Yaltopia and start managing your business
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-5">
|
||||
<View className="flex-row gap-4">
|
||||
<View className="flex-1">
|
||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||
First Name
|
||||
</Text>
|
||||
<View className="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 variant="outline" className="min-h-12 rounded-xl border-border">
|
||||
<Text className="font-medium text-gray-700">Continue with Google</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Pressable onPress={() => router.push('/login')} className="mt-2">
|
||||
<Text className="text-center text-primary font-medium">Already have an account? Sign in</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
className="mt-10 items-center justify-center py-2"
|
||||
onPress={() => nav.go("login")}
|
||||
>
|
||||
<Text className="text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Text className="text-primary">Sign In</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
|
||||
<ArrowLeft color="#71717a" size={20} strokeWidth={2} />
|
||||
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FileText, Download, ChevronRight, BarChart3 } from '@/lib/icons';
|
||||
import { MOCK_REPORTS } from '@/lib/mock-data';
|
||||
import { View, ScrollView, Pressable } from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FileText, Download, ChevronRight, BarChart3 } from "@/lib/icons";
|
||||
import { MOCK_REPORTS } from "@/lib/mock-data";
|
||||
|
||||
const PRIMARY = '#ea580c';
|
||||
const PRIMARY = "#ea580c";
|
||||
|
||||
export default function ReportsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
|
|
@ -24,7 +26,10 @@ export default function ReportsScreen() {
|
|||
</Text>
|
||||
|
||||
{MOCK_REPORTS.map((r) => (
|
||||
<Card key={r.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
|
||||
<Card
|
||||
key={r.id}
|
||||
className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"
|
||||
>
|
||||
<Pressable>
|
||||
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
|
||||
<View className="mr-3 rounded-xl bg-primary/10 p-2">
|
||||
|
|
@ -32,8 +37,12 @@ export default function ReportsScreen() {
|
|||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="font-semibold text-gray-900">{r.title}</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-sm">{r.period}</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-xs">Generated {r.generatedAt}</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-sm">
|
||||
{r.period}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground mt-0.5 text-xs">
|
||||
Generated {r.generatedAt}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Pressable className="rounded-lg bg-primary/10 p-2">
|
||||
|
|
@ -46,7 +55,11 @@ export default function ReportsScreen() {
|
|||
</Card>
|
||||
))}
|
||||
|
||||
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 rounded-xl border-border"
|
||||
onPress={() => nav.back()}
|
||||
>
|
||||
<Text className="font-medium">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Settings, Bell, Globe, ChevronRight, Info } from '@/lib/icons';
|
||||
import { View, ScrollView, Pressable } from "react-native";
|
||||
import { useSirouRouter } from "@sirou/react-native";
|
||||
import { AppRoutes } from "@/lib/routes";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Settings, Bell, Globe, ChevronRight, Info } from "@/lib/icons";
|
||||
|
||||
const PRIMARY = '#ea580c';
|
||||
const PRIMARY = "#ea580c";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const nav = useSirouRouter<AppRoutes>();
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f5f5f5]"
|
||||
|
|
@ -26,7 +28,7 @@ export default function SettingsScreen() {
|
|||
<CardContent className="gap-0">
|
||||
<Pressable
|
||||
className="flex-row items-center justify-between border-b border-border py-3"
|
||||
onPress={() => router.push('/notifications/settings')}
|
||||
onPress={() => nav.go("notifications/settings")}
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Bell color="#71717a" size={20} strokeWidth={2} />
|
||||
|
|
@ -60,11 +62,16 @@ export default function SettingsScreen() {
|
|||
|
||||
<View className="rounded-xl border border-border bg-white p-4">
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
API: Invoices, Proforma, Payments, Reports, Documents, Notifications — see swagger.json and README for integration.
|
||||
API: Invoices, Proforma, Payments, Reports, Documents, Notifications —
|
||||
see swagger.json and README for integration.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button variant="outline" className="mt-6 rounded-xl border-border" onPress={() => router.back()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-6 rounded-xl border-border"
|
||||
onPress={() => nav.back()}
|
||||
>
|
||||
<Text className="font-medium">Back</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
|
|
|
|||
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 } from "react-native";
|
||||
import { View, Image, Pressable, useColorScheme } from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Bell } from "@/lib/icons";
|
||||
import { ArrowLeft, Bell } from "@/lib/icons";
|
||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||
import { MOCK_USER } from "@/lib/mock-data";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
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 (
|
||||
<View className="px-5 pt-4 pb-2 flex-row justify-between items-center bg-background">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<ShadowWrapper level="xs">
|
||||
<View className="flex-1 flex-row items-center gap-3">
|
||||
{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
|
||||
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
|
||||
source={{
|
||||
uri: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&q=80&w=200&h=200",
|
||||
}}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
<Image source={{ uri: avatarUri }} className="h-full w-full" />
|
||||
</Pressable>
|
||||
</ShadowWrapper>
|
||||
</View>
|
||||
<View>
|
||||
<Text
|
||||
variant="muted"
|
||||
|
|
@ -31,16 +52,32 @@ export function StandardHeader() {
|
|||
Welcome back,
|
||||
</Text>
|
||||
<Text variant="h4" className="text-foreground leading-tight">
|
||||
{MOCK_USER.name}
|
||||
{user?.firstName + " " + user?.lastName || "User"}
|
||||
</Text>
|
||||
</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">
|
||||
<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>
|
||||
</ShadowWrapper>
|
||||
)}
|
||||
|
||||
{title && <View className="w-0" />}
|
||||
</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,
|
||||
ArrowUpRight,
|
||||
Lock,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Github,
|
||||
Phone,
|
||||
Chrome,
|
||||
Triangle,
|
||||
Triangle as TrianglePlanets,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
Check,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Banknote,
|
||||
Newspaper,
|
||||
ChevronDown,
|
||||
CalendarSearch,
|
||||
Search,
|
||||
} from "lucide-react-native";
|
||||
|
|
|
|||
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": {
|
||||
"@expo/metro-runtime": "~4.0.1",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/datetimepicker": "8.2.0",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@rn-primitives/portal": "^1.1.0",
|
||||
"@rn-primitives/slot": "^1.1.0",
|
||||
"@simple-api/core": "^1.0.4",
|
||||
"@simple-api/react-native": "^1.0.4",
|
||||
"@sirou/core": "^1.1.0",
|
||||
"@sirou/react-native": "^1.1.0",
|
||||
"babel-preset-expo": "~11.0.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -26,19 +31,22 @@
|
|||
"expo-router": "~4.0.17",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.9",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"lucide-react-native": "^0.471.0",
|
||||
"nativewind": "^4.1.23",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.7",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-get-sms-android": "^2.1.0",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
"react-native-svg": "15.8.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.3.12",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user