Files
beenvoice-app/app/(app)/invoices/edit/[id].tsx
T
soconnor 355b14faef Add local iOS release pipeline, fix shortcuts, and improve invoice UX.
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>
2026-06-23 01:08:20 -04:00

416 lines
12 KiB
TypeScript

import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, LineItemsTableHeader, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import { validateLineItems } from "@/lib/form-validation";
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
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";
export default function InvoiceEditScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoiceEditStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date());
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]);
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const invoice = invoiceQuery.data;
if (!invoice) return;
setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
setItems(
invoice.items.map((item) => ({
id: item.id,
date: new Date(item.date),
description: item.description,
hours: String(item.hours),
rate: String(item.rate),
})),
);
}, [invoiceQuery.data]);
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.invoices.getAll.invalidate({ status: "draft" });
void utils.dashboard.getStats.invalidate();
Alert.alert("Saved", "Invoice updated", [
{ text: "OK", onPress: () => router.back() },
]);
},
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 isDraft = invoice?.status === "draft";
const subtotal = useMemo(
() =>
items.reduce((sum, item) => {
const hours = Number(item.hours) || 0;
const rate = Number(item.rate) || 0;
return sum + hours * rate;
}, 0),
[items],
);
const taxRate = invoice?.taxRate ?? 0;
const taxAmount = subtotal * (taxRate / 100);
const total = subtotal + taxAmount;
const currency = invoice?.currency ?? "USD";
const lineItemsError = isDraft ? validateLineItems(items) : null;
const canSave = isDraft ? !lineItemsError : true;
const previewInput = useMemo(() => {
if (!invoice) return null;
return buildPreviewPdfInput({
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix,
businessId: invoice.businessId,
clientId: invoice.clientId,
issueDate: new Date(invoice.issueDate),
dueDate,
status: invoice.status as "draft" | "sent" | "paid",
notes,
taxRate,
currency,
items,
});
}, [invoice, dueDate, notes, taxRate, currency, items]);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
if (!invoice) {
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)));
}
function addItem() {
setItems((prev) => [
...prev,
{
date: new Date(),
description: "",
hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0",
},
]);
}
function removeItem(index: number) {
if (items.length <= 1) {
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
return;
}
setItems((prev) => prev.filter((_, i) => i !== index));
}
async function handleSave() {
if (!canSave) return;
setError(null);
if (isDraft && sendReminderAt) {
const granted = await ensureNotificationPermissions();
if (!granted) {
Alert.alert(
"Notifications disabled",
"Turn on notifications in Settings to get reminded when it's time to send this invoice.",
);
}
}
const parsedItems: Array<{
date: Date;
description: string;
hours: number;
rate: number;
}> = [];
for (const item of items) {
parsedItems.push({
date: item.date,
description: item.description.trim(),
hours: Number(item.hours),
rate: Number(item.rate),
});
}
updateInvoice.mutate({
id,
notes,
dueDate,
sendReminderAt,
...(isDraft
? {
items: parsedItems,
}
: {}),
});
}
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<View style={styles.hero}>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
</View>
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : (
<>
<Card>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
{isDraft ? (
<>
<DateTimeField
label="Remind me to send"
mode="date"
value={sendReminderAt ?? dueDate}
minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)}
onChange={setSendReminderAt}
/>
{sendReminderAt ? (
<Pressable onPress={() => setSendReminderAt(null)}>
<Text style={[styles.clearReminder, { color: colors.primary }]}>
Clear send reminder
</Text>
</Pressable>
) : null}
</>
) : null}
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
<Card title="Line items">
{!isDraft ? (
<Text style={styles.lockedHint}>
Line items are locked after an invoice is sent. Mark as draft on the invoice
screen to edit entries.
</Text>
) : (
<LineItemsTableHeader />
)}
{items.map((item, index) => (
<LineItemEditor
key={item.id ?? `new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
readOnly={!isDraft}
/>
))}
{isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
) : null}
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={taxRate > 0 ? `Tax (${taxRate}%)` : undefined}
taxAmount={taxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.actions}>
<Button
title="Save changes"
loading={updateInvoice.isPending}
disabled={!canSave}
onPress={handleSave}
/>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
variant="secondary"
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
</View>
</>
)}
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
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",
},
lockedHint: {
fontFamily: fonts.body,
fontSize: 13,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
clearReminder: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
marginBottom: spacing.sm,
},
addLine: {
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
addLineText: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
color: colors.primary,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
actions: {
gap: spacing.sm,
},
});