6d2711e36e
Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius. Co-authored-by: Cursor <cursoragent@cursor.com>
191 lines
4.9 KiB
TypeScript
191 lines
4.9 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||
|
||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||
import { Input } from "@/components/ui/Input";
|
||
import { StepperInput } from "@/components/ui/StepperInput";
|
||
import { fonts, spacing } from "@/constants/theme";
|
||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||
import { formatCurrency, formatDate } from "@/lib/format";
|
||
|
||
export type EditableLineItem = {
|
||
id?: string;
|
||
date: Date;
|
||
description: string;
|
||
hours: string;
|
||
rate: string;
|
||
};
|
||
|
||
type LineItemEditorProps = {
|
||
item: EditableLineItem;
|
||
currency: string;
|
||
expanded: boolean;
|
||
onToggle: () => void;
|
||
onChange: (patch: Partial<EditableLineItem>) => void;
|
||
onRemove: () => void;
|
||
};
|
||
|
||
export function LineItemEditor({
|
||
item,
|
||
currency,
|
||
expanded,
|
||
onToggle,
|
||
onChange,
|
||
onRemove,
|
||
}: LineItemEditorProps) {
|
||
const { colors } = useAppTheme();
|
||
const hours = Number(item.hours) || 0;
|
||
const rate = Number(item.rate) || 0;
|
||
const amount = hours * rate;
|
||
const borderStyle = { borderTopColor: colors.border };
|
||
|
||
if (!expanded) {
|
||
return (
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
onPress={onToggle}
|
||
style={({ pressed }) => [styles.row, borderStyle, pressed && styles.rowPressed]}
|
||
>
|
||
<View style={styles.rowMain}>
|
||
<Text style={[styles.rowTitle, { color: colors.foreground }]} numberOfLines={1}>
|
||
{item.description.trim() || "Untitled line"}
|
||
</Text>
|
||
<Text style={[styles.rowSub, { color: colors.mutedForeground }]}>
|
||
{formatDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||
</Text>
|
||
</View>
|
||
<Text style={[styles.rowAmount, { color: colors.foreground }]}>
|
||
{formatCurrency(amount, currency)}
|
||
</Text>
|
||
<Ionicons name="chevron-down" size={16} color={colors.mutedForeground} />
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<View style={[styles.expanded, borderStyle]}>
|
||
<View style={styles.expandedHeader}>
|
||
<Text style={[styles.expandedLabel, { color: colors.mutedForeground }]}>Line item</Text>
|
||
<Pressable accessibilityRole="button" onPress={onToggle} hitSlop={8}>
|
||
<Ionicons name="chevron-up" size={18} color={colors.mutedForeground} />
|
||
</Pressable>
|
||
</View>
|
||
|
||
<Input
|
||
label="Description"
|
||
value={item.description}
|
||
onChangeText={(description) => onChange({ description })}
|
||
placeholder="What was done"
|
||
/>
|
||
|
||
<View style={styles.inlineRow}>
|
||
<View style={styles.inlineField}>
|
||
<StepperInput
|
||
label="Hours"
|
||
value={item.hours}
|
||
onChangeText={(hours) => onChange({ hours })}
|
||
placeholder="0"
|
||
/>
|
||
</View>
|
||
<View style={styles.inlineField}>
|
||
<Input
|
||
label="Rate"
|
||
value={item.rate}
|
||
onChangeText={(rate) => onChange({ rate })}
|
||
keyboardType="decimal-pad"
|
||
placeholder="0"
|
||
/>
|
||
</View>
|
||
</View>
|
||
|
||
<DateTimeField
|
||
label="Date"
|
||
mode="date"
|
||
value={item.date}
|
||
onChange={(date) => onChange({ date })}
|
||
/>
|
||
|
||
<View style={styles.expandedFooter}>
|
||
<Text style={[styles.lineTotal, { color: colors.foreground }]}>
|
||
{formatCurrency(amount, currency)}
|
||
</Text>
|
||
<Pressable accessibilityRole="button" onPress={onRemove} style={styles.removeButton}>
|
||
<Ionicons name="trash-outline" size={16} color={colors.destructive} />
|
||
<Text style={[styles.removeLabel, { color: colors.destructive }]}>Remove</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
row: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: spacing.sm,
|
||
paddingVertical: spacing.sm,
|
||
borderTopWidth: 1,
|
||
},
|
||
rowPressed: {
|
||
opacity: 0.9,
|
||
},
|
||
rowMain: {
|
||
flex: 1,
|
||
gap: 2,
|
||
},
|
||
rowTitle: {
|
||
fontFamily: fonts.bodyMedium,
|
||
fontSize: 15,
|
||
},
|
||
rowSub: {
|
||
fontFamily: fonts.body,
|
||
fontSize: 12,
|
||
},
|
||
rowAmount: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 14,
|
||
},
|
||
expanded: {
|
||
gap: spacing.sm,
|
||
paddingVertical: spacing.sm,
|
||
borderTopWidth: 1,
|
||
},
|
||
expandedHeader: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
},
|
||
expandedLabel: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 13,
|
||
textTransform: "uppercase",
|
||
letterSpacing: 0.3,
|
||
},
|
||
inlineRow: {
|
||
flexDirection: "row",
|
||
gap: spacing.md,
|
||
},
|
||
inlineField: {
|
||
flex: 1,
|
||
},
|
||
expandedFooter: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
},
|
||
lineTotal: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 16,
|
||
},
|
||
removeButton: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
paddingVertical: spacing.xs,
|
||
},
|
||
removeLabel: {
|
||
fontFamily: fonts.bodyMedium,
|
||
fontSize: 13,
|
||
},
|
||
});
|