415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
import { useLocalSearchParams, router } from "expo-router";
|
|
import { useState } from "react";
|
|
import { Alert, 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 { 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 { 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."
|
|
: "We filled in what we could. Review and 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 showMoreBack>
|
|
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
|
|
<Text style={{ color: colors.mutedForeground }}>
|
|
Expense not found
|
|
</Text>
|
|
</TabScrollView>
|
|
</TabPage>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppBackground>
|
|
<TabPage showMoreBack>
|
|
<TabScrollView
|
|
header={
|
|
<PageHeader
|
|
title={expense.description}
|
|
subtitle={formatDate(expense.date)}
|
|
/>
|
|
}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
{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>
|
|
</TabScrollView>
|
|
</TabPage>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
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: {
|
|
padding: spacing.md,
|
|
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 detailsStart = trimmed.indexOf("\n\nReceipt details:");
|
|
const legacyStart = trimmed.indexOf("\n\nOCR text:");
|
|
const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
|
|
return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
|
|
}
|
|
return `${prefix}\n\nReceipt details:\n${trimmed}`;
|
|
}
|