220 lines
5.6 KiB
TypeScript
220 lines
5.6 KiB
TypeScript
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,
|
|
},
|
|
});
|