Polish mobile app for App Store review and expand CRUD.
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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||
@@ -21,8 +20,9 @@ import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { useNativeTabBarHeight, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
@@ -32,8 +32,6 @@ export default function InvoiceEditScreen() {
|
||||
const styles = useThemedStyles(createInvoiceEditStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const utils = api.useUtils();
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useNativeTabBarHeight();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
@@ -46,7 +44,6 @@ export default function InvoiceEditScreen() {
|
||||
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [footerHeight, setFooterHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const invoice = invoiceQuery.data;
|
||||
@@ -77,6 +74,16 @@ export default function InvoiceEditScreen() {
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
const sendInvoice = api.email.sendInvoice.useMutation({
|
||||
onSuccess: (data) => {
|
||||
Alert.alert("Invoice sent", data.message);
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||
});
|
||||
|
||||
const invoice = invoiceQuery.data;
|
||||
|
||||
const subtotal = useMemo(
|
||||
@@ -106,6 +113,31 @@ export default function InvoiceEditScreen() {
|
||||
return <LoadingScreen message="Invoice not found" />;
|
||||
}
|
||||
|
||||
const status = getInvoiceStatus(invoice);
|
||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||
|
||||
function promptSendInvoice() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client on the web app before sending invoices.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
status === "draft" ? "Send invoice" : "Resend invoice",
|
||||
`Email this invoice to ${clientEmail}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Send",
|
||||
onPress: () => sendInvoice.mutate({ invoiceId: invoice!.id }),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
@@ -180,17 +212,15 @@ export default function InvoiceEditScreen() {
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding + footerHeight },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding + footerHeight }}
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
@@ -245,20 +275,19 @@ export default function InvoiceEditScreen() {
|
||||
</Card>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
onLayout={(event) => setFooterHeight(event.nativeEvent.layout.height)}
|
||||
style={[
|
||||
styles.footer,
|
||||
{
|
||||
bottom: tabBarHeight,
|
||||
paddingBottom: Math.max(insets.bottom, spacing.sm),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
||||
{status !== "paid" ? (
|
||||
<Button
|
||||
title={status === "draft" ? "Send invoice" : "Resend invoice"}
|
||||
variant="secondary"
|
||||
onPress={promptSendInvoice}
|
||||
loading={sendInvoice.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</AppBackground>
|
||||
);
|
||||
@@ -321,58 +350,51 @@ const totalStyles = StyleSheet.create({
|
||||
|
||||
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notesInput: {
|
||||
minHeight: 72,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
addLine: {
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
addLineText: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
totals: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
gap: 6,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
footer: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
});
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notesInput: {
|
||||
minHeight: 72,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
addLine: {
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
addLineText: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
totals: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
gap: 6,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user