diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx
index ea94674..1438ab0 100644
--- a/app/(app)/_layout.tsx
+++ b/app/(app)/_layout.tsx
@@ -37,10 +37,10 @@ export default function AppLayout() {
>
- Dashboard
+ Home
@@ -67,15 +67,7 @@ export default function AppLayout() {
Invoices
-
-
- Settings
-
-
-
+
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,
@@ -33,7 +45,7 @@ export default function DashboardScreen() {
const runningElapsed = useRunningElapsed(runningQuery.data?.startedAt);
if (statsQuery.isLoading) {
- return ;
+ return ;
}
if (statsQuery.error) {
@@ -41,7 +53,7 @@ export default function DashboardScreen() {
- Could not load dashboard
+ Could not load home
{formatTrpcErrorMessage(statsQuery.error)}
@@ -51,27 +63,94 @@ export default function DashboardScreen() {
const stats = statsQuery.data;
if (!stats) {
- return ;
+ return ;
}
+ const now = new Date();
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 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 sendReminderDue = stats.recentInvoices.filter((inv) => inv.status === "draft");
+ 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 (
- }
+ header={}
refreshControl={
}
>
+
+
+
+
+
+ {actionItems.map((item) => (
+ [styles.actionRow, pressed && styles.pressed]}
+ >
+
+
+
+
+ {item.title}
+ {item.detail}
+
+
+
+ ))}
+
+
+
{running ? (
router.push("/(app)/timer")}>
@@ -93,7 +230,7 @@ export default function DashboardScreen() {
{resolveClockDescription(running.description)}
- {running.client?.name ?? "No client"}
+ {runningClient}
{running.invoice
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: ""}
@@ -107,95 +244,93 @@ export default function DashboardScreen() {
) : null}
- {stats.overdueCount > 0 ? (
-
-
-
- {stats.overdueCount} overdue {stats.overdueCount === 1 ? "invoice" : "invoices"}
-
-
- Follow up on outstanding payments from the Invoices tab.
+
+
+
+ {formatCurrency(monthTotal)}
+
+ {monthInvoices.length} {monthInvoices.length === 1 ? "invoice" : "invoices"} this month
-
- ) : null}
+ router.push("/(app)/invoices/new")}
+ />
+
- {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)/timer")} />
- router.push("/(app)/invoices")}
- />
- router.push("/(app)/more/reports" as never)}
- />
-
+
+ {monthInvoices.slice(0, 4).map((invoice) => {
+ const status = getInvoiceStatus(invoice);
+ return (
+ router.push(`/(app)/invoices/${invoice.id}`)}
+ style={({ pressed }) => [styles.monthInvoiceRow, pressed && styles.pressed]}
+ >
+
+
+ {invoice.invoicePrefix}
+ {invoice.invoiceNumber}
+
+ {invoice.client?.name ?? "Client"}
+
+
+
+ {formatCurrency(invoice.totalAmount, invoice.currency)}
+
+
+
+
+ );
+ })}
+ {monthInvoices.length === 0 ? (
+ No invoices in this month yet.
+ ) : null}
+
+
{stats.currentDraft ? (
-
+
router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
>
-
- Draft {stats.currentDraft.invoiceNumber}
-
-
- {stats.currentDraft.client?.name ?? "Client"} ·{" "}
- {formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
- {stats.currentDraft.totalHours.toFixed(1)}h logged
-
+
+ Current draft
+
+ {stats.currentDraft.client?.name ?? "Client"} ·{" "}
+ {formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
+ {stats.currentDraft.totalHours.toFixed(1)}h logged
+
+
+
) : null}
-
+
-
+
router.push("/(app)/entities")}>
-
+
-
+
{stats.revenueChartData.map((point) => {
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
@@ -205,26 +340,12 @@ export default function DashboardScreen() {
{point.monthLabel}
-
- {point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
-
);
})}
-
- {(stats.statusChartData ?? []).map((item) => (
-
- {item.name}
-
- {item.count} · {formatCurrency(item.value)}
-
-
- ))}
-
-
{stats.recentInvoices.length === 0 ? (
No invoices yet. Create one from the Invoices tab.
@@ -234,7 +355,7 @@ export default function DashboardScreen() {
return (
[styles.recentRow, pressed && styles.pressed]}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
>
@@ -242,9 +363,7 @@ export default function DashboardScreen() {
{invoice.invoicePrefix}
{invoice.invoiceNumber}
-
- {invoice.client?.name ?? "Client"}
-
+ {invoice.client?.name ?? "Client"}
{formatDate(invoice.issueDate)}
@@ -266,164 +385,228 @@ export default function DashboardScreen() {
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,
- },
-});
+ 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,
+ },
+ });
diff --git a/app/(app)/more/expenses/[id].tsx b/app/(app)/more/expenses/[id].tsx
index a30e5ab..3a7e863 100644
--- a/app/(app)/more/expenses/[id].tsx
+++ b/app/(app)/more/expenses/[id].tsx
@@ -1,6 +1,6 @@
import { useLocalSearchParams, router } from "expo-router";
import { useState } from "react";
-import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
+import { Alert, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
@@ -12,6 +12,7 @@ import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
+import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
@@ -119,7 +120,7 @@ export default function ExpenseDetailScreen() {
"Receipt attached",
result.items.length > 0
? "Select the owed items, apply the split amount, then save the expense."
- : "OCR filled the fields below. Save to update this expense.",
+ : "We filled in what we could. Review and save to update this expense.",
);
} finally {
setScanning(false);
@@ -163,10 +164,12 @@ export default function ExpenseDetailScreen() {
if (!expense) {
return (
-
-
- Expense not found
-
+
+ }>
+
+ Expense not found
+
+
);
@@ -174,13 +177,16 @@ export default function ExpenseDetailScreen() {
return (
-
-
-
-
+
+
+ }
+ keyboardShouldPersistTaps="handled"
+ >
{editing ? (
<>
{receiptSplit ? (
@@ -316,22 +322,13 @@ export default function ExpenseDetailScreen() {
onPress={() => void attachAndScan(false)}
/>
- router.back()}
- />
-
+
);
}
const styles = StyleSheet.create({
- body: {
- padding: spacing.lg,
- gap: spacing.md,
- },
amount: {
fontSize: 28,
fontWeight: "600",
@@ -362,7 +359,7 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
},
receiptRow: {
- paddingVertical: spacing.sm,
+ padding: spacing.md,
fontSize: 14,
},
actions: {
@@ -408,8 +405,10 @@ function mergeNotes(prefix: string, existing: string) {
const trimmed = existing.trim();
if (!trimmed) return prefix;
if (trimmed.startsWith("Receipt split")) {
- const ocrStart = trimmed.indexOf("\n\nOCR text:");
- return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
+ const detailsStart = trimmed.indexOf("\n\nReceipt details:");
+ const legacyStart = trimmed.indexOf("\n\nOCR text:");
+ const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
+ return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
}
- return `${prefix}\n\nOCR text:\n${trimmed}`;
+ return `${prefix}\n\nReceipt details:\n${trimmed}`;
}
diff --git a/app/(app)/more/expenses/index.tsx b/app/(app)/more/expenses/index.tsx
index 4f3b876..190ddbc 100644
--- a/app/(app)/more/expenses/index.tsx
+++ b/app/(app)/more/expenses/index.tsx
@@ -1,3 +1,4 @@
+import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import {
@@ -8,6 +9,8 @@ import {
Text,
View,
} from "react-native";
+import type { AppRouter } from "beenvoice/server/api/root";
+import type { inferRouterOutputs } from "@trpc/server";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
@@ -17,15 +20,16 @@ import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
-import { fonts, spacing } from "@/constants/theme";
+import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
+import { api } from "@/lib/trpc";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
-import { api } from "@/lib/trpc";
type ExpenseFilter = "all" | "billable" | "receipts";
+type Expense = inferRouterOutputs["expenses"]["getAll"][number];
export default function ExpensesScreen() {
const { colors } = useAppTheme();
@@ -48,24 +52,36 @@ export default function ExpensesScreen() {
}),
[expenses, filter],
);
- const total = filteredExpenses.reduce(
- (sum, expense) => sum + expense.amount,
- 0,
- );
- const receiptCount = filteredExpenses.reduce(
- (sum, expense) => sum + (expense.receiptCount ?? 0),
- 0,
+ const summary = useMemo(() => {
+ const total = filteredExpenses.reduce(
+ (sum, expense) => sum + expense.amount,
+ 0,
+ );
+ const billable = filteredExpenses.reduce(
+ (sum, expense) => sum + (expense.billable ? expense.amount : 0),
+ 0,
+ );
+ const receiptCount = filteredExpenses.reduce(
+ (sum, expense) => sum + (expense.receiptCount ?? 0),
+ 0,
+ );
+ return { total, billable, receiptCount };
+ }, [filteredExpenses]);
+ const groupedExpenses = useMemo(
+ () => groupExpensesByMonth(filteredExpenses),
+ [filteredExpenses],
);
if (expensesQuery.isLoading) {
- return ;
+ return ;
}
if (expensesQuery.error) {
return (
-
+
+
Could not load expenses
@@ -80,19 +96,19 @@ export default function ExpensesScreen() {
return (
-
+
+
router.push("/(app)/more/expenses/new" as never)}
/>
- >
+
}
refreshControl={
{expenses.length === 0 ? (
-
- No expenses yet. Add one with a receipt photo or manual entry.
-
+
+
+
+
+
+ No expenses yet
+
+
+ Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
+
+ router.push("/(app)/more/expenses/new" as never)}
+ />
+
) : (
<>
-
-
-
- Visible total
-
-
- {formatCurrency(total)}
-
-
-
-
- Receipts
-
-
- {receiptCount}
-
-
+
+
+
+
) : (
- filteredExpenses.map((expense) => (
-
- router.push(
- `/(app)/more/expenses/${expense.id}` as never,
- ),
- },
- {
- key: "delete",
- label: "Delete",
- icon: "trash-outline",
- color: "#fff",
- backgroundColor: colors.destructive,
- onPress: () => deleteExpense.mutate({ id: expense.id }),
- },
- ]}
- >
-
- router.push(
- `/(app)/more/expenses/${expense.id}` as never,
- )
- }
- style={({ pressed }) => [
- styles.row,
- pressed && styles.rowPressed,
- ]}
- >
-
-
-
- {expense.description}
-
- {expense.receiptCount ? (
-
- {expense.receiptCount}
-
- ) : null}
-
-
- {formatDate(expense.date)}
- {expense.category ? ` · ${expense.category}` : ""}
- {expense.client?.name
- ? ` · ${expense.client.name}`
- : ""}
- {expense.billable ? " · Billable" : ""}
-
-
-
- {formatCurrency(expense.amount, expense.currency)}
-
-
-
+ groupedExpenses.map(([monthLabel, group]) => (
+
+
+ {monthLabel}
+
+ {group.map((expense) => (
+ deleteExpense.mutate({ id: expense.id })}
+ />
+ ))}
+
))
)}
>
@@ -259,23 +197,171 @@ export default function ExpensesScreen() {
);
}
+function SummaryTile({ label, value }: { label: string; value: string }) {
+ const { colors } = useAppTheme();
+ const styles = useThemedStyles(createStyles);
+
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
+ const { colors } = useAppTheme();
+ const styles = useThemedStyles(createStyles);
+
+ return (
+ router.push(`/(app)/more/expenses/${expense.id}` as never),
+ },
+ {
+ key: "delete",
+ label: "Delete",
+ icon: "trash-outline",
+ color: "#fff",
+ backgroundColor: colors.destructive,
+ onPress: onDelete,
+ },
+ ]}
+ >
+ router.push(`/(app)/more/expenses/${expense.id}` as never)}
+ style={({ pressed }) => [
+ styles.row,
+ { borderColor: colors.border },
+ pressed && styles.rowPressed,
+ ]}
+ >
+
+
+
+
+
+
+ {expense.description}
+
+ {expense.receiptCount ? (
+
+
+
+ {expense.receiptCount}
+
+
+ ) : null}
+
+
+ {formatDate(expense.date)}
+ {expense.category ? ` · ${expense.category}` : ""}
+ {expense.client?.name ? ` · ${expense.client.name}` : ""}
+
+
+ {expense.billable ? (
+
+ Billable
+
+ ) : null}
+ {expense.reimbursable ? (
+
+ Reimbursable
+
+ ) : null}
+ {expense.taxDeductible ? (
+
+ Tax
+
+ ) : null}
+
+
+
+
+ {formatCurrency(expense.amount, expense.currency)}
+
+
+
+
+
+ );
+}
+
+function groupExpensesByMonth(expenses: Expense[]) {
+ const groups = new Map();
+ for (const expense of expenses) {
+ const date = new Date(expense.date);
+ const key = date.toLocaleDateString(undefined, {
+ month: "long",
+ year: "numeric",
+ });
+ const group = groups.get(key) ?? [];
+ group.push(expense);
+ groups.set(key, group);
+ }
+ return Array.from(groups.entries());
+}
+
+function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
+ const normalized = category?.toLowerCase() ?? "";
+ if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
+ if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline";
+ if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline";
+ if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline";
+ if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline";
+ return "receipt-outline";
+}
+
const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
+ header: {
+ gap: spacing.md,
+ },
row: {
flexDirection: "row",
alignItems: "center",
- justifyContent: "space-between",
gap: spacing.md,
- paddingVertical: spacing.md,
- borderBottomWidth: 1,
- borderBottomColor: colors.border,
+ paddingHorizontal: spacing.md,
+ paddingVertical: 12,
+ borderWidth: 1,
+ borderRadius: radii.lg,
},
rowPressed: {
opacity: 0.82,
},
+ categoryIcon: {
+ width: 40,
+ height: 40,
+ borderRadius: radii.md,
+ alignItems: "center",
+ justifyContent: "center",
+ },
meta: {
flex: 1,
- gap: 2,
+ minWidth: 0,
+ gap: spacing.xs,
},
titleRow: {
flexDirection: "row",
@@ -291,23 +377,42 @@ const createStyles = (colors: ThemeColors) =>
fontFamily: fonts.body,
fontSize: 13,
},
+ tagRow: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: spacing.xs,
+ },
+ tag: {
+ borderWidth: 1,
+ borderRadius: radii.pill,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 2,
+ fontFamily: fonts.bodyMedium,
+ fontSize: 11,
+ overflow: "hidden",
+ },
+ amountStack: {
+ alignItems: "flex-end",
+ gap: spacing.xs,
+ },
amount: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
fontVariant: ["tabular-nums"],
},
- summary: {
+ summaryGrid: {
flexDirection: "row",
- justifyContent: "space-between",
- gap: spacing.md,
- padding: spacing.md,
- borderRadius: 16,
- borderWidth: 1,
- borderColor: colors.border,
- backgroundColor: colors.cardGlass,
+ flexWrap: "wrap",
+ gap: spacing.xs,
},
- summaryRight: {
- alignItems: "flex-end",
+ summaryTile: {
+ flexGrow: 1,
+ flexBasis: "30%",
+ minWidth: 104,
+ paddingHorizontal: spacing.md,
+ paddingVertical: 12,
+ borderRadius: radii.lg,
+ borderWidth: 1,
},
summaryLabel: {
fontFamily: fonts.bodyMedium,
@@ -320,25 +425,56 @@ const createStyles = (colors: ThemeColors) =>
fontSize: 20,
fontVariant: ["tabular-nums"],
},
+ monthGroup: {
+ gap: 2,
+ },
+ monthLabel: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 12,
+ textTransform: "uppercase",
+ letterSpacing: 0,
+ paddingHorizontal: spacing.xs,
+ },
filters: {
gap: spacing.sm,
paddingRight: spacing.lg,
},
receiptPill: {
- minWidth: 26,
- textAlign: "center",
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 2,
borderWidth: 1,
- borderRadius: 999,
- paddingHorizontal: spacing.sm,
+ borderRadius: radii.pill,
+ paddingHorizontal: 7,
paddingVertical: 2,
+ },
+ receiptPillText: {
fontFamily: fonts.bodyMedium,
fontSize: 12,
- overflow: "hidden",
},
empty: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
+ textAlign: "center",
+ },
+ emptyCard: {
+ alignItems: "center",
+ gap: spacing.sm,
+ padding: spacing.lg,
+ borderWidth: 1,
+ borderRadius: radii.lg,
+ },
+ emptyIcon: {
+ width: 52,
+ height: 52,
+ borderRadius: radii.lg,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ emptyTitle: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 17,
},
errorBox: {
padding: spacing.lg,
diff --git a/app/(app)/more/expenses/new.tsx b/app/(app)/more/expenses/new.tsx
index 5faf2e2..a2833a9 100644
--- a/app/(app)/more/expenses/new.tsx
+++ b/app/(app)/more/expenses/new.tsx
@@ -1,6 +1,6 @@
import { router } from "expo-router";
import { useMemo, useState } from "react";
-import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
+import { Alert, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
@@ -11,10 +11,11 @@ import {
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
import { PageHeader } from "@/components/PageHeader";
import { TabPage } from "@/components/TabPage";
+import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
-import { spacing } from "@/constants/theme";
+import { Card } from "@/components/ui/Card";
+import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
-import { mlKitOcrAvailable } from "@/lib/receipt-ocr";
import {
scanReceiptImage,
type PickedReceiptImage,
@@ -96,7 +97,7 @@ export default function NewExpenseScreen() {
? "Select the items this person owes, then apply the split amount."
: result.amountText
? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.`
- : "Review the fields — OCR could not find a total automatically.",
+ : "Review the fields and enter the total before saving.",
);
} finally {
setScanning(false);
@@ -150,42 +151,44 @@ export default function NewExpenseScreen() {
return (
-
-
+
+ }
keyboardShouldPersistTaps="handled"
>
-
+
+
+ void runScan(true)}
+ />
+ void runScan(false)}
+ />
+
-
- void runScan(true)}
- />
- void runScan(false)}
- />
-
-
- {pendingReceipt ? (
-
- Receipt image ready — it will attach when you save.
-
- ) : null}
+ {pendingReceipt ? (
+
+
+ Receipt attached. Review the details below before saving.
+
+
+ ) : null}
+
{receiptSplit ? (
) : null}
-
+
+
+
- void handleSave()}
- />
- router.back()}
- />
-
+
+ void handleSave()}
+ />
+ router.back()}
+ />
+
+
);
}
const styles = StyleSheet.create({
- body: {
- padding: spacing.lg,
- gap: spacing.md,
- },
actions: {
flexDirection: "row",
gap: spacing.sm,
@@ -243,14 +248,29 @@ const styles = StyleSheet.create({
actionButton: {
flex: 1,
},
+ saveActions: {
+ gap: spacing.sm,
+ },
+ notice: {
+ borderRadius: 12,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ },
+ noticeText: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 13,
+ lineHeight: 18,
+ },
});
function mergeNotes(prefix: string, existing: string) {
const trimmed = existing.trim();
if (!trimmed) return prefix;
if (trimmed.startsWith("Receipt split")) {
- const ocrStart = trimmed.indexOf("\n\nOCR text:");
- return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
+ const detailsStart = trimmed.indexOf("\n\nReceipt details:");
+ const legacyStart = trimmed.indexOf("\n\nOCR text:");
+ const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
+ return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
}
- return `${prefix}\n\nOCR text:\n${trimmed}`;
+ return `${prefix}\n\nReceipt details:\n${trimmed}`;
}
diff --git a/app/(app)/more/index.tsx b/app/(app)/more/index.tsx
index 92461a0..0e447b9 100644
--- a/app/(app)/more/index.tsx
+++ b/app/(app)/more/index.tsx
@@ -43,6 +43,12 @@ const ITEMS: HubItem[] = [
href: "/(app)/more/time-entries",
icon: "time-outline",
},
+ {
+ title: "Settings",
+ subtitle: "Account, security, and app preferences",
+ href: "/(app)/more/settings",
+ icon: "settings-outline",
+ },
];
export default function MoreScreen() {
diff --git a/app/(app)/more/recurring.tsx b/app/(app)/more/recurring.tsx
index bf72104..f89a5d0 100644
--- a/app/(app)/more/recurring.tsx
+++ b/app/(app)/more/recurring.tsx
@@ -37,7 +37,7 @@ export default function RecurringScreen() {
return (
-
+
-
-
- {formatTrpcErrorMessage(statsQuery.error)}
-
+
+
+
+
+ {formatTrpcErrorMessage(statsQuery.error)}
+
+
);
@@ -41,7 +44,7 @@ export default function ReportsScreen() {
return (
-
+
}
refreshControl={
@@ -105,4 +108,8 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
paddingVertical: spacing.sm,
},
+ errorBox: {
+ padding: spacing.lg,
+ gap: spacing.md,
+ },
});
diff --git a/app/(app)/settings.tsx b/app/(app)/more/settings.tsx
similarity index 99%
rename from app/(app)/settings.tsx
rename to app/(app)/more/settings.tsx
index b83b773..9fa3c39 100644
--- a/app/(app)/settings.tsx
+++ b/app/(app)/more/settings.tsx
@@ -187,7 +187,7 @@ export default function SettingsScreen() {
return (
-
+
-
-
- {formatTrpcErrorMessage(entriesQuery.error)}
-
+
+
+
+
+ {formatTrpcErrorMessage(entriesQuery.error)}
+
+
);
@@ -65,7 +68,7 @@ export default function TimeEntriesScreen() {
return (
-
+
@@ -136,10 +139,14 @@ const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: spacing.md,
- paddingVertical: spacing.sm,
+ padding: spacing.md,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
+ errorBox: {
+ padding: spacing.lg,
+ gap: spacing.md,
+ },
});
diff --git a/components/AccountSwitcher.tsx b/components/AccountSwitcher.tsx
index 605e887..dc77eb8 100644
--- a/components/AccountSwitcher.tsx
+++ b/components/AccountSwitcher.tsx
@@ -1,4 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
+import { router } from "expo-router";
import { useState } from "react";
import {
ActivityIndicator,
@@ -82,6 +83,11 @@ export function AccountSwitcher() {
}
}
+ function handleOpenSettings() {
+ setOpen(false);
+ router.push("/(app)/more/settings" as never);
+ }
+
function handleRemove(accountId: string, label: string) {
confirmRemoveAccount(
label,
@@ -219,6 +225,22 @@ export function AccountSwitcher() {
Add account
+
+ [
+ styles.settingsRow,
+ { borderTopColor: colors.border },
+ pressed && styles.pressed,
+ ]}
+ >
+
+
+ Settings
+
+
+
@@ -333,6 +355,19 @@ const styles = StyleSheet.create({
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
+ settingsRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.md,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+ settingsLabel: {
+ flex: 1,
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 15,
+ },
pressed: {
opacity: 0.75,
},
diff --git a/components/ShortcutHandler.tsx b/components/ShortcutHandler.tsx
index 3e8fcce..6ec524d 100644
--- a/components/ShortcutHandler.tsx
+++ b/components/ShortcutHandler.tsx
@@ -105,23 +105,11 @@ export function ShortcutHandler() {
const clientId =
pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || "";
-
- if (!clientId) {
- await clearPendingShortcut();
- setPending(null);
- router.push("/(app)/timer");
- Alert.alert(
- "Choose a client",
- "Open the time clock and pick a client once — shortcuts will use it next time.",
- );
- return;
- }
-
const client = (clientsQuery.data ?? []).find((entry) => entry.id === clientId);
const rate = resolveEffectiveHourlyRate("", client?.defaultHourlyRate);
await clockIn.mutateAsync({
- clientId,
+ clientId: clientId || "",
description: resolveClockDescription(pending.title || DEFAULT_CLOCK_DESCRIPTION),
rate: rate ?? undefined,
});
diff --git a/components/SwipeableRow.tsx b/components/SwipeableRow.tsx
index 042dbfb..f686c9c 100644
--- a/components/SwipeableRow.tsx
+++ b/components/SwipeableRow.tsx
@@ -72,6 +72,9 @@ const createSwipeableRowStyles = (colors: ThemeColors) =>
StyleSheet.create({
row: {
backgroundColor: colors.background,
+ borderRadius: radii.lg,
+ overflow: "hidden",
+ marginBottom: spacing.xs,
},
actions: {
flexDirection: "row",
diff --git a/components/TabPage.tsx b/components/TabPage.tsx
index 440e5b8..bb56f8e 100644
--- a/components/TabPage.tsx
+++ b/components/TabPage.tsx
@@ -7,16 +7,17 @@ import { useAppTheme } from "@/contexts/ThemeContext";
type TabPageProps = {
children: ReactNode;
+ showMoreBack?: boolean;
};
/** Tab root — pinned top chrome, scrollable body below. */
-export function TabPage({ children }: TabPageProps) {
+export function TabPage({ children, showMoreBack = false }: TabPageProps) {
const { isDark } = useAppTheme();
return (
-
+
{children}
);
diff --git a/components/TabScrollView.tsx b/components/TabScrollView.tsx
index 0e7df29..8cf72f3 100644
--- a/components/TabScrollView.tsx
+++ b/components/TabScrollView.tsx
@@ -19,11 +19,15 @@ export function TabScrollView({
header,
children,
contentContainerStyle,
+ refreshControl,
style,
+ bounces,
+ alwaysBounceVertical,
...props
}: TabScrollViewProps) {
const scrollRef = useRef(null);
const bottomPadding = useTabScreenScrollPadding();
+ const canRefresh = Boolean(refreshControl);
useScrollToTop(scrollRef);
@@ -37,6 +41,12 @@ export function TabScrollView({
contentContainerStyle,
]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
+ automaticallyAdjustContentInsets={false}
+ automaticallyAdjustKeyboardInsets={false}
+ automaticallyAdjustsScrollIndicatorInsets={false}
+ bounces={bounces ?? canRefresh}
+ alwaysBounceVertical={alwaysBounceVertical ?? canRefresh}
+ refreshControl={refreshControl}
scrollIndicatorInsets={{ bottom: bottomPadding }}
{...props}
>
diff --git a/components/TopChrome.tsx b/components/TopChrome.tsx
index 2bed165..559e47a 100644
--- a/components/TopChrome.tsx
+++ b/components/TopChrome.tsx
@@ -1,18 +1,49 @@
+import { Ionicons } from "@expo/vector-icons";
+import { router } from "expo-router";
import { StyleSheet, View } from "react-native";
+import { Pressable, Text } from "react-native";
import { AccountSwitcher } from "@/components/AccountSwitcher";
import { Logo } from "@/components/Logo";
-import { spacing } from "@/constants/theme";
+import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
+type TopChromeProps = {
+ showMoreBack?: boolean;
+};
+
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
-export function TopChrome() {
- const { isDark } = useAppTheme();
+export function TopChrome({ showMoreBack = false }: TopChromeProps) {
+ const { colors, isDark } = useAppTheme();
+
+ function handleBack() {
+ if (router.canGoBack()) {
+ router.back();
+ return;
+ }
+ router.replace("/(app)/more" as never);
+ }
return (
-
+ {showMoreBack ? (
+ [
+ styles.backButton,
+ { borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
+ pressed && styles.pressed,
+ ]}
+ >
+
+ More
+
+ ) : (
+
+ )}
);
@@ -26,4 +57,20 @@ const styles = StyleSheet.create({
height: TOP_CHROME_ROW_HEIGHT,
paddingHorizontal: spacing.md,
},
+ backButton: {
+ minHeight: 36,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.xs,
+ paddingHorizontal: spacing.sm,
+ borderWidth: 1,
+ borderRadius: radii.pill,
+ },
+ backLabel: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 13,
+ },
+ pressed: {
+ opacity: 0.82,
+ },
});
diff --git a/components/TopChromeBar.tsx b/components/TopChromeBar.tsx
index eb3b7b9..5aa54bd 100644
--- a/components/TopChromeBar.tsx
+++ b/components/TopChromeBar.tsx
@@ -10,8 +10,12 @@ import {
TOP_CHROME_ROW_HEIGHT,
} from "@/lib/top-chrome-insets";
+type TopChromeBarProps = {
+ showMoreBack?: boolean;
+};
+
/** Blurred status-bar chrome with logo + account switcher. */
-export function TopChromeBar() {
+export function TopChromeBar({ showMoreBack = false }: TopChromeBarProps) {
const insets = useSafeAreaInsets();
const { isDark } = useAppTheme();
const tint = isDark ? "rgba(9, 9, 11, 0.28)" : "rgba(255, 255, 255, 0.32)";
@@ -38,7 +42,7 @@ export function TopChromeBar() {
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: tint }]}
/>
-
+
);
}
diff --git a/components/expenses/ExpenseFormFields.tsx b/components/expenses/ExpenseFormFields.tsx
index 8bfda5c..da943ba 100644
--- a/components/expenses/ExpenseFormFields.tsx
+++ b/components/expenses/ExpenseFormFields.tsx
@@ -54,7 +54,7 @@ export function ExpenseFormFields({
clients,
onChange,
notesLabel = "Notes",
- notesPlaceholder = "Internal details or receipt OCR text",
+ notesPlaceholder = "Internal details",
}: ExpenseFormFieldsProps) {
const { colors } = useAppTheme();
@@ -172,7 +172,15 @@ export function ExpenseFormFields({
onValueChange: (value: boolean) => void;
}) {
return (
-
+
{label}
diff --git a/components/expenses/ReceiptItemSelector.tsx b/components/expenses/ReceiptItemSelector.tsx
index ee140cc..5cf84b8 100644
--- a/components/expenses/ReceiptItemSelector.tsx
+++ b/components/expenses/ReceiptItemSelector.tsx
@@ -80,7 +80,12 @@ export function ReceiptItemSelector({
};
return (
-
+
@@ -140,7 +145,7 @@ export function ReceiptItemSelector({
onPress={() => toggle(item.id)}
style={({ pressed }) => [
styles.item,
- { borderColor: colors.borderGlass },
+ { borderColor: colors.borderGlass, backgroundColor: colors.background },
selected && { backgroundColor: colors.muted },
pressed && styles.pressed,
]}
diff --git a/components/time-clock/TimeClockPanel.tsx b/components/time-clock/TimeClockPanel.tsx
index b8b9ef1..46bd2c3 100644
--- a/components/time-clock/TimeClockPanel.tsx
+++ b/components/time-clock/TimeClockPanel.tsx
@@ -105,7 +105,6 @@ export function TimeClockPanel({
const [featuredClientIds, setFeaturedClientIds] = useState([]);
const [storedLastClientId, setStoredLastClientId] = useState(null);
const [prefsLoaded, setPrefsLoaded] = useState(false);
- const [initialClientResolved, setInitialClientResolved] = useState(Boolean(defaultClientId));
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
@@ -210,32 +209,6 @@ export function TimeClockPanel({
setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]);
- useEffect(() => {
- if (running || defaultClientId || initialClientResolved) return;
- if (!prefsLoaded || clients.length === 0) return;
-
- const preferredId =
- storedLastClientId && clients.some((client) => client.id === storedLastClientId)
- ? storedLastClientId
- : recentClientIds.find((id) => clients.some((client) => client.id === id)) ?? null;
-
- if (preferredId) {
- const client = clients.find((c) => c.id === preferredId);
- setClientId(preferredId);
- setRateText(clientRateText(client));
- }
-
- setInitialClientResolved(true);
- }, [
- clients,
- defaultClientId,
- initialClientResolved,
- prefsLoaded,
- recentClientIds,
- running,
- storedLastClientId,
- ]);
-
useEffect(() => {
if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
@@ -332,10 +305,12 @@ export function TimeClockPanel({
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
- if (!featuredClientIds.includes(nextClientId)) {
+ if (nextClientId && !featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true);
}
- void persistClientChoice(nextClientId);
+ if (nextClientId) {
+ void persistClientChoice(nextClientId);
+ }
}
function selectInvoice(nextInvoiceId: string) {
@@ -596,6 +571,11 @@ export function TimeClockPanel({
) : (
<>
+ selectClient("")}
+ />
{featuredClients.map((client) => renderClientChip(client))}
{moreClients.length > 0 ? (
Invoice (optional)
{!clientId ? (
- Pick a client to attach a draft invoice.
+
+ No invoice for now. Add a client and invoice later if this becomes billable.
+
) : (
setInvoiceId("")} />
diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx
index af3c786..e0ee76b 100644
--- a/components/ui/Button.tsx
+++ b/components/ui/Button.tsx
@@ -17,6 +17,7 @@ type ButtonProps = PressableProps & {
loading?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost";
style?: ViewStyle;
+ leftIcon?: keyof typeof Ionicons.glyphMap;
showArrow?: boolean;
};
@@ -26,6 +27,7 @@ export function Button({
variant = "primary",
disabled,
style,
+ leftIcon,
showArrow = false,
...props
}: ButtonProps) {
@@ -44,7 +46,11 @@ export function Button({
borderWidth: 1,
borderColor: colors.destructive,
},
- ghost: { backgroundColor: "transparent" },
+ ghost: {
+ backgroundColor: colors.cardGlass,
+ borderWidth: 1,
+ borderColor: colors.borderGlass,
+ },
} as const;
const labelStyles = {
@@ -73,7 +79,16 @@ export function Button({
/>
) : (
- {title}
+ {leftIcon ? (
+
+ ) : null}
+
+ {title}
+
{showArrow ? (