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>
416 lines
12 KiB
TypeScript
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,
|
|
},
|
|
});
|