257 lines
7.6 KiB
TypeScript
257 lines
7.6 KiB
TypeScript
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}`;
|
|
}
|