355b14faef
Enable App Store builds without EAS, iOS 18 App Intents plugins, and signing fixes for distribution export. Add mobile invoice PDF preview, compact line items, and more reliable shortcut deep-link handling. Co-authored-by: Cursor <cursoragent@cursor.com>
275 lines
7.1 KiB
TypeScript
275 lines
7.1 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 { 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;
|
||
readOnly?: boolean;
|
||
isLast?: boolean;
|
||
};
|
||
|
||
export function LineItemsTableHeader() {
|
||
const { colors } = useAppTheme();
|
||
|
||
return (
|
||
<View style={[headerStyles.row, { borderBottomColor: colors.border }]}>
|
||
<Text style={[headerStyles.cell, headerStyles.desc, { color: colors.mutedForeground }]}>
|
||
Description
|
||
</Text>
|
||
<Text style={[headerStyles.cell, headerStyles.date, { color: colors.mutedForeground }]}>
|
||
Date
|
||
</Text>
|
||
<Text style={[headerStyles.cell, headerStyles.hours, { color: colors.mutedForeground }]}>
|
||
Hrs
|
||
</Text>
|
||
<Text style={[headerStyles.cell, headerStyles.rate, { color: colors.mutedForeground }]}>
|
||
Rate
|
||
</Text>
|
||
<Text style={[headerStyles.cell, headerStyles.amt, { color: colors.mutedForeground }]}>
|
||
Amt
|
||
</Text>
|
||
<View style={headerStyles.spacer} />
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export function LineItemEditor({
|
||
item,
|
||
index,
|
||
currency,
|
||
onChange,
|
||
onRemove,
|
||
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.row,
|
||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
||
]}
|
||
>
|
||
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
|
||
<View style={styles.descCol}>
|
||
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={2}>
|
||
{item.description.trim() || "Untitled line"}
|
||
</Text>
|
||
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
|
||
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||
</Text>
|
||
</View>
|
||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||
{formatCurrency(amount, currency)}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<View
|
||
style={[
|
||
styles.editBlock,
|
||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
||
]}
|
||
>
|
||
<View style={styles.editTop}>
|
||
<Text style={[styles.index, { color: colors.mutedForeground }]}>{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>
|
||
|
||
<View style={styles.metricsRow}>
|
||
<CompactDateField
|
||
value={item.date}
|
||
onChange={(date) => onChange({ date })}
|
||
style={styles.dateField}
|
||
/>
|
||
<CompactStepperInput
|
||
value={item.hours}
|
||
onChangeText={(hours) => onChange({ hours })}
|
||
step={0.25}
|
||
style={styles.hoursField}
|
||
/>
|
||
<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>
|
||
<Text style={[styles.amount, styles.amountEdit, { color: colors.foreground }]}>
|
||
{formatCurrency(amount, currency)}
|
||
</Text>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Remove line item"
|
||
onPress={onRemove}
|
||
hitSlop={8}
|
||
style={({ pressed }) => [styles.remove, pressed && styles.removePressed]}
|
||
>
|
||
<Ionicons name="trash-outline" size={17} color={colors.destructive} />
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const headerStyles = StyleSheet.create({
|
||
row: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: spacing.xs,
|
||
paddingBottom: spacing.xs,
|
||
marginBottom: spacing.xs,
|
||
borderBottomWidth: 1,
|
||
},
|
||
cell: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 11,
|
||
textTransform: "uppercase",
|
||
letterSpacing: 0.4,
|
||
},
|
||
desc: { flex: 1, paddingLeft: 22 },
|
||
date: { width: 72 },
|
||
hours: { width: 88, textAlign: "center" },
|
||
rate: { width: 72, textAlign: "center" },
|
||
amt: { width: 64, textAlign: "right" },
|
||
spacer: { width: 32 },
|
||
});
|
||
|
||
const styles = StyleSheet.create({
|
||
row: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: spacing.xs,
|
||
paddingVertical: spacing.sm,
|
||
},
|
||
editBlock: {
|
||
paddingVertical: spacing.sm,
|
||
gap: spacing.xs,
|
||
},
|
||
editTop: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: spacing.xs,
|
||
},
|
||
index: {
|
||
width: 18,
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 12,
|
||
textAlign: "center",
|
||
},
|
||
descCol: {
|
||
flex: 1,
|
||
gap: 2,
|
||
},
|
||
readTitle: {
|
||
fontFamily: fonts.bodyMedium,
|
||
fontSize: 14,
|
||
lineHeight: 18,
|
||
},
|
||
readSub: {
|
||
fontFamily: fonts.body,
|
||
fontSize: 11,
|
||
},
|
||
descriptionInput: {
|
||
flex: 1,
|
||
minHeight: 36,
|
||
borderWidth: 1,
|
||
borderRadius: radii.md,
|
||
paddingHorizontal: spacing.sm,
|
||
fontFamily: fonts.body,
|
||
fontSize: 14,
|
||
paddingVertical: 6,
|
||
},
|
||
metricsRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: spacing.xs,
|
||
paddingLeft: 22,
|
||
},
|
||
dateField: {
|
||
width: 72,
|
||
},
|
||
hoursField: {
|
||
width: 88,
|
||
},
|
||
rateField: {
|
||
width: 72,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
borderWidth: 1,
|
||
borderRadius: radii.md,
|
||
minHeight: 36,
|
||
paddingHorizontal: spacing.xs,
|
||
},
|
||
ratePrefix: {
|
||
fontFamily: fonts.body,
|
||
fontSize: 13,
|
||
},
|
||
rateInput: {
|
||
flex: 1,
|
||
fontFamily: fonts.body,
|
||
fontSize: 13,
|
||
paddingVertical: 4,
|
||
textAlign: "right",
|
||
},
|
||
amount: {
|
||
width: 64,
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 13,
|
||
textAlign: "right",
|
||
},
|
||
amountEdit: {
|
||
fontSize: 12,
|
||
},
|
||
remove: {
|
||
width: 32,
|
||
height: 36,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
removePressed: {
|
||
opacity: 0.65,
|
||
},
|
||
});
|