613 lines
20 KiB
TypeScript
613 lines
20 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { router } from "expo-router";
|
|
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
|
|
|
|
import { AppBackground } from "@/components/AppBackground";
|
|
import { GlassSurface } from "@/components/GlassSurface";
|
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
|
import { PageHeader } from "@/components/PageHeader";
|
|
import { Screen } from "@/components/Screen";
|
|
import { StatCard } from "@/components/StatCard";
|
|
import { StatusBadge } from "@/components/StatusBadge";
|
|
import { TabPage } from "@/components/TabPage";
|
|
import { TabScrollView } from "@/components/TabScrollView";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { fonts, radii, spacing } from "@/constants/theme";
|
|
import { useSession } from "@/contexts/AuthContext";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { formatCurrency, formatDate } from "@/lib/format";
|
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
|
import type { ThemeColors } from "@/lib/theme-palette";
|
|
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
|
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
|
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
|
import { api } from "@/lib/trpc";
|
|
|
|
type ActionItem = {
|
|
key: string;
|
|
title: string;
|
|
detail: string;
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
tone: "warning" | "primary" | "success";
|
|
onPress: () => void;
|
|
};
|
|
|
|
export default function DashboardScreen() {
|
|
const { colors } = useAppTheme();
|
|
const styles = useThemedStyles(createDashboardStyles);
|
|
const { data: session } = useSession();
|
|
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 home…" />;
|
|
}
|
|
|
|
if (statsQuery.error) {
|
|
return (
|
|
<AppBackground>
|
|
<Screen>
|
|
<View style={styles.errorBox}>
|
|
<Text style={styles.errorTitle}>Could not load home</Text>
|
|
<Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text>
|
|
</View>
|
|
</Screen>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
const stats = statsQuery.data;
|
|
if (!stats) {
|
|
return <LoadingScreen message="Loading home…" />;
|
|
}
|
|
|
|
const now = new Date();
|
|
const running = runningQuery.data;
|
|
const runningClient = running?.client?.name ?? "No client";
|
|
const monthInvoices = stats.monthInvoices ?? [];
|
|
const monthTotal = monthInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
|
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
|
|
const drafts = stats.recentInvoices.filter((invoice) => invoice.status === "draft");
|
|
const pendingInvoices = monthInvoices.filter((invoice) => {
|
|
const status = getInvoiceStatus(invoice);
|
|
return status === "sent" || status === "overdue";
|
|
});
|
|
const overdueInvoices = monthInvoices.filter((invoice) => getInvoiceStatus(invoice) === "overdue");
|
|
const displayName = session?.user.name?.trim();
|
|
const firstName =
|
|
(displayName ? displayName.split(/\s+/)[0] : undefined) ??
|
|
session?.user.email?.split("@")[0] ??
|
|
"there";
|
|
|
|
const actionItems: ActionItem[] = [
|
|
...(running
|
|
? [
|
|
{
|
|
key: "running",
|
|
title: "Timer running",
|
|
detail: `${formatElapsedHoursMinutes(runningElapsed)} on ${runningClient}`,
|
|
icon: "timer-outline" as const,
|
|
tone: "success" as const,
|
|
onPress: () => router.push("/(app)/timer"),
|
|
},
|
|
]
|
|
: []),
|
|
...(overdueInvoices.length > 0
|
|
? [
|
|
{
|
|
key: "overdue",
|
|
title: `${overdueInvoices.length} overdue ${overdueInvoices.length === 1 ? "invoice" : "invoices"}`,
|
|
detail: `${formatCurrency(overdueInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0))} needs follow-up`,
|
|
icon: "alert-circle-outline" as const,
|
|
tone: "warning" as const,
|
|
onPress: () => router.push("/(app)/invoices"),
|
|
},
|
|
]
|
|
: []),
|
|
...(drafts.length > 0
|
|
? [
|
|
{
|
|
key: "drafts",
|
|
title: `${drafts.length} draft ${drafts.length === 1 ? "invoice" : "invoices"}`,
|
|
detail: "Review and send when ready",
|
|
icon: "document-text-outline" as const,
|
|
tone: "primary" as const,
|
|
onPress: () => router.push("/(app)/invoices"),
|
|
},
|
|
]
|
|
: []),
|
|
...(pendingInvoices.length > 0
|
|
? [
|
|
{
|
|
key: "pending",
|
|
title: `${pendingInvoices.length} awaiting payment`,
|
|
detail: `${formatCurrency(pendingInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0))} outstanding this month`,
|
|
icon: "card-outline" as const,
|
|
tone: "primary" as const,
|
|
onPress: () => router.push("/(app)/invoices"),
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
|
|
if (actionItems.length === 0) {
|
|
actionItems.push({
|
|
key: "clear",
|
|
title: "No urgent action items",
|
|
detail: "You are clear for the moment",
|
|
icon: "checkmark-circle-outline",
|
|
tone: "success",
|
|
onPress: () => router.push("/(app)/invoices"),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<AppBackground>
|
|
<TabPage>
|
|
<TabScrollView
|
|
header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
|
|
onRefresh={() => {
|
|
void statsQuery.refetch();
|
|
void runningQuery.refetch();
|
|
}}
|
|
tintColor={colors.primary}
|
|
/>
|
|
}
|
|
>
|
|
<View style={styles.quickActions}>
|
|
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
|
|
<Button
|
|
title="Invoices"
|
|
variant="secondary"
|
|
onPress={() => router.push("/(app)/invoices")}
|
|
/>
|
|
<Button
|
|
title="Reports"
|
|
variant="secondary"
|
|
onPress={() => router.push("/(app)/more/reports" as never)}
|
|
/>
|
|
</View>
|
|
|
|
<Card title="Action items">
|
|
<View style={styles.actionList}>
|
|
{actionItems.map((item) => (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
key={item.key}
|
|
onPress={item.onPress}
|
|
style={({ pressed }) => [styles.actionRow, pressed && styles.pressed]}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.actionIcon,
|
|
{
|
|
backgroundColor:
|
|
item.tone === "warning"
|
|
? colors.warningBg
|
|
: item.tone === "success"
|
|
? colors.successBg
|
|
: colors.muted,
|
|
},
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name={item.icon}
|
|
size={20}
|
|
color={
|
|
item.tone === "warning"
|
|
? colors.warning
|
|
: item.tone === "success"
|
|
? colors.success
|
|
: colors.primary
|
|
}
|
|
/>
|
|
</View>
|
|
<View style={styles.actionCopy}>
|
|
<Text style={styles.actionTitle}>{item.title}</Text>
|
|
<Text style={styles.actionDetail}>{item.detail}</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</Card>
|
|
|
|
{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}>
|
|
{runningClient}
|
|
{running.invoice
|
|
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
|
: ""}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.runningTime}>
|
|
{formatElapsedHoursMinutes(runningElapsed)}
|
|
</Text>
|
|
</View>
|
|
</GlassSurface>
|
|
</Pressable>
|
|
) : null}
|
|
|
|
<Card
|
|
title={now.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
})}
|
|
>
|
|
<View style={styles.monthSummary}>
|
|
<View>
|
|
<Text style={styles.monthValue}>{formatCurrency(monthTotal)}</Text>
|
|
<Text style={styles.monthLabel}>
|
|
{monthInvoices.length} {monthInvoices.length === 1 ? "invoice" : "invoices"} this month
|
|
</Text>
|
|
</View>
|
|
<Button
|
|
title="New invoice"
|
|
variant="secondary"
|
|
onPress={() => router.push("/(app)/invoices/new")}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.monthList}>
|
|
{monthInvoices.slice(0, 4).map((invoice) => {
|
|
const status = getInvoiceStatus(invoice);
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
key={invoice.id}
|
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
|
style={({ pressed }) => [styles.monthInvoiceRow, pressed && styles.pressed]}
|
|
>
|
|
<View style={styles.invoiceMeta}>
|
|
<Text style={styles.invoiceTitle}>
|
|
{invoice.invoicePrefix}
|
|
{invoice.invoiceNumber}
|
|
</Text>
|
|
<Text style={styles.invoiceClient}>{invoice.client?.name ?? "Client"}</Text>
|
|
</View>
|
|
<View style={styles.invoiceRight}>
|
|
<Text style={styles.invoiceAmount}>
|
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
|
</Text>
|
|
<StatusBadge status={status} />
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
{monthInvoices.length === 0 ? (
|
|
<Text style={styles.empty}>No invoices in this month yet.</Text>
|
|
) : null}
|
|
</View>
|
|
</Card>
|
|
|
|
{stats.currentDraft ? (
|
|
<GlassSurface style={styles.draftGlass}>
|
|
<Pressable
|
|
style={styles.draftBanner}
|
|
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
|
|
>
|
|
<View style={styles.draftCopy}>
|
|
<Text style={styles.draftTitle}>Current draft</Text>
|
|
<Text style={styles.draftText}>
|
|
{stats.currentDraft.client?.name ?? "Client"} ·{" "}
|
|
{formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
|
|
{stats.currentDraft.totalHours.toFixed(1)}h logged
|
|
</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
|
</Pressable>
|
|
</GlassSurface>
|
|
) : null}
|
|
|
|
<View style={styles.statsGrid}>
|
|
<View style={styles.statCell}>
|
|
<StatCard label="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)} />
|
|
</View>
|
|
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
|
|
<StatCard label="Clients" value={String(stats.totalClients)} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
<Card title="Revenue trend">
|
|
<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>
|
|
</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={({ pressed }) => [styles.recentRow, pressed && styles.pressed]}
|
|
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({
|
|
actionList: {
|
|
gap: spacing.xs,
|
|
},
|
|
actionRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.md,
|
|
minHeight: 58,
|
|
paddingVertical: spacing.xs,
|
|
},
|
|
actionIcon: {
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: radii.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
actionCopy: {
|
|
flex: 1,
|
|
gap: 2,
|
|
},
|
|
actionTitle: {
|
|
color: colors.foreground,
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 15,
|
|
lineHeight: 20,
|
|
},
|
|
actionDetail: {
|
|
color: colors.mutedForeground,
|
|
fontFamily: fonts.body,
|
|
fontSize: 12,
|
|
lineHeight: 16,
|
|
},
|
|
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,
|
|
},
|
|
monthSummary: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: spacing.md,
|
|
},
|
|
monthValue: {
|
|
color: colors.foreground,
|
|
fontFamily: fonts.heading,
|
|
fontSize: 24,
|
|
lineHeight: 30,
|
|
},
|
|
monthLabel: {
|
|
color: colors.mutedForeground,
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 12,
|
|
lineHeight: 16,
|
|
},
|
|
monthList: {
|
|
gap: spacing.xs,
|
|
},
|
|
monthInvoiceRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: spacing.md,
|
|
paddingTop: spacing.sm,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
},
|
|
quickActions: {
|
|
flexDirection: "row",
|
|
gap: spacing.sm,
|
|
},
|
|
draftGlass: {
|
|
borderColor: isDark ? "rgba(59, 130, 246, 0.32)" : "#BFDBFE",
|
|
},
|
|
draftBanner: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.md,
|
|
padding: spacing.md,
|
|
},
|
|
draftCopy: {
|
|
flex: 1,
|
|
gap: 3,
|
|
},
|
|
draftTitle: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
color: colors.primary,
|
|
fontSize: 14,
|
|
},
|
|
draftText: {
|
|
fontFamily: fonts.body,
|
|
color: colors.mutedForeground,
|
|
fontSize: 13,
|
|
lineHeight: 18,
|
|
},
|
|
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,
|
|
},
|
|
empty: {
|
|
color: colors.mutedForeground,
|
|
fontSize: 14,
|
|
fontFamily: fonts.body,
|
|
lineHeight: 20,
|
|
},
|
|
recentRow: {
|
|
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,
|
|
},
|
|
pressed: {
|
|
opacity: 0.85,
|
|
},
|
|
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,
|
|
},
|
|
});
|