Files
beenvoice-app/app/(app)/invoices/index.tsx
T

341 lines
11 KiB
TypeScript

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 { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
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 { formatTrpcErrorMessage } from "@/lib/trpc-errors";
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),
});
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
utils.invoices.getAll.invalidate();
utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Delete 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}>{formatTrpcErrorMessage(invoicesQuery.error)}</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" },
]);
}
function confirmDelete(invoiceId: string, label: string) {
Alert.alert("Delete invoice?", `Remove ${label}? This cannot be undone.`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteInvoice.mutate({ id: invoiceId }),
},
]);
}
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}>
Tap + to create your first invoice, or pull to refresh.
</Text>
</View>
) : (
invoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
const label = `${invoice.invoicePrefix}${invoice.invoiceNumber}`;
const actions = [
{
key: "open",
label: "Open",
icon: "open-outline" as const,
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/invoices/${invoice.id}`),
},
...(status === "draft"
? [
{
key: "edit",
label: "Edit",
icon: "create-outline" as const,
color: "#fff",
backgroundColor: colors.mutedForeground,
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
},
{
key: "send",
label: "Send",
icon: "send-outline" as const,
color: "#fff",
backgroundColor: colors.success,
onPress: () => router.push(`/(app)/invoices/send/${invoice.id}`),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline" as const,
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => confirmDelete(invoice.id, label),
},
]
: [
{
key: "status",
label: "Status",
icon: "flag-outline" as const,
color: "#fff",
backgroundColor: colors.warning,
onPress: () => promptStatusChange(invoice.id, status),
},
]),
];
return (
<SwipeableRow key={invoice.id} actions={actions} backgroundColor={colors.cardGlass}>
<Pressable
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}>{label}</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>
</SwipeableRow>
);
})
)}
</TabScrollView>
<FloatingActionButton
accessibilityLabel="Create invoice"
onPress={() => {
Alert.alert("Create invoice", "Choose how to start", [
{ text: "Cancel", style: "cancel" },
{
text: "With line items",
onPress: () => router.push("/(app)/invoices/new"),
},
{
text: "Blank (for timer)",
onPress: () => router.push("/(app)/invoices/new?blank=1"),
},
]);
}}
/>
</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,
},
});