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>
434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||
import { useMemo, useState } from "react";
|
||
import { Alert, 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 { LoadingScreen } from "@/components/LoadingScreen";
|
||
import { StatusBadge } from "@/components/StatusBadge";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { Card } from "@/components/ui/Card";
|
||
import { fonts, spacing } from "@/constants/theme";
|
||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||
import { formatCurrency, formatDate } from "@/lib/format";
|
||
import type { ThemeColors } from "@/lib/theme-palette";
|
||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
|
||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||
import { api } from "@/lib/trpc";
|
||
|
||
export default function InvoiceDetailScreen() {
|
||
const { colors } = useAppTheme();
|
||
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||
const { id } = useLocalSearchParams<{ id: string }>();
|
||
const utils = api.useUtils();
|
||
const scrollPadding = useTabBarScrollPadding();
|
||
const [section, setSection] = useState<InvoiceEditorSection>("edit");
|
||
|
||
const invoiceQuery = api.invoices.getById.useQuery(
|
||
{ id: id ?? "" },
|
||
{ enabled: Boolean(id) },
|
||
);
|
||
|
||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||
onSuccess: () => {
|
||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||
void utils.invoices.getAll.invalidate();
|
||
void utils.dashboard.getStats.invalidate();
|
||
},
|
||
onError: (err) => Alert.alert("Update failed", 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 sendPaymentReminder = api.invoices.sendReminder.useMutation({
|
||
onSuccess: () => {
|
||
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
|
||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||
},
|
||
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
||
});
|
||
|
||
if (!id) {
|
||
return <LoadingScreen message="Invalid invoice" />;
|
||
}
|
||
|
||
if (invoiceQuery.isLoading) {
|
||
return <LoadingScreen message="Loading invoice…" />;
|
||
}
|
||
|
||
if (invoiceQuery.error || !invoiceQuery.data) {
|
||
return (
|
||
<AppBackground>
|
||
<View style={styles.errorBox}>
|
||
<Text style={styles.errorTitle}>Could not load invoice</Text>
|
||
<Text style={styles.errorText}>
|
||
{invoiceQuery.error?.message ?? "Invoice not found"}
|
||
</Text>
|
||
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
|
||
</View>
|
||
</AppBackground>
|
||
);
|
||
}
|
||
|
||
const invoice = invoiceQuery.data;
|
||
const status = getInvoiceStatus(invoice);
|
||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||
const previewInput = useMemo(
|
||
() => buildPreviewPdfInputFromInvoice(invoice),
|
||
[invoice],
|
||
);
|
||
|
||
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 promptPaymentReminder() {
|
||
if (!clientEmail) {
|
||
Alert.alert(
|
||
"No client email",
|
||
"Add an email address to this client before sending payment reminders.",
|
||
);
|
||
return;
|
||
}
|
||
|
||
Alert.alert(
|
||
"Send payment reminder",
|
||
`Email a payment reminder to ${clientEmail}?`,
|
||
[
|
||
{ text: "Cancel", style: "cancel" },
|
||
{
|
||
text: "Send",
|
||
onPress: () => sendPaymentReminder.mutate({ id: invoice.id }),
|
||
},
|
||
],
|
||
);
|
||
}
|
||
|
||
function promptStatusChange(current: InvoiceStatus) {
|
||
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||
if (current !== "sent" && current !== "overdue") {
|
||
options.push({ label: "Mark as sent", status: "sent" });
|
||
}
|
||
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||
if (options.length === 0) return;
|
||
|
||
Alert.alert("Update status", "Choose a new status", [
|
||
...options.map((option) => ({
|
||
text: option.label,
|
||
onPress: () => updateStatus.mutate({ id: invoice.id, status: option.status }),
|
||
})),
|
||
{ text: "Cancel", style: "cancel" },
|
||
]);
|
||
}
|
||
|
||
return (
|
||
<AppBackground>
|
||
<Stack.Screen
|
||
options={{
|
||
headerBackTitle: "Invoices",
|
||
headerRight: () =>
|
||
status !== "paid" ? (
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
hitSlop={8}
|
||
onPress={promptSendInvoice}
|
||
disabled={sendInvoice.isPending}
|
||
style={({ pressed }) => pressed && styles.headerPressed}
|
||
>
|
||
<Text style={[styles.headerAction, { color: colors.primary }]}>
|
||
{status === "draft" ? "Send" : "Resend"}
|
||
</Text>
|
||
</Pressable>
|
||
) : null,
|
||
}}
|
||
/>
|
||
<ScrollView
|
||
style={styles.scroll}
|
||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||
keyboardShouldPersistTaps="handled"
|
||
>
|
||
<Card>
|
||
<View style={styles.headerRow}>
|
||
<View style={styles.headerMeta}>
|
||
<Text style={styles.invoiceNumber}>
|
||
{invoice.invoicePrefix}
|
||
{invoice.invoiceNumber}
|
||
</Text>
|
||
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
||
</View>
|
||
<StatusBadge status={status} />
|
||
</View>
|
||
<Text style={styles.total}>
|
||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||
</Text>
|
||
</Card>
|
||
|
||
<InvoiceEditorSectionTabs
|
||
value={section}
|
||
onChange={setSection}
|
||
editLabel="Details"
|
||
previewLabel="PDF"
|
||
/>
|
||
|
||
{section === "preview" ? (
|
||
<Card title="PDF preview">
|
||
<InvoicePdfPreview input={previewInput} />
|
||
</Card>
|
||
) : (
|
||
<>
|
||
<Card title="Details">
|
||
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
|
||
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
|
||
<DetailRow label="Currency" value={invoice.currency} />
|
||
{invoice.taxRate > 0 ? (
|
||
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
|
||
) : null}
|
||
{invoice.status === "draft" && invoice.sendReminderAt ? (
|
||
<DetailRow
|
||
label="Send reminder"
|
||
value={
|
||
new Date(invoice.sendReminderAt) <= new Date()
|
||
? "Due now"
|
||
: formatDate(invoice.sendReminderAt)
|
||
}
|
||
/>
|
||
) : null}
|
||
</Card>
|
||
|
||
<Card title="Line items">
|
||
{invoice.items.map((item) => (
|
||
<View key={item.id} style={styles.lineItem}>
|
||
<View style={styles.lineMeta}>
|
||
<Text style={styles.lineDescription}>{item.description}</Text>
|
||
<Text style={styles.lineSub}>
|
||
{formatDate(item.date)} · {item.hours}h ×{" "}
|
||
{formatCurrency(item.rate, invoice.currency)}
|
||
</Text>
|
||
</View>
|
||
<Text style={styles.lineAmount}>
|
||
{formatCurrency(item.amount, invoice.currency)}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
<InvoiceTotals
|
||
subtotal={formatCurrency(subtotal, invoice.currency)}
|
||
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
|
||
taxAmount={
|
||
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined
|
||
}
|
||
total={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||
/>
|
||
</Card>
|
||
|
||
{invoice.notes ? (
|
||
<Card title="Notes">
|
||
<Text style={styles.notes}>{invoice.notes}</Text>
|
||
</Card>
|
||
) : null}
|
||
|
||
<View style={styles.actions}>
|
||
{status !== "paid" ? (
|
||
<Button
|
||
title={status === "draft" ? "Send invoice" : "Resend invoice"}
|
||
onPress={promptSendInvoice}
|
||
loading={sendInvoice.isPending}
|
||
/>
|
||
) : null}
|
||
{status === "sent" || status === "overdue" ? (
|
||
<Button
|
||
title="Send payment reminder"
|
||
variant="secondary"
|
||
onPress={promptPaymentReminder}
|
||
loading={sendPaymentReminder.isPending}
|
||
/>
|
||
) : null}
|
||
<Button
|
||
title="Edit invoice"
|
||
variant="secondary"
|
||
onPress={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
|
||
/>
|
||
<Button
|
||
title="Update status"
|
||
variant="ghost"
|
||
onPress={() => promptStatusChange(status)}
|
||
loading={updateStatus.isPending}
|
||
/>
|
||
<Button
|
||
title="Track time to this invoice"
|
||
variant="ghost"
|
||
onPress={() =>
|
||
router.push(
|
||
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
|
||
)
|
||
}
|
||
/>
|
||
</View>
|
||
</>
|
||
)}
|
||
</ScrollView>
|
||
</AppBackground>
|
||
);
|
||
}
|
||
|
||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||
const { colors } = useAppTheme();
|
||
return (
|
||
<View style={detailStyles.row}>
|
||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const detailStyles = StyleSheet.create({
|
||
row: {
|
||
flexDirection: "row",
|
||
justifyContent: "space-between",
|
||
gap: spacing.md,
|
||
paddingVertical: 4,
|
||
},
|
||
label: {
|
||
fontSize: 14,
|
||
fontFamily: fonts.body,
|
||
},
|
||
value: {
|
||
fontSize: 14,
|
||
fontFamily: fonts.bodyMedium,
|
||
},
|
||
});
|
||
|
||
const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||
StyleSheet.create({
|
||
scroll: {
|
||
flex: 1,
|
||
},
|
||
container: {
|
||
padding: spacing.md,
|
||
gap: spacing.md,
|
||
},
|
||
headerRow: {
|
||
flexDirection: "row",
|
||
justifyContent: "space-between",
|
||
alignItems: "flex-start",
|
||
gap: spacing.md,
|
||
},
|
||
headerMeta: {
|
||
flex: 1,
|
||
gap: 4,
|
||
},
|
||
invoiceNumber: {
|
||
fontSize: 22,
|
||
lineHeight: 26,
|
||
fontFamily: fonts.heading,
|
||
color: colors.foreground,
|
||
},
|
||
clientName: {
|
||
fontSize: 15,
|
||
fontFamily: fonts.body,
|
||
color: colors.mutedForeground,
|
||
},
|
||
total: {
|
||
marginTop: spacing.sm,
|
||
fontSize: 28,
|
||
fontFamily: fonts.bodySemiBold,
|
||
color: colors.foreground,
|
||
},
|
||
lineItem: {
|
||
flexDirection: "row",
|
||
justifyContent: "space-between",
|
||
gap: spacing.md,
|
||
paddingVertical: spacing.sm,
|
||
borderTopWidth: 1,
|
||
borderTopColor: colors.border,
|
||
},
|
||
lineMeta: {
|
||
flex: 1,
|
||
gap: 2,
|
||
},
|
||
lineDescription: {
|
||
fontFamily: fonts.bodyMedium,
|
||
color: colors.foreground,
|
||
fontSize: 14,
|
||
},
|
||
lineSub: {
|
||
fontFamily: fonts.body,
|
||
color: colors.mutedForeground,
|
||
fontSize: 12,
|
||
},
|
||
lineAmount: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
color: colors.foreground,
|
||
fontSize: 14,
|
||
},
|
||
notes: {
|
||
fontFamily: fonts.body,
|
||
color: colors.foreground,
|
||
fontSize: 14,
|
||
lineHeight: 20,
|
||
},
|
||
actions: {
|
||
gap: spacing.sm,
|
||
},
|
||
headerAction: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 16,
|
||
},
|
||
headerPressed: {
|
||
opacity: 0.65,
|
||
},
|
||
errorBox: {
|
||
flex: 1,
|
||
justifyContent: "center",
|
||
padding: spacing.lg,
|
||
gap: spacing.md,
|
||
},
|
||
errorTitle: {
|
||
fontSize: 18,
|
||
fontFamily: fonts.bodySemiBold,
|
||
color: colors.foreground,
|
||
},
|
||
errorText: {
|
||
color: colors.mutedForeground,
|
||
fontFamily: fonts.body,
|
||
lineHeight: 20,
|
||
},
|
||
});
|