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 ;
}
if (statsQuery.error) {
return (
Could not load dashboard
{statsQuery.error.message}
);
}
const stats = statsQuery.data;
if (!stats) {
return ;
}
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 (
}
refreshControl={
{
void statsQuery.refetch();
void runningQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
{running ? (
router.push("/(app)/timer")}>
{resolveClockDescription(running.description)}
{running.client?.name ?? "No client"}
{running.invoice
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: ""}
{formatElapsedHoursMinutes(runningElapsed)}
) : null}
{stats.overdueCount > 0 ? (
{stats.overdueCount} overdue {stats.overdueCount === 1 ? "invoice" : "invoices"}
Follow up on outstanding payments from the Invoices tab.
) : null}
{sendReminderDue.length > 0 ? (
{sendReminderDue.length} draft{" "}
{sendReminderDue.length === 1 ? "invoice" : "invoices"} ready to send
{sendReminderDue
.slice(0, 2)
.map(
(inv) =>
`${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.client?.name ?? "Client"})`,
)
.join(" · ")}
) : null}
router.push("/(app)/entities")}>
{stats.revenueChartData.map((point) => {
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
return (
{point.monthLabel}
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
);
})}
{stats.recentInvoices.length === 0 ? (
No invoices yet. Create one from the Invoices tab.
) : (
stats.recentInvoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
return (
router.push(`/(app)/invoices/${invoice.id}`)}
>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
{invoice.client?.name ?? "Client"}
{formatDate(invoice.issueDate)}
{formatCurrency(invoice.totalAmount, invoice.currency)}
);
})
)}
);
}
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,
},
});