Make scheduling and dates timezone-safe
This commit is contained in:
@@ -1,12 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import type { AppRouter } from "beenvoice/server/api/root";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
@@ -26,6 +21,7 @@ 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 { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
type ExpenseFilter = "all" | "billable" | "receipts";
|
||||
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
|
||||
@@ -120,17 +116,27 @@ export default function ExpensesScreen() {
|
||||
<View
|
||||
style={[
|
||||
styles.emptyCard,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
|
||||
<View
|
||||
style={[styles.emptyIcon, { backgroundColor: colors.muted }]}
|
||||
>
|
||||
<Ionicons
|
||||
name="receipt-outline"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
||||
No expenses yet
|
||||
</Text>
|
||||
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
|
||||
Scan a receipt or add a manual entry when something needs to be
|
||||
tracked, billed, or reimbursed.
|
||||
</Text>
|
||||
<Button
|
||||
title="Add expense"
|
||||
@@ -140,9 +146,18 @@ export default function ExpensesScreen() {
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.summaryGrid}>
|
||||
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
|
||||
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
|
||||
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
|
||||
<SummaryTile
|
||||
label="Visible total"
|
||||
value={formatCurrency(summary.total)}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Billable"
|
||||
value={formatCurrency(summary.billable)}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Receipts"
|
||||
value={String(summary.receiptCount)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@@ -174,14 +189,21 @@ export default function ExpensesScreen() {
|
||||
) : (
|
||||
groupedExpenses.map(([monthLabel, group]) => (
|
||||
<View key={monthLabel} style={styles.monthGroup}>
|
||||
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.monthLabel,
|
||||
{ color: colors.mutedForeground },
|
||||
]}
|
||||
>
|
||||
{monthLabel}
|
||||
</Text>
|
||||
{group.map((expense) => (
|
||||
<ExpenseRow
|
||||
key={expense.id}
|
||||
expense={expense}
|
||||
onDelete={() => deleteExpense.mutate({ id: expense.id })}
|
||||
onDelete={() =>
|
||||
deleteExpense.mutate({ id: expense.id })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -209,14 +231,23 @@ function SummaryTile({ label, value }: { label: string; value: string }) {
|
||||
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
|
||||
<Text
|
||||
style={[styles.summaryValue, { color: colors.foreground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
|
||||
function ExpenseRow({
|
||||
expense,
|
||||
onDelete,
|
||||
}: {
|
||||
expense: Expense;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createStyles);
|
||||
|
||||
@@ -236,7 +267,8 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
icon: "open-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/more/expenses/${expense.id}` as never),
|
||||
onPress: () =>
|
||||
router.push(`/(app)/more/expenses/${expense.id}` as never),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
@@ -249,45 +281,77 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
]}
|
||||
>
|
||||
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
|
||||
<Ionicons
|
||||
name={expenseIcon(expense.category)}
|
||||
size={18}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
|
||||
<Text
|
||||
style={[styles.title, { color: colors.foreground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{expense.description}
|
||||
</Text>
|
||||
{expense.receiptCount ? (
|
||||
<View
|
||||
style={[
|
||||
styles.receiptPill,
|
||||
{ borderColor: colors.border, backgroundColor: colors.background },
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
|
||||
<Ionicons
|
||||
name="document-attach-outline"
|
||||
size={13}
|
||||
color={colors.primary}
|
||||
/>
|
||||
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
|
||||
{expense.receiptCount}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
||||
<Text
|
||||
style={[styles.sub, { color: colors.mutedForeground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{formatDate(expense.date)}
|
||||
{expense.category ? ` · ${expense.category}` : ""}
|
||||
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||
</Text>
|
||||
<View style={styles.tagRow}>
|
||||
{expense.billable ? (
|
||||
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tag,
|
||||
{ color: colors.primary, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Billable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.reimbursable ? (
|
||||
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tag,
|
||||
{ color: colors.foreground, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Reimbursable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.taxDeductible ? (
|
||||
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tag,
|
||||
{ color: colors.success, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Tax
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -297,7 +361,11 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={colors.mutedForeground}
|
||||
/>
|
||||
</View>
|
||||
</SwipeableRow>
|
||||
);
|
||||
@@ -306,8 +374,7 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
function groupExpensesByMonth(expenses: Expense[]) {
|
||||
const groups = new Map<string, Expense[]>();
|
||||
for (const expense of expenses) {
|
||||
const date = new Date(expense.date);
|
||||
const key = date.toLocaleDateString(undefined, {
|
||||
const key = formatCalendarDate(expense.date, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
@@ -320,11 +387,16 @@ function groupExpensesByMonth(expenses: Expense[]) {
|
||||
|
||||
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";
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user