6d2711e36e
Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius. Co-authored-by: Cursor <cursoragent@cursor.com>
422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||
|
||
import { AppBackground } from "@/components/AppBackground";
|
||
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 { 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 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),
|
||
});
|
||
|
||
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() ?? "";
|
||
|
||
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 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>
|
||
|
||
<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}
|
||
</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>
|
||
))}
|
||
<View style={styles.totals}>
|
||
<TotalRow label="Subtotal" value={formatCurrency(subtotal, invoice.currency)} />
|
||
{invoice.taxRate > 0 ? (
|
||
<TotalRow
|
||
label={`Tax (${invoice.taxRate}%)`}
|
||
value={formatCurrency(taxAmount, invoice.currency)}
|
||
/>
|
||
) : null}
|
||
<TotalRow
|
||
label="Total"
|
||
value={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||
bold
|
||
/>
|
||
</View>
|
||
</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}
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function TotalRow({
|
||
label,
|
||
value,
|
||
bold,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
bold?: boolean;
|
||
}) {
|
||
const { colors } = useAppTheme();
|
||
return (
|
||
<View style={detailStyles.totalRow}>
|
||
<Text
|
||
style={[
|
||
detailStyles.totalLabel,
|
||
{ color: colors.mutedForeground },
|
||
bold && detailStyles.totalBold,
|
||
bold && { color: colors.foreground },
|
||
]}
|
||
>
|
||
{label}
|
||
</Text>
|
||
<Text
|
||
style={[
|
||
detailStyles.totalValue,
|
||
{ color: colors.foreground },
|
||
bold && detailStyles.totalBold,
|
||
]}
|
||
>
|
||
{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,
|
||
},
|
||
totalRow: {
|
||
flexDirection: "row",
|
||
justifyContent: "space-between",
|
||
},
|
||
totalLabel: {
|
||
fontFamily: fonts.body,
|
||
fontSize: 14,
|
||
},
|
||
totalValue: {
|
||
fontFamily: fonts.bodyMedium,
|
||
fontSize: 14,
|
||
},
|
||
totalBold: {
|
||
fontFamily: fonts.bodySemiBold,
|
||
fontSize: 14,
|
||
},
|
||
});
|
||
|
||
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,
|
||
},
|
||
totals: {
|
||
marginTop: spacing.sm,
|
||
paddingTop: spacing.sm,
|
||
borderTopWidth: 1,
|
||
borderTopColor: colors.border,
|
||
gap: 4,
|
||
},
|
||
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,
|
||
},
|
||
});
|