321 lines
8.4 KiB
TypeScript
321 lines
8.4 KiB
TypeScript
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.length === 1 ? [items[0]!.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, backgroundColor: colors.cardGlass },
|
|
]}
|
|
>
|
|
<View style={styles.header}>
|
|
<View style={styles.headerCopy}>
|
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
|
Select expense items
|
|
</Text>
|
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
|
Pick only the receipt lines that should become this expense. Tax is
|
|
split proportionally.
|
|
</Text>
|
|
</View>
|
|
<Text style={[styles.total, { color: colors.foreground }]}>
|
|
{formatCurrency(calculation.owedTotal)}
|
|
</Text>
|
|
</View>
|
|
|
|
{items.length > 1 ? (
|
|
<View style={styles.bulkActions}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
onPress={() =>
|
|
setSelectedIds(new Set(items.map((item) => item.id)))
|
|
}
|
|
style={({ pressed }) => [
|
|
styles.bulkAction,
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<Text style={[styles.bulkActionText, { color: colors.primary }]}>
|
|
Select all
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
onPress={() => setSelectedIds(new Set())}
|
|
style={({ pressed }) => [
|
|
styles.bulkAction,
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<Text
|
|
style={[styles.bulkActionText, { color: colors.mutedForeground }]}
|
|
>
|
|
Clear
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : null}
|
|
|
|
<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, backgroundColor: colors.background },
|
|
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={
|
|
calculation.selected.length === 0
|
|
? "Select an item to apply"
|
|
: "Apply selected items"
|
|
}
|
|
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,
|
|
},
|
|
bulkActions: {
|
|
flexDirection: "row",
|
|
gap: spacing.sm,
|
|
},
|
|
bulkAction: {
|
|
minHeight: 34,
|
|
justifyContent: "center",
|
|
},
|
|
bulkActionText: {
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 13,
|
|
},
|
|
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,
|
|
},
|
|
});
|