Files
beenvoice-app/app/(app)/more/expenses/index.tsx
T

352 lines
11 KiB
TypeScript

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,
},
});