Add mobile expenses and receipt OCR
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
import { router } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type ExpenseFilter = "all" | "billable" | "receipts";
|
||||
|
||||
export default function ExpensesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createStyles);
|
||||
const utils = api.useUtils();
|
||||
const [filter, setFilter] = useState<ExpenseFilter>("all");
|
||||
const expensesQuery = api.expenses.getAll.useQuery();
|
||||
|
||||
const deleteExpense = api.expenses.delete.useMutation({
|
||||
onSuccess: () => void utils.expenses.getAll.invalidate(),
|
||||
});
|
||||
|
||||
const expenses = expensesQuery.data ?? [];
|
||||
const filteredExpenses = useMemo(
|
||||
() =>
|
||||
expenses.filter((expense) => {
|
||||
if (filter === "billable") return expense.billable;
|
||||
if (filter === "receipts") return expense.receiptCount > 0;
|
||||
return true;
|
||||
}),
|
||||
[expenses, filter],
|
||||
);
|
||||
const total = filteredExpenses.reduce(
|
||||
(sum, expense) => sum + expense.amount,
|
||||
0,
|
||||
);
|
||||
const receiptCount = filteredExpenses.reduce(
|
||||
(sum, expense) => sum + (expense.receiptCount ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (expensesQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading expenses…" />;
|
||||
}
|
||||
|
||||
if (expensesQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={[styles.errorTitle, { color: colors.foreground }]}>
|
||||
Could not load expenses
|
||||
</Text>
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
{formatTrpcErrorMessage(expensesQuery.error)}
|
||||
</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TabScrollView
|
||||
header={
|
||||
<>
|
||||
<PageHeader
|
||||
title="Expenses"
|
||||
subtitle={`${expenses.length} recorded`}
|
||||
/>
|
||||
<Button
|
||||
title="Add expense"
|
||||
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={expensesQuery.isRefetching}
|
||||
onRefresh={() => void expensesQuery.refetch()}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{expenses.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||
No expenses yet. Add one with a receipt photo or manual entry.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.summary}>
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
styles.summaryLabel,
|
||||
{ color: colors.mutedForeground },
|
||||
]}
|
||||
>
|
||||
Visible total
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.summaryValue, { color: colors.foreground }]}
|
||||
>
|
||||
{formatCurrency(total)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRight}>
|
||||
<Text
|
||||
style={[
|
||||
styles.summaryLabel,
|
||||
{ color: colors.mutedForeground },
|
||||
]}
|
||||
>
|
||||
Receipts
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.summaryValue, { color: colors.foreground }]}
|
||||
>
|
||||
{receiptCount}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filters}
|
||||
>
|
||||
<FilterChip
|
||||
label="All"
|
||||
active={filter === "all"}
|
||||
onPress={() => setFilter("all")}
|
||||
/>
|
||||
<FilterChip
|
||||
label="Billable"
|
||||
active={filter === "billable"}
|
||||
onPress={() => setFilter("billable")}
|
||||
/>
|
||||
<FilterChip
|
||||
label="With receipts"
|
||||
active={filter === "receipts"}
|
||||
onPress={() => setFilter("receipts")}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{filteredExpenses.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||
No expenses match this filter.
|
||||
</Text>
|
||||
) : (
|
||||
filteredExpenses.map((expense) => (
|
||||
<SwipeableRow
|
||||
key={expense.id}
|
||||
actions={[
|
||||
{
|
||||
key: "open",
|
||||
label: "Open",
|
||||
icon: "open-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () =>
|
||||
router.push(
|
||||
`/(app)/more/expenses/${expense.id}` as never,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: "trash-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.destructive,
|
||||
onPress: () => deleteExpense.mutate({ id: expense.id }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() =>
|
||||
router.push(
|
||||
`/(app)/more/expenses/${expense.id}` as never,
|
||||
)
|
||||
}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.meta}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text
|
||||
style={[styles.title, { color: colors.foreground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{expense.description}
|
||||
</Text>
|
||||
{expense.receiptCount ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.receiptPill,
|
||||
{
|
||||
color: colors.primary,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{expense.receiptCount}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.sub,
|
||||
{ color: colors.mutedForeground },
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{formatDate(expense.date)}
|
||||
{expense.category ? ` · ${expense.category}` : ""}
|
||||
{expense.client?.name
|
||||
? ` · ${expense.client.name}`
|
||||
: ""}
|
||||
{expense.billable ? " · Billable" : ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.amount, { color: colors.foreground }]}
|
||||
>
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</SwipeableRow>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
rowPressed: {
|
||||
opacity: 0.82,
|
||||
},
|
||||
meta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
sub: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
},
|
||||
amount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
summary: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
summaryRight: {
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
summaryLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
summaryValue: {
|
||||
marginTop: 2,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 20,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
filters: {
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.lg,
|
||||
},
|
||||
receiptPill: {
|
||||
minWidth: 26,
|
||||
textAlign: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
overflow: "hidden",
|
||||
},
|
||||
empty: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorBox: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
errorTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 18,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user