Files
beenvoice-app/app/(app)/index.tsx
T
soconnor 06bc91ac13 Redesign mobile time clock, add shortcuts, and improve account management.
Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable
client picker with last-client defaults, account refresh/remove, and softer
session handling on unauthorized API responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:06:17 -04:00

395 lines
12 KiB
TypeScript

import { router } from "expo-router";
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import { Screen } from "@/components/Screen";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { AppBackground } from "@/components/AppBackground";
import { PageHeader } from "@/components/PageHeader";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatCard } from "@/components/StatCard";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, radii, 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 } from "@/lib/invoice-status";
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
export default function DashboardScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createDashboardStyles);
const statsQuery = api.dashboard.getStats.useQuery();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const runningElapsed = useRunningElapsed(runningQuery.data?.startedAt);
if (statsQuery.isLoading) {
return <LoadingScreen message="Loading dashboard…" />;
}
if (statsQuery.error) {
return (
<AppBackground>
<Screen>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load dashboard</Text>
<Text style={styles.errorText}>{statsQuery.error.message}</Text>
</View>
</Screen>
</AppBackground>
);
}
const stats = statsQuery.data;
if (!stats) {
return <LoadingScreen message="Loading dashboard…" />;
}
const running = runningQuery.data;
const revenueChange =
stats.revenueChange > 0
? `+${stats.revenueChange.toFixed(0)}% vs last month`
: stats.revenueChange < 0
? `${stats.revenueChange.toFixed(0)}% vs last month`
: "No change vs last month";
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
const sendReminderDue = stats.sendReminderDue ?? [];
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader title="Overview" subtitle="Your invoicing at a glance" />
}
refreshControl={
<RefreshControl
refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
onRefresh={() => {
void statsQuery.refetch();
void runningQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
{running ? (
<Pressable onPress={() => router.push("/(app)/timer")}>
<GlassSurface style={styles.runningGlass}>
<View style={styles.runningRow}>
<View style={styles.runningDot} />
<View style={styles.runningMeta}>
<Text style={styles.runningTitle}>
{resolveClockDescription(running.description)}
</Text>
<Text style={styles.runningSub}>
{running.client?.name ?? "No client"}
{running.invoice
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: ""}
</Text>
</View>
<Text style={styles.runningTime}>
{formatElapsedHoursMinutes(runningElapsed)}
</Text>
</View>
</GlassSurface>
</Pressable>
) : null}
{stats.overdueCount > 0 ? (
<GlassSurface style={styles.alertGlass}>
<View style={styles.alertBanner}>
<Text style={styles.alertTitle}>
{stats.overdueCount} overdue {stats.overdueCount === 1 ? "invoice" : "invoices"}
</Text>
<Text style={styles.alertText}>
Follow up on outstanding payments from the Invoices tab.
</Text>
</View>
</GlassSurface>
) : null}
{sendReminderDue.length > 0 ? (
<GlassSurface style={styles.alertGlass}>
<View style={styles.alertBanner}>
<Text style={styles.alertTitle}>
{sendReminderDue.length} draft{" "}
{sendReminderDue.length === 1 ? "invoice" : "invoices"} ready to send
</Text>
<Text style={styles.alertText}>
{sendReminderDue
.slice(0, 2)
.map(
(inv) =>
`${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.client?.name ?? "Client"})`,
)
.join(" · ")}
</Text>
</View>
</GlassSurface>
) : null}
<View style={styles.quickActions}>
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
<Button
title="View invoices"
variant="secondary"
onPress={() => router.push("/(app)/invoices")}
/>
</View>
<View style={styles.statsGrid}>
<View style={styles.statCell}>
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
</View>
<View style={styles.statCell}>
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
</View>
<View style={styles.statCell}>
<StatCard
label="Overdue"
value={String(stats.overdueCount)}
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
/>
</View>
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
<StatCard
label="Clients"
value={String(stats.totalClients)}
hint={revenueChange}
/>
</Pressable>
</View>
<Card title="Revenue (6 months)">
<View style={styles.chart}>
{stats.revenueChartData.map((point) => {
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
return (
<View key={point.month} style={styles.chartColumn}>
<View style={styles.chartBarTrack}>
<View style={[styles.chartBar, { height: barHeight }]} />
</View>
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
<Text style={styles.chartValue}>
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
</Text>
</View>
);
})}
</View>
</Card>
<Card title="Recent invoices">
{stats.recentInvoices.length === 0 ? (
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
) : (
stats.recentInvoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
return (
<Pressable
key={invoice.id}
style={styles.invoiceRow}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
>
<View style={styles.invoiceMeta}>
<Text style={styles.invoiceTitle}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.invoiceClient}>
{invoice.client?.name ?? "Client"}
</Text>
<Text style={styles.invoiceDate}>{formatDate(invoice.issueDate)}</Text>
</View>
<View style={styles.invoiceRight}>
<Text style={styles.invoiceAmount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
<StatusBadge status={status} />
</View>
</Pressable>
);
})
)}
</Card>
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
safe: {
flex: 1,
},
runningGlass: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "#BBF7D0",
},
runningRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
padding: spacing.md,
},
runningDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.success,
},
runningMeta: {
flex: 1,
gap: 2,
},
runningTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
runningSub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
runningTime: {
fontFamily: fonts.mono,
fontSize: 18,
color: colors.success,
},
alertBanner: {
padding: spacing.md,
gap: 4,
},
alertGlass: {
borderColor: isDark ? "rgba(251, 191, 36, 0.4)" : "#FDE68A",
},
alertTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.warning,
fontSize: 14,
},
alertText: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 13,
},
quickActions: {
flexDirection: "row",
gap: spacing.sm,
},
statsGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.md,
alignContent: "flex-start",
},
statCell: {
flexGrow: 0,
flexShrink: 0,
flexBasis: "47%",
},
chart: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.xs,
},
chartColumn: {
flex: 1,
alignItems: "center",
gap: 4,
},
chartBarTrack: {
width: "100%",
height: 80,
justifyContent: "flex-end",
alignItems: "center",
},
chartBar: {
width: "70%",
minHeight: 4,
backgroundColor: colors.primary,
borderRadius: radii.sm,
},
chartLabel: {
fontSize: 10,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
},
chartValue: {
fontSize: 9,
fontFamily: fonts.body,
color: colors.mutedForeground,
textAlign: "center",
},
empty: {
color: colors.mutedForeground,
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
invoiceRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
invoiceMeta: {
flex: 1,
gap: 2,
},
invoiceTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
invoiceClient: {
color: colors.mutedForeground,
fontSize: 14,
fontFamily: fonts.body,
},
invoiceDate: {
color: colors.mutedForeground,
fontSize: 12,
fontFamily: fonts.body,
},
invoiceRight: {
alignItems: "flex-end",
gap: spacing.sm,
},
invoiceAmount: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
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,
},
});