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