Move production to beenvoice.app with migrated accounts, refreshed auth and timer UX, and expanded invoice flows.
Official URL migration preserves sessions, shortcuts prefs, and last clock-in client; auth screens match web with legal links; time clock and invoice editor/send flows are updated for the new domain and UI patterns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import type { InvoiceStatus } from "@/lib/invoice-status";
|
||||
|
||||
type ActionItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type InvoiceDetailActionsProps = {
|
||||
status: InvoiceStatus;
|
||||
clientEmail: string;
|
||||
onPaymentReminder?: () => void;
|
||||
paymentReminderLoading?: boolean;
|
||||
onUpdateStatus: () => void;
|
||||
updateStatusLoading?: boolean;
|
||||
onTrackTime: () => void;
|
||||
};
|
||||
|
||||
export function InvoiceDetailActions({
|
||||
status,
|
||||
clientEmail,
|
||||
onPaymentReminder,
|
||||
paymentReminderLoading,
|
||||
onUpdateStatus,
|
||||
updateStatusLoading,
|
||||
onTrackTime,
|
||||
}: InvoiceDetailActionsProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
const rows: ActionItem[] = [];
|
||||
|
||||
if ((status === "sent" || status === "overdue") && onPaymentReminder) {
|
||||
rows.push({
|
||||
key: "reminder",
|
||||
title: "Send payment reminder",
|
||||
subtitle: clientEmail ? `Nudge ${clientEmail}` : "Add a client email first",
|
||||
icon: "notifications-outline",
|
||||
onPress: onPaymentReminder,
|
||||
loading: paymentReminderLoading,
|
||||
});
|
||||
}
|
||||
|
||||
rows.push(
|
||||
{
|
||||
key: "status",
|
||||
title: "Update status",
|
||||
subtitle: "Draft, sent, or paid",
|
||||
icon: "swap-horizontal-outline",
|
||||
onPress: onUpdateStatus,
|
||||
loading: updateStatusLoading,
|
||||
},
|
||||
{
|
||||
key: "timer",
|
||||
title: "Track time",
|
||||
subtitle: "Clock hours to this invoice",
|
||||
icon: "timer-outline",
|
||||
onPress: onTrackTime,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.list}>
|
||||
{rows.map((row, index) => (
|
||||
<View key={row.key}>
|
||||
{index > 0 ? (
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={row.loading}
|
||||
onPress={row.onPress}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={row.icon} size={20} color={colors.foreground} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>{row.title}</Text>
|
||||
{row.subtitle ? (
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||
{row.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.lg,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
list: {
|
||||
gap: 0,
|
||||
},
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
rowPressed: {
|
||||
opacity: 0.75,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type SecondaryAction = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type InvoiceEditorFooterProps = {
|
||||
primaryTitle: string;
|
||||
onPrimary: () => void;
|
||||
primaryLoading?: boolean;
|
||||
primaryDisabled?: boolean;
|
||||
secondary?: SecondaryAction;
|
||||
};
|
||||
|
||||
export function InvoiceEditorFooter({
|
||||
primaryTitle,
|
||||
onPrimary,
|
||||
primaryLoading,
|
||||
primaryDisabled,
|
||||
secondary,
|
||||
}: InvoiceEditorFooterProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
title={primaryTitle}
|
||||
loading={primaryLoading}
|
||||
disabled={primaryDisabled}
|
||||
onPress={onPrimary}
|
||||
/>
|
||||
|
||||
{secondary ? (
|
||||
<>
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={secondary.disabled || secondary.loading}
|
||||
onPress={secondary.onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.secondaryRow,
|
||||
(pressed || secondary.loading) && styles.secondaryPressed,
|
||||
(secondary.disabled || secondary.loading) && styles.secondaryDisabled,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={secondary.icon} size={20} color={colors.foreground} />
|
||||
</View>
|
||||
<View style={styles.secondaryCopy}>
|
||||
<Text style={[styles.secondaryTitle, { color: colors.foreground }]}>
|
||||
{secondary.title}
|
||||
</Text>
|
||||
{secondary.subtitle ? (
|
||||
<Text style={[styles.secondarySubtitle, { color: colors.mutedForeground }]}>
|
||||
{secondary.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.lg,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
secondaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
secondaryPressed: {
|
||||
opacity: 0.75,
|
||||
},
|
||||
secondaryDisabled: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
secondaryCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
},
|
||||
secondaryTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
secondarySubtitle: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -3,21 +3,34 @@ import { ScrollView, StyleSheet, View } from "react-native";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { spacing } from "@/constants/theme";
|
||||
|
||||
export type InvoiceEditorSection = "edit" | "preview";
|
||||
export type InvoiceEditorSection = "setup" | "lines" | "preview";
|
||||
export type InvoiceViewSection = "details" | "preview";
|
||||
|
||||
type InvoiceEditorSectionTabsProps = {
|
||||
value: InvoiceEditorSection;
|
||||
onChange: (value: InvoiceEditorSection) => void;
|
||||
editLabel?: string;
|
||||
previewLabel?: string;
|
||||
};
|
||||
type InvoiceEditorSectionTabsProps =
|
||||
| {
|
||||
mode?: "edit";
|
||||
value: InvoiceEditorSection;
|
||||
onChange: (value: InvoiceEditorSection) => void;
|
||||
}
|
||||
| {
|
||||
mode: "view";
|
||||
value: InvoiceViewSection;
|
||||
onChange: (value: InvoiceViewSection) => void;
|
||||
};
|
||||
|
||||
export function InvoiceEditorSectionTabs(props: InvoiceEditorSectionTabsProps) {
|
||||
const tabs =
|
||||
props.mode === "view"
|
||||
? [
|
||||
{ id: "details" as const, label: "Details" },
|
||||
{ id: "preview" as const, label: "PDF" },
|
||||
]
|
||||
: [
|
||||
{ id: "setup" as const, label: "Setup" },
|
||||
{ id: "lines" as const, label: "Line items" },
|
||||
{ id: "preview" as const, label: "PDF preview" },
|
||||
];
|
||||
|
||||
export function InvoiceEditorSectionTabs({
|
||||
value,
|
||||
onChange,
|
||||
editLabel = "Edit",
|
||||
previewLabel = "PDF preview",
|
||||
}: InvoiceEditorSectionTabsProps) {
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
@@ -25,16 +38,14 @@ export function InvoiceEditorSectionTabs({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
<FilterChip
|
||||
label={editLabel}
|
||||
active={value === "edit"}
|
||||
onPress={() => onChange("edit")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={previewLabel}
|
||||
active={value === "preview"}
|
||||
onPress={() => onChange("preview")}
|
||||
/>
|
||||
{tabs.map((tab) => (
|
||||
<FilterChip
|
||||
key={tab.id}
|
||||
label={tab.label}
|
||||
active={props.value === tab.id}
|
||||
onPress={() => props.onChange(tab.id as never)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { SelectField } from "@/components/ui/SelectField";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { defaultDueDate } from "@/lib/invoice-number";
|
||||
|
||||
type SelectOption = { label: string; value: string };
|
||||
|
||||
type InvoiceSetupFormProps = {
|
||||
businessId: string;
|
||||
onBusinessIdChange: (value: string) => void;
|
||||
businessOptions: SelectOption[];
|
||||
businessError?: string;
|
||||
businessReadOnly?: boolean;
|
||||
clientId: string;
|
||||
onClientIdChange: (value: string) => void;
|
||||
clientOptions: SelectOption[];
|
||||
clientError?: string;
|
||||
clientReadOnly?: boolean;
|
||||
invoiceNumber: string;
|
||||
onInvoiceNumberChange?: (value: string) => void;
|
||||
invoiceNumberReadOnly?: boolean;
|
||||
issueDate: Date;
|
||||
onIssueDateChange?: (date: Date) => void;
|
||||
issueDateReadOnly?: boolean;
|
||||
dueDate: Date;
|
||||
onDueDateChange: (date: Date) => void;
|
||||
taxRate: string;
|
||||
onTaxRateChange?: (value: string) => void;
|
||||
taxRateReadOnly?: boolean;
|
||||
notes: string;
|
||||
onNotesChange: (value: string) => void;
|
||||
sendReminderAt?: Date | null;
|
||||
onSendReminderAtChange?: (date: Date | null) => void;
|
||||
showSendReminder?: boolean;
|
||||
};
|
||||
|
||||
export function InvoiceSetupForm({
|
||||
businessId,
|
||||
onBusinessIdChange,
|
||||
businessOptions,
|
||||
businessError,
|
||||
businessReadOnly = false,
|
||||
clientId,
|
||||
onClientIdChange,
|
||||
clientOptions,
|
||||
clientError,
|
||||
clientReadOnly = false,
|
||||
invoiceNumber,
|
||||
onInvoiceNumberChange,
|
||||
invoiceNumberReadOnly = false,
|
||||
issueDate,
|
||||
onIssueDateChange,
|
||||
issueDateReadOnly = false,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
taxRate,
|
||||
onTaxRateChange,
|
||||
taxRateReadOnly = false,
|
||||
notes,
|
||||
onNotesChange,
|
||||
sendReminderAt,
|
||||
onSendReminderAtChange,
|
||||
showSendReminder = false,
|
||||
}: InvoiceSetupFormProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.form}>
|
||||
{businessOptions.length === 0 ? (
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||
Add a business in Entities before invoicing.
|
||||
</Text>
|
||||
) : (
|
||||
<SelectField
|
||||
label="Business"
|
||||
placeholder="Select business…"
|
||||
value={businessId}
|
||||
options={businessOptions}
|
||||
required
|
||||
error={businessError}
|
||||
disabled={businessReadOnly}
|
||||
onValueChange={onBusinessIdChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clientOptions.length === 0 ? (
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||
Add a client in Entities before invoicing.
|
||||
</Text>
|
||||
) : (
|
||||
<SelectField
|
||||
label="Client"
|
||||
placeholder="Select client…"
|
||||
value={clientId}
|
||||
options={clientOptions}
|
||||
required
|
||||
error={clientError}
|
||||
disabled={clientReadOnly}
|
||||
onValueChange={onClientIdChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{invoiceNumberReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
Invoice number
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{invoiceNumber}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Input
|
||||
label="Invoice number"
|
||||
value={invoiceNumber}
|
||||
onChangeText={onInvoiceNumberChange}
|
||||
autoCapitalize="characters"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{issueDateReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
Issue date
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{issueDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<DateTimeField
|
||||
label="Issue date"
|
||||
mode="date"
|
||||
value={issueDate}
|
||||
onChange={(date) => {
|
||||
onIssueDateChange?.(date);
|
||||
if (dueDate < date) onDueDateChange(defaultDueDate(date));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} />
|
||||
|
||||
{taxRateReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
Tax rate
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{taxRate}%
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Input
|
||||
label="Tax rate (%)"
|
||||
value={taxRate}
|
||||
onChangeText={onTaxRateChange}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSendReminder && onSendReminderAtChange ? (
|
||||
<>
|
||||
<DateTimeField
|
||||
label="Remind me to send"
|
||||
mode="date"
|
||||
value={sendReminderAt ?? dueDate}
|
||||
minimumDate={new Date()}
|
||||
maximumDate={new Date(2100, 0, 1)}
|
||||
onChange={onSendReminderAtChange}
|
||||
/>
|
||||
{sendReminderAt ? (
|
||||
<Pressable onPress={() => onSendReminderAtChange(null)}>
|
||||
<Text style={[styles.clearReminder, { color: colors.primary }]}>
|
||||
Clear send reminder
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChangeText={onNotesChange}
|
||||
placeholder="Optional notes for the client"
|
||||
multiline
|
||||
style={styles.notesInput}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
form: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
hint: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
readOnlyField: {
|
||||
gap: 4,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
readOnlyLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
},
|
||||
readOnlyValue: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 15,
|
||||
},
|
||||
notesInput: {
|
||||
minHeight: 72,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
clearReminder: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -65,10 +65,10 @@ function TotalRow({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
totals: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
gap: 6,
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ScrollView, StyleSheet, View } from "react-native";
|
||||
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { spacing } from "@/constants/theme";
|
||||
import type { InvoiceStatus } from "@/lib/invoice-status";
|
||||
|
||||
export type InvoiceViewSection = "details" | "preview";
|
||||
|
||||
type InvoiceViewChipsProps = {
|
||||
section: InvoiceViewSection;
|
||||
onSectionChange: (section: InvoiceViewSection) => void;
|
||||
status: InvoiceStatus;
|
||||
onEdit: () => void;
|
||||
onSend: () => void;
|
||||
};
|
||||
|
||||
export function InvoiceViewChips({
|
||||
section,
|
||||
onSectionChange,
|
||||
status,
|
||||
onEdit,
|
||||
onSend,
|
||||
}: InvoiceViewChipsProps) {
|
||||
const sendLabel = status === "draft" ? "Send" : "Resend";
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
<FilterChip
|
||||
label="Details"
|
||||
active={section === "details"}
|
||||
onPress={() => onSectionChange("details")}
|
||||
/>
|
||||
<FilterChip label="Edit" onPress={onEdit} />
|
||||
{status !== "paid" ? (
|
||||
<FilterChip label={sendLabel} onPress={onSend} />
|
||||
) : null}
|
||||
<FilterChip
|
||||
label="View PDF"
|
||||
active={section === "preview"}
|
||||
onPress={() => onSectionChange("preview")}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -25,28 +25,10 @@ type LineItemEditorProps = {
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export function LineItemsTableHeader() {
|
||||
function FieldLabel({ children }: { children: string }) {
|
||||
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>
|
||||
<Text style={[styles.fieldLabel, { color: colors.mutedForeground }]}>{children}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,20 +50,20 @@ export function LineItemEditor({
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.row,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
||||
styles.readBlock,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||
]}
|
||||
>
|
||||
<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 }]}>
|
||||
<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>
|
||||
@@ -92,154 +74,159 @@ export function LineItemEditor({
|
||||
<View
|
||||
style={[
|
||||
styles.editBlock,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||
]}
|
||||
>
|
||||
<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>
|
||||
<Text style={[styles.lineLabel, { color: colors.mutedForeground }]}>Line {index + 1}</Text>
|
||||
|
||||
<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 }]}
|
||||
<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>
|
||||
<Text style={[styles.amount, styles.amountEdit, { color: colors.foreground }]}>
|
||||
{formatCurrency(amount, currency)}
|
||||
</Text>
|
||||
<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, pressed && styles.removePressed]}
|
||||
style={({ pressed }) => [
|
||||
styles.remove,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
pressed && styles.removePressed,
|
||||
]}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={17} color={colors.destructive} />
|
||||
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const headerStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
const styles = StyleSheet.create({
|
||||
readBlock: {
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.xs,
|
||||
paddingBottom: spacing.xs,
|
||||
marginBottom: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
cell: {
|
||||
readIndex: {
|
||||
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,
|
||||
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: {
|
||||
flex: 1,
|
||||
minHeight: 36,
|
||||
width: "100%",
|
||||
minHeight: 40,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
paddingVertical: 6,
|
||||
fontSize: 15,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
metricsRow: {
|
||||
fieldsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
paddingLeft: 22,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dateField: {
|
||||
width: 72,
|
||||
fieldCol: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
minWidth: 0,
|
||||
},
|
||||
hoursField: {
|
||||
width: 88,
|
||||
fieldLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 11,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
fieldControl: {
|
||||
width: "100%",
|
||||
},
|
||||
rateField: {
|
||||
width: 72,
|
||||
minHeight: 36,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
minHeight: 36,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
ratePrefix: {
|
||||
@@ -248,23 +235,40 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
rateInput: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.body,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
paddingVertical: 4,
|
||||
textAlign: "right",
|
||||
minWidth: 0,
|
||||
},
|
||||
amount: {
|
||||
width: 64,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
textAlign: "right",
|
||||
footerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
amountEdit: {
|
||||
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: 32,
|
||||
height: 36,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
borderWidth: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user