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>
261 lines
7.8 KiB
TypeScript
261 lines
7.8 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 { 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}>
|
|
Tap + to create your first invoice, or pull to refresh.
|
|
</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>
|
|
<FloatingActionButton
|
|
accessibilityLabel="Create invoice"
|
|
onPress={() => router.push("/(app)/invoices/new")}
|
|
/>
|
|
</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,
|
|
},
|
|
});
|