Add mobile expenses and receipt OCR
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import Swipeable, {
|
||||
type SwipeableMethods,
|
||||
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
|
||||
export type SwipeAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
type SwipeableRowProps = {
|
||||
children: ReactNode;
|
||||
actions: SwipeAction[];
|
||||
enabled?: boolean;
|
||||
backgroundColor?: string;
|
||||
};
|
||||
|
||||
export function SwipeableRow({
|
||||
children,
|
||||
actions,
|
||||
enabled = true,
|
||||
backgroundColor,
|
||||
}: SwipeableRowProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createSwipeableRowStyles);
|
||||
const rowBackground = backgroundColor ?? colors.background;
|
||||
const swipeRef = useRef<SwipeableMethods>(null);
|
||||
|
||||
function renderRightActions() {
|
||||
return (
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => (
|
||||
<Pressable
|
||||
key={action.key}
|
||||
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
||||
onPress={() => {
|
||||
swipeRef.current?.close();
|
||||
action.onPress();
|
||||
}}
|
||||
>
|
||||
<Ionicons name={action.icon} size={20} color={action.color} />
|
||||
<Text style={[styles.actionLabel, { color: action.color }]}>{action.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled || actions.length === 0) {
|
||||
return <View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
|
||||
<View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>
|
||||
</Swipeable>
|
||||
);
|
||||
}
|
||||
|
||||
const createSwipeableRowStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
row: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
},
|
||||
actionButton: {
|
||||
width: 80,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
borderRadius: radii.md,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
actionLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 11,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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