Files
beenvoice-app/app/(app)/invoices/[id].tsx
T
soconnorandCursor 6762a9bff3 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>
2026-06-26 03:40:48 -04:00

382 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
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 styles = useThemedStyles(createInvoiceDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [section, setSection] = useState<InvoiceViewSection>("details");
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 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),
});
const invoice = invoiceQuery.data;
const previewInput = useMemo(
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
[invoice],
);
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 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() ?? "";
function openSendScreen() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client on the web app before sending invoices.",
);
return;
}
if (invoice.items.length === 0) {
Alert.alert(
"No line items",
"Add line items or clock time to this invoice before sending.",
);
return;
}
router.push(`/(app)/invoices/send/${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" }} />
<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>
<InvoiceViewChips
section={section}
onSectionChange={setSection}
status={status}
onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
onSend={openSendScreen}
/>
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : (
<>
<Card title="Details">
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
<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.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Clock time to this invoice from the Timer tab, or edit to
add lines manually.
</Text>
) : (
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}
<InvoiceDetailActions
status={status}
clientEmail={clientEmail}
onPaymentReminder={
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
}
paymentReminderLoading={sendPaymentReminder.isPending}
onUpdateStatus={() => promptStatusChange(status)}
updateStatusLoading={updateStatus.isPending}
onTrackTime={() =>
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
}
/>
</>
)}
</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,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
},
notes: {
fontFamily: fonts.body,
color: colors.foreground,
fontSize: 14,
lineHeight: 20,
},
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,
},
});