Add mobile expenses and receipt OCR
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import {
|
||||
ExpenseFormFields,
|
||||
type ExpenseFormState,
|
||||
} from "@/components/expenses/ExpenseFormFields";
|
||||
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type ReceiptSplitDraft = Pick<
|
||||
ReceiptScanResult,
|
||||
"items" | "subtotal" | "tax" | "total"
|
||||
>;
|
||||
|
||||
export default function ExpenseDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { colors } = useAppTheme();
|
||||
const utils = api.useUtils();
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||
null,
|
||||
);
|
||||
const [form, setForm] = useState<ExpenseFormState>({
|
||||
description: "",
|
||||
amountText: "",
|
||||
date: new Date(),
|
||||
category: "",
|
||||
businessId: "",
|
||||
clientId: "",
|
||||
billable: false,
|
||||
reimbursable: false,
|
||||
taxDeductible: false,
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const expenseQuery = api.expenses.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
|
||||
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||
onSuccess: () => void expenseQuery.refetch(),
|
||||
});
|
||||
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||
onSuccess: () => void expenseQuery.refetch(),
|
||||
});
|
||||
const updateExpense = api.expenses.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.expenses.getAll.invalidate();
|
||||
await expenseQuery.refetch();
|
||||
setEditing(false);
|
||||
},
|
||||
});
|
||||
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||
|
||||
const expense = expenseQuery.data;
|
||||
const businesses = businessesQuery.data ?? [];
|
||||
const clients = clientsQuery.data ?? [];
|
||||
|
||||
async function attachAndScan(fromCamera: boolean) {
|
||||
if (!id || !expense) return;
|
||||
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await scanReceiptImage(
|
||||
fromCamera,
|
||||
{
|
||||
description: expense.description,
|
||||
amountText: String(expense.amount),
|
||||
date: new Date(expense.date),
|
||||
},
|
||||
(input) => suggest.mutateAsync(input),
|
||||
);
|
||||
if (!result) return;
|
||||
|
||||
await uploadReceipt.mutateAsync({
|
||||
expenseId: id,
|
||||
filename: result.image.filename,
|
||||
mimeType: result.image.mimeType,
|
||||
data: result.image.base64,
|
||||
});
|
||||
|
||||
setForm(
|
||||
expenseToForm(expense, {
|
||||
description: result.description,
|
||||
amountText: result.amountText,
|
||||
date: result.date,
|
||||
notes: result.ocrText,
|
||||
}),
|
||||
);
|
||||
setReceiptSplit(
|
||||
result.items.length > 0
|
||||
? {
|
||||
items: result.items,
|
||||
subtotal: result.subtotal,
|
||||
tax: result.tax,
|
||||
total: result.total,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setEditing(true);
|
||||
|
||||
Alert.alert(
|
||||
"Receipt attached",
|
||||
result.items.length > 0
|
||||
? "Select the owed items, apply the split amount, then save the expense."
|
||||
: "OCR filled the fields below. Save to update this expense.",
|
||||
);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveEdits() {
|
||||
if (!id) return;
|
||||
const amount = Number(form.amountText);
|
||||
if (!form.description.trim() || !Number.isFinite(amount) || amount <= 0) {
|
||||
Alert.alert("Invalid fields", "Description and amount are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateExpense.mutate({
|
||||
id,
|
||||
description: form.description.trim(),
|
||||
amount,
|
||||
date: form.date,
|
||||
category: form.category || undefined,
|
||||
businessId: form.businessId || undefined,
|
||||
clientId: form.clientId || undefined,
|
||||
billable: form.billable,
|
||||
reimbursable: form.reimbursable,
|
||||
taxDeductible: form.taxDeductible,
|
||||
notes: form.notes.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
if (!expense) return;
|
||||
setForm(expenseToForm(expense));
|
||||
setReceiptSplit(null);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
if (expenseQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading expense…" />;
|
||||
}
|
||||
|
||||
if (!expense) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}>
|
||||
Expense not found
|
||||
</Text>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<PageHeader
|
||||
title={expense.description}
|
||||
subtitle={formatDate(expense.date)}
|
||||
/>
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
{receiptSplit ? (
|
||||
<ReceiptItemSelector
|
||||
items={receiptSplit.items}
|
||||
subtotal={receiptSplit.subtotal}
|
||||
tax={receiptSplit.tax}
|
||||
total={receiptSplit.total}
|
||||
onApply={(selection) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
amountText: selection.owedTotal.toFixed(2),
|
||||
notes: mergeNotes(selection.notes, current.notes),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<ExpenseFormFields
|
||||
value={form}
|
||||
businesses={businesses}
|
||||
clients={clients}
|
||||
onChange={setForm}
|
||||
/>
|
||||
<Button
|
||||
title="Save changes"
|
||||
loading={updateExpense.isPending}
|
||||
onPress={handleSaveEdits}
|
||||
/>
|
||||
<Button
|
||||
title="Cancel edit"
|
||||
variant="secondary"
|
||||
onPress={() => setEditing(false)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</Text>
|
||||
<View style={styles.metaStack}>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
{expense.category || "No category"}
|
||||
{expense.business?.name ? ` · ${expense.business.name}` : ""}
|
||||
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||
</Text>
|
||||
<View style={styles.badges}>
|
||||
{expense.billable ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.badge,
|
||||
{ color: colors.primary, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Billable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.reimbursable ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
color: colors.foreground,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Reimbursable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.taxDeductible ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.badge,
|
||||
{ color: colors.success, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Tax deductible
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{expense.notes ? (
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
{expense.notes}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
title="Edit expense"
|
||||
variant="secondary"
|
||||
onPress={startEditing}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={[styles.section, { color: colors.foreground }]}>
|
||||
Receipts ({expense.receipts.length})
|
||||
</Text>
|
||||
|
||||
{expense.receipts.map((receipt) => (
|
||||
<SwipeableRow
|
||||
key={receipt.id}
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: "trash-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.destructive,
|
||||
onPress: () => deleteReceipt.mutate({ id: receipt.id }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.receiptRow, { color: colors.mutedForeground }]}
|
||||
>
|
||||
{receipt.originalFilename}
|
||||
</Text>
|
||||
</SwipeableRow>
|
||||
))}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title={scanning ? "Scanning…" : "Scan receipt"}
|
||||
loading={scanning || uploadReceipt.isPending}
|
||||
style={styles.actionButton}
|
||||
onPress={() => void attachAndScan(true)}
|
||||
/>
|
||||
<Button
|
||||
title="Import photo"
|
||||
variant="secondary"
|
||||
loading={scanning || uploadReceipt.isPending}
|
||||
style={styles.actionButton}
|
||||
onPress={() => void attachAndScan(false)}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
title="Back"
|
||||
variant="secondary"
|
||||
onPress={() => router.back()}
|
||||
/>
|
||||
</ScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
body: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 28,
|
||||
fontWeight: "600",
|
||||
},
|
||||
metaStack: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
meta: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
badges: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
badge: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
},
|
||||
section: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
receiptRow: {
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 14,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
function expenseToForm(
|
||||
expense: {
|
||||
description: string;
|
||||
amount: number;
|
||||
date: Date | string;
|
||||
category: string | null;
|
||||
businessId: string | null;
|
||||
clientId: string | null;
|
||||
billable: boolean;
|
||||
reimbursable: boolean;
|
||||
taxDeductible: boolean | null;
|
||||
notes: string | null;
|
||||
},
|
||||
overrides: Partial<ExpenseFormState> = {},
|
||||
): ExpenseFormState {
|
||||
return {
|
||||
description: expense.description,
|
||||
amountText: String(expense.amount),
|
||||
date: new Date(expense.date),
|
||||
category: expense.category ?? "",
|
||||
businessId: expense.businessId ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
billable: expense.billable,
|
||||
reimbursable: expense.reimbursable,
|
||||
taxDeductible: expense.taxDeductible ?? false,
|
||||
notes: expense.notes ?? "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeNotes(prefix: string, existing: string) {
|
||||
const trimmed = existing.trim();
|
||||
if (!trimmed) return prefix;
|
||||
if (trimmed.startsWith("Receipt split")) {
|
||||
const ocrStart = trimmed.indexOf("\n\nOCR text:");
|
||||
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
|
||||
}
|
||||
return `${prefix}\n\nOCR text:\n${trimmed}`;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { router } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import {
|
||||
defaultExpenseFormState,
|
||||
ExpenseFormFields,
|
||||
type ExpenseFormState,
|
||||
} from "@/components/expenses/ExpenseFormFields";
|
||||
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { mlKitOcrAvailable } from "@/lib/receipt-ocr";
|
||||
import {
|
||||
scanReceiptImage,
|
||||
type PickedReceiptImage,
|
||||
type ReceiptScanResult,
|
||||
} from "@/lib/receipt-scan";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type ReceiptSplitDraft = Pick<
|
||||
ReceiptScanResult,
|
||||
"items" | "subtotal" | "tax" | "total"
|
||||
>;
|
||||
|
||||
export default function NewExpenseScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const utils = api.useUtils();
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
|
||||
const businesses = businessesQuery.data ?? [];
|
||||
const clients = clientsQuery.data ?? [];
|
||||
const defaultBusinessId = useMemo(
|
||||
() =>
|
||||
businesses.find((business) => business.isDefault)?.id ??
|
||||
businesses[0]?.id ??
|
||||
"",
|
||||
[businesses],
|
||||
);
|
||||
const [form, setForm] = useState<ExpenseFormState>(() =>
|
||||
defaultExpenseFormState(),
|
||||
);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [pendingReceipt, setPendingReceipt] =
|
||||
useState<PickedReceiptImage | null>(null);
|
||||
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const createExpense = api.expenses.create.useMutation();
|
||||
const uploadReceipt = api.expenses.uploadReceipt.useMutation();
|
||||
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||
|
||||
async function runScan(fromCamera: boolean) {
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await scanReceiptImage(
|
||||
fromCamera,
|
||||
{
|
||||
description: form.description,
|
||||
amountText: form.amountText,
|
||||
date: form.date,
|
||||
},
|
||||
(input) => suggest.mutateAsync(input),
|
||||
);
|
||||
if (!result) return;
|
||||
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
description: result.description,
|
||||
amountText: result.amountText,
|
||||
date: result.date,
|
||||
notes: result.ocrText,
|
||||
businessId: current.businessId || defaultBusinessId,
|
||||
}));
|
||||
setPendingReceipt(result.image);
|
||||
setReceiptSplit(
|
||||
result.items.length > 0
|
||||
? {
|
||||
items: result.items,
|
||||
subtotal: result.subtotal,
|
||||
tax: result.tax,
|
||||
total: result.total,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
Alert.alert(
|
||||
"Receipt scanned",
|
||||
result.items.length > 0
|
||||
? "Select the items this person owes, then apply the split amount."
|
||||
: result.amountText
|
||||
? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.`
|
||||
: "Review the fields — OCR could not find a total automatically.",
|
||||
);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const amount = Number(form.amountText);
|
||||
if (!form.description.trim()) {
|
||||
Alert.alert("Description required", "Enter what this expense was for.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
Alert.alert("Amount required", "Enter a valid amount.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const expense = await createExpense.mutateAsync({
|
||||
description: form.description.trim(),
|
||||
amount,
|
||||
date: form.date,
|
||||
currency: "USD",
|
||||
category: form.category || undefined,
|
||||
clientId: form.clientId || undefined,
|
||||
businessId: form.businessId || defaultBusinessId || undefined,
|
||||
billable: form.billable,
|
||||
reimbursable: form.reimbursable,
|
||||
taxDeductible: form.taxDeductible,
|
||||
notes: form.notes.trim() || undefined,
|
||||
});
|
||||
|
||||
if (pendingReceipt) {
|
||||
await uploadReceipt.mutateAsync({
|
||||
expenseId: expense.id,
|
||||
filename: pendingReceipt.filename,
|
||||
mimeType: pendingReceipt.mimeType,
|
||||
data: pendingReceipt.base64,
|
||||
});
|
||||
}
|
||||
|
||||
await utils.expenses.getAll.invalidate();
|
||||
router.replace(`/(app)/more/expenses/${expense.id}` as never);
|
||||
} catch (err) {
|
||||
Alert.alert(
|
||||
"Could not save expense",
|
||||
err instanceof Error ? err.message : "Try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.body}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<PageHeader
|
||||
title="New expense"
|
||||
subtitle={
|
||||
mlKitOcrAvailable()
|
||||
? "Scan a receipt with on-device ML Kit OCR"
|
||||
: "Manual entry (OCR unavailable on this device)"
|
||||
}
|
||||
/>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title={scanning ? "Scanning…" : "Scan receipt"}
|
||||
variant="secondary"
|
||||
loading={scanning}
|
||||
style={styles.actionButton}
|
||||
onPress={() => void runScan(true)}
|
||||
/>
|
||||
<Button
|
||||
title="Import photo"
|
||||
variant="secondary"
|
||||
loading={scanning}
|
||||
style={styles.actionButton}
|
||||
onPress={() => void runScan(false)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{pendingReceipt ? (
|
||||
<Text style={{ color: colors.success, fontSize: 13 }}>
|
||||
Receipt image ready — it will attach when you save.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{receiptSplit ? (
|
||||
<ReceiptItemSelector
|
||||
items={receiptSplit.items}
|
||||
subtotal={receiptSplit.subtotal}
|
||||
tax={receiptSplit.tax}
|
||||
total={receiptSplit.total}
|
||||
onApply={(selection) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
amountText: selection.owedTotal.toFixed(2),
|
||||
notes: mergeNotes(selection.notes, current.notes),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ExpenseFormFields
|
||||
value={{
|
||||
...form,
|
||||
businessId: form.businessId || defaultBusinessId,
|
||||
}}
|
||||
businesses={businesses}
|
||||
clients={clients}
|
||||
onChange={setForm}
|
||||
notesLabel="Notes"
|
||||
notesPlaceholder="Receipt OCR text or internal notes"
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Save expense"
|
||||
loading={createExpense.isPending}
|
||||
onPress={() => void handleSave()}
|
||||
/>
|
||||
<Button
|
||||
title="Cancel"
|
||||
variant="secondary"
|
||||
onPress={() => router.back()}
|
||||
/>
|
||||
</ScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
body: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
function mergeNotes(prefix: string, existing: string) {
|
||||
const trimmed = existing.trim();
|
||||
if (!trimmed) return prefix;
|
||||
if (trimmed.startsWith("Receipt split")) {
|
||||
const ocrStart = trimmed.indexOf("\n\nOCR text:");
|
||||
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
|
||||
}
|
||||
return `${prefix}\n\nOCR text:\n${trimmed}`;
|
||||
}
|
||||
Reference in New Issue
Block a user