Add mobile expenses and receipt OCR

This commit is contained in:
2026-06-29 01:35:00 -04:00
parent 8a3f498874
commit 8ad4210908
18 changed files with 2460 additions and 4 deletions
+219
View File
@@ -0,0 +1,219 @@
import { Switch, StyleSheet, Text, View } from "react-native";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { SelectField, type SelectOption } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
const NONE = "__none__";
export type ExpenseFormState = {
description: string;
amountText: string;
date: Date;
category: string;
businessId: string;
clientId: string;
billable: boolean;
reimbursable: boolean;
taxDeductible: boolean;
notes: string;
};
type ExpenseFormFieldsProps = {
value: ExpenseFormState;
businesses: Array<{ id: string; name: string; isDefault?: boolean | null }>;
clients: Array<{ id: string; name: string }>;
onChange: (value: ExpenseFormState) => void;
notesLabel?: string;
notesPlaceholder?: string;
};
export function defaultExpenseFormState(
defaultBusinessId = "",
): ExpenseFormState {
return {
description: "",
amountText: "",
date: new Date(),
category: "",
businessId: defaultBusinessId,
clientId: "",
billable: false,
reimbursable: false,
taxDeductible: false,
notes: "",
};
}
export function ExpenseFormFields({
value,
businesses,
clients,
onChange,
notesLabel = "Notes",
notesPlaceholder = "Internal details or receipt OCR text",
}: ExpenseFormFieldsProps) {
const { colors } = useAppTheme();
const businessOptions: SelectOption[] = [
{ label: "Default business", value: NONE },
...businesses.map((business) => ({
label: business.isDefault ? `${business.name} (default)` : business.name,
value: business.id,
})),
];
const clientOptions: SelectOption[] = [
{ label: "No client", value: NONE },
...clients.map((client) => ({ label: client.name, value: client.id })),
];
const categoryOptions: SelectOption[] = [
{ label: "No category", value: NONE },
...EXPENSE_CATEGORIES.map((category) => ({
label: category,
value: category,
})),
];
const setField = <K extends keyof ExpenseFormState>(
field: K,
nextValue: ExpenseFormState[K],
) => onChange({ ...value, [field]: nextValue });
return (
<View style={styles.stack}>
<Input
label="Description"
required
value={value.description}
onChangeText={(text) => setField("description", text)}
placeholder="e.g. Client lunch"
/>
<Input
label="Amount"
required
value={value.amountText}
onChangeText={(text) => setField("amountText", text)}
keyboardType="decimal-pad"
placeholder="0.00"
/>
<DateTimeField
label="Date"
value={value.date}
onChange={(date) => setField("date", date)}
mode="date"
/>
<SelectField
label="Category"
placeholder="No category"
value={value.category || NONE}
options={categoryOptions}
onValueChange={(next) =>
setField("category", next === NONE ? "" : next)
}
/>
<SelectField
label="Business"
placeholder="Default business"
value={value.businessId || NONE}
options={businessOptions}
onValueChange={(next) =>
setField("businessId", next === NONE ? "" : next)
}
/>
<SelectField
label="Client"
placeholder="No client"
value={value.clientId || NONE}
options={clientOptions}
onValueChange={(next) =>
setField("clientId", next === NONE ? "" : next)
}
/>
<View style={styles.flags}>
<FlagSwitch
label="Billable"
value={value.billable}
onValueChange={(next) => setField("billable", next)}
/>
<FlagSwitch
label="Reimbursable"
value={value.reimbursable}
onValueChange={(next) => setField("reimbursable", next)}
/>
<FlagSwitch
label="Tax deductible"
value={value.taxDeductible}
onValueChange={(next) => setField("taxDeductible", next)}
/>
</View>
<Input
label={notesLabel}
value={value.notes}
onChangeText={(text) => setField("notes", text)}
placeholder={notesPlaceholder}
multiline
style={styles.notesInput}
/>
</View>
);
function FlagSwitch({
label,
value: checked,
onValueChange,
}: {
label: string;
value: boolean;
onValueChange: (value: boolean) => void;
}) {
return (
<View style={[styles.flagRow, { borderColor: colors.borderGlass }]}>
<Text style={[styles.flagLabel, { color: colors.foreground }]}>
{label}
</Text>
<Switch
value={checked}
onValueChange={onValueChange}
trackColor={{
true: colors.switchTrackOn,
false: colors.switchTrackOff,
}}
thumbColor={colors.switchThumb}
ios_backgroundColor={colors.switchIosBackground}
/>
</View>
);
}
}
const styles = StyleSheet.create({
stack: {
gap: spacing.md,
},
flags: {
gap: spacing.sm,
},
flagRow: {
minHeight: 48,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
flagLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
notesInput: {
minHeight: 96,
textAlignVertical: "top",
paddingTop: spacing.md,
},
});
+265
View File
@@ -0,0 +1,265 @@
import { useMemo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import type { ReceiptLineItem } from "@/lib/receipt-parse";
export type ReceiptSplitSelection = {
selectedItemIds: string[];
selectedSubtotal: number;
allocatedTax: number;
owedTotal: number;
notes: string;
};
type ReceiptItemSelectorProps = {
items: ReceiptLineItem[];
subtotal: number | null;
tax: number | null;
total: number | null;
onApply: (selection: ReceiptSplitSelection) => void;
};
export function ReceiptItemSelector({
items,
subtotal,
tax,
total,
onApply,
}: ReceiptItemSelectorProps) {
const { colors } = useAppTheme();
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(items.map((item) => item.id)),
);
const calculation = useMemo(() => {
const selected = items.filter((item) => selectedIds.has(item.id));
const selectedSubtotal = roundMoney(
selected.reduce((sum, item) => sum + item.amount, 0),
);
const receiptSubtotal =
subtotal && subtotal > 0
? subtotal
: roundMoney(items.reduce((sum, item) => sum + item.amount, 0));
const knownTax =
tax ??
(total && receiptSubtotal > 0
? Math.max(0, roundMoney(total - receiptSubtotal))
: 0);
const allocatedTax =
receiptSubtotal > 0
? roundMoney(knownTax * (selectedSubtotal / receiptSubtotal))
: 0;
const owedTotal = roundMoney(selectedSubtotal + allocatedTax);
const notes = [
"Receipt split",
...selected.map(
(item) => `- ${item.name}: ${formatCurrency(item.amount)}`,
),
`Selected subtotal: ${formatCurrency(selectedSubtotal)}`,
`Allocated tax: ${formatCurrency(allocatedTax)}`,
`Owed total: ${formatCurrency(owedTotal)}`,
].join("\n");
return { selected, selectedSubtotal, allocatedTax, owedTotal, notes };
}, [items, selectedIds, subtotal, tax, total]);
if (items.length === 0) return null;
const toggle = (id: string) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<View style={[styles.wrap, { borderColor: colors.border }]}>
<View style={styles.header}>
<View style={styles.headerCopy}>
<Text style={[styles.title, { color: colors.foreground }]}>
Split receipt items
</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Select what this person owes. Tax is split proportionally.
</Text>
</View>
<Text style={[styles.total, { color: colors.foreground }]}>
{formatCurrency(calculation.owedTotal)}
</Text>
</View>
<View style={styles.itemList}>
{items.map((item) => {
const selected = selectedIds.has(item.id);
return (
<Pressable
key={item.id}
accessibilityRole="checkbox"
accessibilityState={{ checked: selected }}
onPress={() => toggle(item.id)}
style={({ pressed }) => [
styles.item,
{ borderColor: colors.borderGlass },
selected && { backgroundColor: colors.muted },
pressed && styles.pressed,
]}
>
<Ionicons
name={selected ? "checkmark-circle" : "ellipse-outline"}
size={21}
color={selected ? colors.primary : colors.mutedForeground}
/>
<Text
style={[styles.itemName, { color: colors.foreground }]}
numberOfLines={2}
>
{item.name}
</Text>
<Text style={[styles.itemAmount, { color: colors.foreground }]}>
{formatCurrency(item.amount)}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.summary}>
<SummaryRow label="Items" value={calculation.selectedSubtotal} />
<SummaryRow label="Tax" value={calculation.allocatedTax} />
<SummaryRow label="Owed" value={calculation.owedTotal} strong />
</View>
<Button
title="Apply owed amount"
disabled={calculation.selected.length === 0}
onPress={() =>
onApply({
selectedItemIds: calculation.selected.map((item) => item.id),
selectedSubtotal: calculation.selectedSubtotal,
allocatedTax: calculation.allocatedTax,
owedTotal: calculation.owedTotal,
notes: calculation.notes,
})
}
/>
</View>
);
function SummaryRow({
label,
value,
strong,
}: {
label: string;
value: number;
strong?: boolean;
}) {
return (
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text
style={[
styles.summaryValue,
{ color: colors.foreground },
strong && styles.summaryValueStrong,
]}
>
{formatCurrency(value)}
</Text>
</View>
);
}
}
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
const styles = StyleSheet.create({
wrap: {
gap: spacing.md,
borderWidth: 1,
borderRadius: 16,
padding: spacing.md,
},
header: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: spacing.md,
},
headerCopy: {
flex: 1,
gap: 2,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
subtitle: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
total: {
fontFamily: fonts.bodySemiBold,
fontSize: 18,
fontVariant: ["tabular-nums"],
},
itemList: {
gap: spacing.sm,
},
item: {
minHeight: 48,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
pressed: {
opacity: 0.85,
},
itemName: {
flex: 1,
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
itemAmount: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
fontVariant: ["tabular-nums"],
},
summary: {
gap: spacing.xs,
},
summaryRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
},
summaryLabel: {
fontFamily: fonts.body,
fontSize: 13,
},
summaryValue: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
fontVariant: ["tabular-nums"],
},
summaryValueStrong: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
});