311 lines
7.8 KiB
TypeScript
311 lines
7.8 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
||
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||
|
||
import { CompactDateField } from "@/components/ui/CompactDateField";
|
||
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
|
||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||
import { fonts, radii, spacing } from "@/constants/theme";
|
||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||
import { formatCurrency, formatShortDate } from "@/lib/format";
|
||
|
||
export type EditableLineItem = {
|
||
id?: string;
|
||
date: Date;
|
||
description: string;
|
||
hours: string;
|
||
rate: string;
|
||
};
|
||
|
||
type LineItemEditorProps = {
|
||
item: EditableLineItem;
|
||
index: number;
|
||
currency: string;
|
||
onChange: (patch: Partial<EditableLineItem>) => void;
|
||
onRemove: () => void;
|
||
onDuplicate?: () => void;
|
||
readOnly?: boolean;
|
||
isLast?: boolean;
|
||
};
|
||
|
||
function FieldLabel({ children }: { children: string }) {
|
||
const { colors } = useAppTheme();
|
||
return (
|
||
<Text style={[styles.fieldLabel, { color: colors.mutedForeground }]}>{children}</Text>
|
||
);
|
||
}
|
||
|
||
export function LineItemEditor({
|
||
item,
|
||
index,
|
||
currency,
|
||
onChange,
|
||
onRemove,
|
||
onDuplicate,
|
||
readOnly = false,
|
||
isLast = false,
|
||
}: LineItemEditorProps) {
|
||
const { colors } = useAppTheme();
|
||
const hours = Number(item.hours) || 0;
|
||
const rate = Number(item.rate) || 0;
|
||
const amount = hours * rate;
|
||
|
||
if (readOnly) {
|
||
return (
|
||
<View
|
||
style={[
|
||
styles.readBlock,
|
||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||
]}
|
||
>
|
||
<Text style={[styles.readIndex, { color: colors.mutedForeground }]}>
|
||
Line {index + 1}
|
||
</Text>
|
||
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={3}>
|
||
{item.description.trim() || "Untitled line"}
|
||
</Text>
|
||
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
|
||
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||
</Text>
|
||
<Text style={[styles.readAmount, { color: colors.foreground }]}>
|
||
{formatCurrency(amount, currency)}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const content = (
|
||
<View
|
||
style={[
|
||
styles.editBlock,
|
||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||
]}
|
||
>
|
||
<Text style={[styles.lineLabel, { color: colors.mutedForeground }]}>Line {index + 1}</Text>
|
||
|
||
<TextInput
|
||
value={item.description}
|
||
onChangeText={(description) => onChange({ description })}
|
||
placeholder="What was done?"
|
||
placeholderTextColor={colors.mutedForeground}
|
||
style={[
|
||
styles.descriptionInput,
|
||
{
|
||
color: colors.foreground,
|
||
borderColor: colors.border,
|
||
backgroundColor: colors.cardGlass,
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<View style={styles.fieldsRow}>
|
||
<View style={styles.fieldCol}>
|
||
<FieldLabel>Date</FieldLabel>
|
||
<CompactDateField
|
||
value={item.date}
|
||
onChange={(date) => onChange({ date })}
|
||
style={styles.fieldControl}
|
||
/>
|
||
</View>
|
||
<View style={styles.fieldCol}>
|
||
<FieldLabel>Hours</FieldLabel>
|
||
<CompactStepperInput
|
||
value={item.hours}
|
||
onChangeText={(hours) => onChange({ hours })}
|
||
step={0.25}
|
||
style={styles.fieldControl}
|
||
/>
|
||
</View>
|
||
<View style={styles.fieldCol}>
|
||
<FieldLabel>Rate</FieldLabel>
|
||
<View
|
||
style={[
|
||
styles.rateField,
|
||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||
]}
|
||
>
|
||
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
|
||
<TextInput
|
||
value={item.rate}
|
||
onChangeText={(rate) => onChange({ rate })}
|
||
keyboardType="decimal-pad"
|
||
placeholder="0"
|
||
placeholderTextColor={colors.mutedForeground}
|
||
style={[styles.rateInput, { color: colors.foreground }]}
|
||
/>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.footerRow}>
|
||
<View style={styles.amountGroup}>
|
||
<Text style={[styles.amountLabel, { color: colors.mutedForeground }]}>Amount</Text>
|
||
<Text style={[styles.amountValue, { color: colors.foreground }]}>
|
||
{formatCurrency(amount, currency)}
|
||
</Text>
|
||
</View>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Remove line item"
|
||
onPress={onRemove}
|
||
hitSlop={8}
|
||
style={({ pressed }) => [
|
||
styles.remove,
|
||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||
pressed && styles.removePressed,
|
||
]}
|
||
>
|
||
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
);
|
||
|
||
const swipeActions = [
|
||
...(onDuplicate
|
||
? [
|
||
{
|
||
key: "duplicate",
|
||
label: "Copy",
|
||
icon: "copy-outline" as const,
|
||
color: "#fff",
|
||
backgroundColor: colors.primary,
|
||
onPress: onDuplicate,
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
key: "delete",
|
||
label: "Delete",
|
||
icon: "trash-outline" as const,
|
||
color: "#fff",
|
||
backgroundColor: colors.destructive,
|
||
onPress: onRemove,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<SwipeableRow actions={swipeActions} backgroundColor={colors.card}>
|
||
{content}
|
||
</SwipeableRow>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
readBlock: {
|
||
paddingVertical: spacing.md,
|
||
gap: spacing.xs,
|
||
},
|
||
readIndex: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 11,
|
||
textTransform: "uppercase",
|
||
letterSpacing: 0.4,
|
||
},
|
||
readTitle: {
|
||
fontFamily: fonts.bodyMedium,
|
||
fontSize: 15,
|
||
lineHeight: 20,
|
||
},
|
||
readSub: {
|
||
fontFamily: fonts.body,
|
||
fontSize: 13,
|
||
},
|
||
readAmount: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 15,
|
||
marginTop: 2,
|
||
},
|
||
editBlock: {
|
||
paddingVertical: spacing.md,
|
||
gap: spacing.sm,
|
||
},
|
||
lineLabel: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 11,
|
||
textTransform: "uppercase",
|
||
letterSpacing: 0.4,
|
||
},
|
||
descriptionInput: {
|
||
width: "100%",
|
||
minHeight: 40,
|
||
borderWidth: 1,
|
||
borderRadius: radii.md,
|
||
paddingHorizontal: spacing.sm,
|
||
fontFamily: fonts.body,
|
||
fontSize: 15,
|
||
paddingVertical: 8,
|
||
},
|
||
fieldsRow: {
|
||
flexDirection: "row",
|
||
gap: spacing.sm,
|
||
},
|
||
fieldCol: {
|
||
flex: 1,
|
||
gap: 4,
|
||
minWidth: 0,
|
||
},
|
||
fieldLabel: {
|
||
fontFamily: fonts.bodyMedium,
|
||
fontSize: 11,
|
||
textTransform: "uppercase",
|
||
letterSpacing: 0.3,
|
||
},
|
||
fieldControl: {
|
||
width: "100%",
|
||
},
|
||
rateField: {
|
||
minHeight: 36,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
borderWidth: 1,
|
||
borderRadius: radii.md,
|
||
paddingHorizontal: spacing.xs,
|
||
},
|
||
ratePrefix: {
|
||
fontFamily: fonts.body,
|
||
fontSize: 13,
|
||
},
|
||
rateInput: {
|
||
flex: 1,
|
||
fontFamily: fonts.bodyMedium,
|
||
fontSize: 13,
|
||
paddingVertical: 4,
|
||
textAlign: "right",
|
||
minWidth: 0,
|
||
},
|
||
footerRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: spacing.sm,
|
||
marginTop: 2,
|
||
},
|
||
amountGroup: {
|
||
flex: 1,
|
||
flexDirection: "row",
|
||
alignItems: "baseline",
|
||
gap: spacing.sm,
|
||
},
|
||
amountLabel: {
|
||
fontFamily: fonts.bodyMedium,
|
||
fontSize: 12,
|
||
textTransform: "uppercase",
|
||
letterSpacing: 0.3,
|
||
},
|
||
amountValue: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 17,
|
||
},
|
||
remove: {
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: radii.md,
|
||
borderWidth: 1,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
removePressed: {
|
||
opacity: 0.65,
|
||
},
|
||
});
|