14c880123c
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>
359 lines
11 KiB
TypeScript
359 lines
11 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 } 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);
|
|
|
|
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}>
|
|
{running.description || "Timer running"}
|
|
</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}
|
|
|
|
<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}>
|
|
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
|
|
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
|
<StatCard
|
|
label="Overdue"
|
|
value={String(stats.overdueCount)}
|
|
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
|
|
/>
|
|
<StatCard label="Clients" value={String(stats.totalClients)} hint={revenueChange} />
|
|
</View>
|
|
|
|
<Card title="Revenue (6 months)">
|
|
<View style={styles.chart}>
|
|
{stats.revenueChartData.map((point) => (
|
|
<View key={point.month} style={styles.chartColumn}>
|
|
<View style={styles.chartBarTrack}>
|
|
<View
|
|
style={[
|
|
styles.chartBar,
|
|
{ height: `${Math.max(8, (point.revenue / maxRevenue) * 100)}%` },
|
|
]}
|
|
/>
|
|
</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 on the web app.</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,
|
|
},
|
|
chart: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
gap: spacing.xs,
|
|
minHeight: 140,
|
|
},
|
|
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,
|
|
},
|
|
});
|