483 lines
15 KiB
TypeScript
483 lines
15 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { router } from "expo-router";
|
|
import { useMemo, useState } from "react";
|
|
import {
|
|
RefreshControl,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import type { AppRouter } from "beenvoice/server/api/root";
|
|
import type { inferRouterOutputs } from "@trpc/server";
|
|
|
|
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, radii, spacing } from "@/constants/theme";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { formatCurrency, formatDate } from "@/lib/format";
|
|
import { api } from "@/lib/trpc";
|
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
|
import type { ThemeColors } from "@/lib/theme-palette";
|
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
|
|
|
type ExpenseFilter = "all" | "billable" | "receipts";
|
|
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
|
|
|
|
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 summary = useMemo(() => {
|
|
const total = filteredExpenses.reduce(
|
|
(sum, expense) => sum + expense.amount,
|
|
0,
|
|
);
|
|
const billable = filteredExpenses.reduce(
|
|
(sum, expense) => sum + (expense.billable ? expense.amount : 0),
|
|
0,
|
|
);
|
|
const receiptCount = filteredExpenses.reduce(
|
|
(sum, expense) => sum + (expense.receiptCount ?? 0),
|
|
0,
|
|
);
|
|
return { total, billable, receiptCount };
|
|
}, [filteredExpenses]);
|
|
const groupedExpenses = useMemo(
|
|
() => groupExpensesByMonth(filteredExpenses),
|
|
[filteredExpenses],
|
|
);
|
|
|
|
if (expensesQuery.isLoading) {
|
|
return <LoadingScreen message="Loading expenses..." />;
|
|
}
|
|
|
|
if (expensesQuery.error) {
|
|
return (
|
|
<AppBackground>
|
|
<TabPage showMoreBack>
|
|
<View style={styles.errorBox}>
|
|
<PageHeader title="Expenses" subtitle="Expense tracking" />
|
|
<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 showMoreBack>
|
|
<TabScrollView
|
|
header={
|
|
<View style={styles.header}>
|
|
<PageHeader
|
|
title="Expenses"
|
|
subtitle={`${expenses.length} recorded expense${expenses.length === 1 ? "" : "s"}`}
|
|
/>
|
|
<Button
|
|
title="Add expense"
|
|
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
|
/>
|
|
</View>
|
|
}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={expensesQuery.isRefetching}
|
|
onRefresh={() => void expensesQuery.refetch()}
|
|
tintColor={colors.primary}
|
|
/>
|
|
}
|
|
>
|
|
{expenses.length === 0 ? (
|
|
<View
|
|
style={[
|
|
styles.emptyCard,
|
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
|
]}
|
|
>
|
|
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
|
|
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
|
|
</View>
|
|
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
|
No expenses yet
|
|
</Text>
|
|
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
|
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
|
|
</Text>
|
|
<Button
|
|
title="Add expense"
|
|
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
|
/>
|
|
</View>
|
|
) : (
|
|
<>
|
|
<View style={styles.summaryGrid}>
|
|
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
|
|
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
|
|
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
|
|
</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>
|
|
) : (
|
|
groupedExpenses.map(([monthLabel, group]) => (
|
|
<View key={monthLabel} style={styles.monthGroup}>
|
|
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
|
|
{monthLabel}
|
|
</Text>
|
|
{group.map((expense) => (
|
|
<ExpenseRow
|
|
key={expense.id}
|
|
expense={expense}
|
|
onDelete={() => deleteExpense.mutate({ id: expense.id })}
|
|
/>
|
|
))}
|
|
</View>
|
|
))
|
|
)}
|
|
</>
|
|
)}
|
|
</TabScrollView>
|
|
</TabPage>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
function SummaryTile({ label, value }: { label: string; value: string }) {
|
|
const { colors } = useAppTheme();
|
|
const styles = useThemedStyles(createStyles);
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.summaryTile,
|
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
|
]}
|
|
>
|
|
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
|
{label}
|
|
</Text>
|
|
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
|
|
{value}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
|
|
const { colors } = useAppTheme();
|
|
const styles = useThemedStyles(createStyles);
|
|
|
|
return (
|
|
<SwipeableRow
|
|
backgroundColor={colors.cardGlass}
|
|
contentStyle={({ pressed }) => [
|
|
styles.row,
|
|
{ borderColor: colors.border },
|
|
pressed && styles.rowPressed,
|
|
]}
|
|
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
|
|
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: onDelete,
|
|
},
|
|
]}
|
|
>
|
|
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
|
|
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
|
|
</View>
|
|
<View style={styles.meta}>
|
|
<View style={styles.titleRow}>
|
|
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
|
|
{expense.description}
|
|
</Text>
|
|
{expense.receiptCount ? (
|
|
<View
|
|
style={[
|
|
styles.receiptPill,
|
|
{ borderColor: colors.border, backgroundColor: colors.background },
|
|
]}
|
|
>
|
|
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
|
|
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
|
|
{expense.receiptCount}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
|
{formatDate(expense.date)}
|
|
{expense.category ? ` · ${expense.category}` : ""}
|
|
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
|
</Text>
|
|
<View style={styles.tagRow}>
|
|
{expense.billable ? (
|
|
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
|
|
Billable
|
|
</Text>
|
|
) : null}
|
|
{expense.reimbursable ? (
|
|
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
|
|
Reimbursable
|
|
</Text>
|
|
) : null}
|
|
{expense.taxDeductible ? (
|
|
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
|
|
Tax
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
<View style={styles.amountStack}>
|
|
<Text style={[styles.amount, { color: colors.foreground }]}>
|
|
{formatCurrency(expense.amount, expense.currency)}
|
|
</Text>
|
|
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
|
|
</View>
|
|
</SwipeableRow>
|
|
);
|
|
}
|
|
|
|
function groupExpensesByMonth(expenses: Expense[]) {
|
|
const groups = new Map<string, Expense[]>();
|
|
for (const expense of expenses) {
|
|
const date = new Date(expense.date);
|
|
const key = date.toLocaleDateString(undefined, {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
const group = groups.get(key) ?? [];
|
|
group.push(expense);
|
|
groups.set(key, group);
|
|
}
|
|
return Array.from(groups.entries());
|
|
}
|
|
|
|
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
|
|
const normalized = category?.toLowerCase() ?? "";
|
|
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
|
|
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline";
|
|
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline";
|
|
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline";
|
|
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline";
|
|
return "receipt-outline";
|
|
}
|
|
|
|
const createStyles = (colors: ThemeColors) =>
|
|
StyleSheet.create({
|
|
header: {
|
|
gap: spacing.md,
|
|
},
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.md,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: 12,
|
|
borderWidth: 1,
|
|
borderRadius: radii.lg,
|
|
},
|
|
rowPressed: {
|
|
opacity: 0.82,
|
|
},
|
|
categoryIcon: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: radii.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
meta: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
gap: spacing.xs,
|
|
},
|
|
titleRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.sm,
|
|
},
|
|
title: {
|
|
flex: 1,
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 15,
|
|
},
|
|
sub: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 13,
|
|
},
|
|
tagRow: {
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
gap: spacing.xs,
|
|
},
|
|
tag: {
|
|
borderWidth: 1,
|
|
borderRadius: radii.pill,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 2,
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 11,
|
|
overflow: "hidden",
|
|
},
|
|
amountStack: {
|
|
alignItems: "flex-end",
|
|
gap: spacing.xs,
|
|
},
|
|
amount: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 15,
|
|
fontVariant: ["tabular-nums"],
|
|
},
|
|
summaryGrid: {
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
gap: spacing.xs,
|
|
},
|
|
summaryTile: {
|
|
flexGrow: 1,
|
|
flexBasis: "30%",
|
|
minWidth: 104,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: 12,
|
|
borderRadius: radii.lg,
|
|
borderWidth: 1,
|
|
},
|
|
summaryLabel: {
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 12,
|
|
textTransform: "uppercase",
|
|
},
|
|
summaryValue: {
|
|
marginTop: 2,
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 20,
|
|
fontVariant: ["tabular-nums"],
|
|
},
|
|
monthGroup: {
|
|
gap: 2,
|
|
},
|
|
monthLabel: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 12,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0,
|
|
paddingHorizontal: spacing.xs,
|
|
},
|
|
filters: {
|
|
gap: spacing.sm,
|
|
paddingRight: spacing.lg,
|
|
},
|
|
receiptPill: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 2,
|
|
borderWidth: 1,
|
|
borderRadius: radii.pill,
|
|
paddingHorizontal: 7,
|
|
paddingVertical: 2,
|
|
},
|
|
receiptPillText: {
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 12,
|
|
},
|
|
empty: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
textAlign: "center",
|
|
},
|
|
emptyCard: {
|
|
alignItems: "center",
|
|
gap: spacing.sm,
|
|
padding: spacing.lg,
|
|
borderWidth: 1,
|
|
borderRadius: radii.lg,
|
|
},
|
|
emptyIcon: {
|
|
width: 52,
|
|
height: 52,
|
|
borderRadius: radii.lg,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
emptyTitle: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 17,
|
|
},
|
|
errorBox: {
|
|
padding: spacing.lg,
|
|
gap: spacing.sm,
|
|
},
|
|
errorTitle: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 18,
|
|
},
|
|
});
|