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>
This commit is contained in:
+66
-118
@@ -1,14 +1,12 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import {
|
||||
InvoiceEditorSectionTabs,
|
||||
type InvoiceEditorSection,
|
||||
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
||||
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";
|
||||
@@ -24,12 +22,11 @@ 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 [section, setSection] = useState<InvoiceViewSection>("details");
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
@@ -45,16 +42,6 @@ export default function InvoiceDetailScreen() {
|
||||
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.");
|
||||
@@ -63,6 +50,12 @@ export default function InvoiceDetailScreen() {
|
||||
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" />;
|
||||
}
|
||||
@@ -85,17 +78,12 @@ export default function InvoiceDetailScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
function openSendScreen() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
@@ -103,18 +91,14 @@ export default function InvoiceDetailScreen() {
|
||||
);
|
||||
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 }),
|
||||
},
|
||||
],
|
||||
);
|
||||
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() {
|
||||
@@ -159,25 +143,7 @@ export default function InvoiceDetailScreen() {
|
||||
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
@@ -201,11 +167,12 @@ export default function InvoiceDetailScreen() {
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<InvoiceEditorSectionTabs
|
||||
value={section}
|
||||
onChange={setSection}
|
||||
editLabel="Details"
|
||||
previewLabel="PDF"
|
||||
<InvoiceViewChips
|
||||
section={section}
|
||||
onSectionChange={setSection}
|
||||
status={status}
|
||||
onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
|
||||
onSend={openSendScreen}
|
||||
/>
|
||||
|
||||
{section === "preview" ? (
|
||||
@@ -215,6 +182,8 @@ export default function InvoiceDetailScreen() {
|
||||
) : (
|
||||
<>
|
||||
<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} />
|
||||
@@ -234,20 +203,27 @@ export default function InvoiceDetailScreen() {
|
||||
</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)}
|
||||
{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>
|
||||
<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}
|
||||
@@ -264,43 +240,19 @@ export default function InvoiceDetailScreen() {
|
||||
</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>
|
||||
<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>
|
||||
@@ -398,22 +350,18 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
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,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerAction: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 16,
|
||||
},
|
||||
headerPressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
|
||||
Reference in New Issue
Block a user