Add beenvoice mobile companion app with full dark mode support.
Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { Alert, Platform, 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>
|
||||
<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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
import { fonts } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
export default function InvoicesLayout() {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
headerStyle: { backgroundColor: colors.cardGlass },
|
||||
headerTitleStyle: {
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 18,
|
||||
color: colors.foreground,
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: colors.foreground,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
statusBarTranslucent: true,
|
||||
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="[id]" options={{ title: "Invoice" }} />
|
||||
<Stack.Screen name="edit/[id]" options={{ title: "Edit" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LineItemEditor, 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 { useNativeTabBarHeight, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
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 insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useNativeTabBarHeight();
|
||||
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 [items, setItems] = useState<EditableLineItem[]>([]);
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [footerHeight, setFooterHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const invoice = invoiceQuery.data;
|
||||
if (!invoice) return;
|
||||
setNotes(invoice.notes ?? "");
|
||||
setDueDate(new Date(invoice.dueDate));
|
||||
setItems(
|
||||
invoice.items.map((item) => ({
|
||||
id: item.id,
|
||||
date: new Date(item.date),
|
||||
description: item.description,
|
||||
hours: String(item.hours),
|
||||
rate: String(item.rate),
|
||||
})),
|
||||
);
|
||||
setExpandedIndex(null);
|
||||
}, [invoiceQuery.data]);
|
||||
|
||||
const updateInvoice = api.invoices.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
Alert.alert("Saved", "Invoice updated", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
const invoice = invoiceQuery.data;
|
||||
|
||||
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";
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading invoice…" />;
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return <LoadingScreen message="Invoice not found" />;
|
||||
}
|
||||
|
||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const nextIndex = items.length;
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||
},
|
||||
]);
|
||||
setExpandedIndex(nextIndex);
|
||||
}
|
||||
|
||||
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));
|
||||
setExpandedIndex((current) => {
|
||||
if (current === null) return null;
|
||||
if (current === index) return null;
|
||||
return current > index ? current - 1 : current;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setError(null);
|
||||
|
||||
const parsedItems: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const hours = Number(item.hours);
|
||||
const rate = Number(item.rate);
|
||||
if (!item.description.trim()) {
|
||||
setError("Each line needs a description");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(hours) || hours < 0) {
|
||||
setError("Hours must be a valid number");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(rate) || rate < 0) {
|
||||
setError("Rate must be a valid number");
|
||||
return;
|
||||
}
|
||||
parsedItems.push({
|
||||
date: item.date,
|
||||
description: item.description.trim(),
|
||||
hours,
|
||||
rate,
|
||||
});
|
||||
}
|
||||
|
||||
updateInvoice.mutate({
|
||||
id,
|
||||
notes,
|
||||
dueDate,
|
||||
items: parsedItems,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding + footerHeight },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding + footerHeight }}
|
||||
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>
|
||||
|
||||
<Card>
|
||||
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
|
||||
<Input
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Optional notes for the client"
|
||||
multiline
|
||||
style={styles.notesInput}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Line items">
|
||||
{items.map((item, index) => (
|
||||
<LineItemEditor
|
||||
key={item.id ?? `new-${index}`}
|
||||
item={item}
|
||||
currency={currency}
|
||||
expanded={expandedIndex === index}
|
||||
onToggle={() =>
|
||||
setExpandedIndex((current) => (current === index ? null : index))
|
||||
}
|
||||
onChange={(patch) => updateItem(index, patch)}
|
||||
onRemove={() => removeItem(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||
<Text style={styles.addLineText}>+ Add line</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.totals}>
|
||||
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
|
||||
{taxRate > 0 ? (
|
||||
<TotalRow
|
||||
label={`Tax (${taxRate}%)`}
|
||||
value={formatCurrency(taxAmount, currency)}
|
||||
/>
|
||||
) : null}
|
||||
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
onLayout={(event) => setFooterHeight(event.nativeEvent.layout.height)}
|
||||
style={[
|
||||
styles.footer,
|
||||
{
|
||||
bottom: tabBarHeight,
|
||||
paddingBottom: Math.max(insets.bottom, spacing.sm),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalRow({
|
||||
label,
|
||||
value,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={totalStyles.row}>
|
||||
<Text
|
||||
style={[
|
||||
totalStyles.label,
|
||||
{ color: colors.mutedForeground },
|
||||
bold && totalStyles.bold,
|
||||
bold && { color: colors.foreground },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
totalStyles.value,
|
||||
{ color: colors.foreground },
|
||||
bold && totalStyles.bold,
|
||||
]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const totalStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 14,
|
||||
},
|
||||
bold: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
addLine: {
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
addLineText: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
totals: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
gap: 6,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
footer: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
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 { api } from "@/lib/trpc";
|
||||
|
||||
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Sent", value: "sent" },
|
||||
{ label: "Paid", value: "paid" },
|
||||
{ label: "Overdue", value: "overdue" },
|
||||
];
|
||||
|
||||
export default function InvoicesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createInvoicesStyles);
|
||||
const [filter, setFilter] = useState<(typeof filters)[number]["value"]>("all");
|
||||
const utils = api.useUtils();
|
||||
const invoicesQuery = api.invoices.getAll.useQuery();
|
||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.invoices.getAll.invalidate();
|
||||
utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (err) => Alert.alert("Update failed", err.message),
|
||||
});
|
||||
|
||||
if (invoicesQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading invoices…" />;
|
||||
}
|
||||
|
||||
if (invoicesQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorTitle}>Could not load invoices</Text>
|
||||
<Text style={styles.errorText}>{invoicesQuery.error.message}</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const invoices = (invoicesQuery.data ?? []).filter((invoice) => {
|
||||
if (filter === "all") return true;
|
||||
return getInvoiceStatus(invoice) === filter;
|
||||
});
|
||||
|
||||
function promptStatusChange(invoiceId: string, 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: invoiceId, status: option.status });
|
||||
},
|
||||
})),
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={invoicesQuery.isRefetching}
|
||||
onRefresh={() => invoicesQuery.refetch()}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filterScroll}
|
||||
contentContainerStyle={styles.filters}
|
||||
>
|
||||
{filters.map((item) => (
|
||||
<FilterChip
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
active={filter === item.value}
|
||||
onPress={() => setFilter(item.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No invoices found</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Create invoices on the web app, then view and edit them here.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
invoices.map((invoice) => {
|
||||
const status = getInvoiceStatus(invoice);
|
||||
return (
|
||||
<Pressable
|
||||
key={invoice.id}
|
||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||
onLongPress={() => promptStatusChange(invoice.id, status)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.cardTop}>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.invoiceNumber}>
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.clientName}>
|
||||
{invoice.client?.name ?? "Client"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.amount}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardBottom}>
|
||||
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createInvoicesStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
filterScroll: {
|
||||
flexGrow: 0,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
filters: {
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.md,
|
||||
},
|
||||
card: {},
|
||||
cardInner: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
cardTop: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
},
|
||||
cardMeta: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
cardBottom: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
date: {
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
empty: {
|
||||
padding: spacing.lg,
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user