Add mobile expenses and receipt OCR
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user