Make scheduling and dates timezone-safe
This commit is contained in:
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||
import {
|
||||
LineItemEditor,
|
||||
type EditableLineItem,
|
||||
} from "@/components/invoices/LineItemEditor";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
@@ -35,6 +38,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
export default function InvoiceEditScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
@@ -53,7 +57,9 @@ export default function InvoiceEditScreen() {
|
||||
const [businessId, setBusinessId] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [dueDate, setDueDate] = useState(() => new Date());
|
||||
const [dueDate, setDueDate] = useState(() =>
|
||||
calendarDateFromLocalDate(new Date()),
|
||||
);
|
||||
const [taxRate, setTaxRate] = useState("0");
|
||||
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
||||
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||
@@ -68,7 +74,9 @@ export default function InvoiceEditScreen() {
|
||||
setNotes(invoice.notes ?? "");
|
||||
setDueDate(new Date(invoice.dueDate));
|
||||
setTaxRate(String(invoice.taxRate));
|
||||
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
|
||||
setSendReminderAt(
|
||||
invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null,
|
||||
);
|
||||
setItems(
|
||||
invoice.items.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -119,9 +127,14 @@ export default function InvoiceEditScreen() {
|
||||
[clientsQuery.data],
|
||||
);
|
||||
|
||||
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
||||
const selectedClient = clientsQuery.data?.find(
|
||||
(client) => client.id === clientId,
|
||||
);
|
||||
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
|
||||
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||
const resolvedBusinessId = resolveInvoiceBusinessId(
|
||||
businessId,
|
||||
businessesQuery.data,
|
||||
);
|
||||
|
||||
const subtotal = useMemo(
|
||||
() =>
|
||||
@@ -137,8 +150,12 @@ export default function InvoiceEditScreen() {
|
||||
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
const lineItemsError = isDraft ? validateLineItems(items) : null;
|
||||
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
|
||||
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
|
||||
const taxError =
|
||||
isDraft && !isValidTaxRate(taxRate)
|
||||
? "Tax rate must be between 0 and 100"
|
||||
: null;
|
||||
const businessError =
|
||||
isDraft && !resolvedBusinessId ? "Select a business" : undefined;
|
||||
const clientError = isDraft && !clientId ? "Select a client" : undefined;
|
||||
const canSave = isDraft
|
||||
? !lineItemsError && !taxError && !businessError && !clientError
|
||||
@@ -159,13 +176,26 @@ export default function InvoiceEditScreen() {
|
||||
currency,
|
||||
items,
|
||||
});
|
||||
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
|
||||
}, [
|
||||
invoice,
|
||||
resolvedBusinessId,
|
||||
clientId,
|
||||
dueDate,
|
||||
notes,
|
||||
parsedTaxRate,
|
||||
currency,
|
||||
items,
|
||||
]);
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
|
||||
if (
|
||||
invoiceQuery.isLoading ||
|
||||
businessesQuery.isLoading ||
|
||||
clientsQuery.isLoading
|
||||
) {
|
||||
return <LoadingScreen message="Loading invoice…" />;
|
||||
}
|
||||
|
||||
@@ -177,14 +207,16 @@ export default function InvoiceEditScreen() {
|
||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||
|
||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
setItems((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
|
||||
);
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||
@@ -260,8 +292,13 @@ export default function InvoiceEditScreen() {
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={
|
||||
Platform.OS === "ios" ? "automatic" : undefined
|
||||
}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
@@ -316,13 +353,13 @@ export default function InvoiceEditScreen() {
|
||||
<Card title="Line items">
|
||||
{!isDraft ? (
|
||||
<Text style={styles.lockedHint}>
|
||||
Line items are locked after an invoice is sent. Mark as draft on the invoice
|
||||
screen to edit entries.
|
||||
Line items are locked after an invoice is sent. Mark as
|
||||
draft on the invoice screen to edit entries.
|
||||
</Text>
|
||||
) : items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Add lines here or clock time to this invoice from the
|
||||
Timer tab.
|
||||
No line items yet. Add lines here or clock time to this
|
||||
invoice from the Timer tab.
|
||||
</Text>
|
||||
) : null}
|
||||
{items.map((item, index) => (
|
||||
@@ -334,26 +371,40 @@ export default function InvoiceEditScreen() {
|
||||
isLast={index === items.length - 1}
|
||||
onChange={(patch) => updateItem(index, patch)}
|
||||
onRemove={() => removeItem(index)}
|
||||
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
|
||||
onDuplicate={
|
||||
isDraft ? () => duplicateItem(index) : undefined
|
||||
}
|
||||
readOnly={!isDraft}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDraft ? (
|
||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={addItem}
|
||||
style={styles.addLine}
|
||||
>
|
||||
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<InvoiceTotals
|
||||
subtotal={formatCurrency(subtotal, currency)}
|
||||
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
||||
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
|
||||
taxLabel={
|
||||
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
|
||||
}
|
||||
taxAmount={
|
||||
parsedTaxRate > 0
|
||||
? formatCurrency(taxAmount, currency)
|
||||
: undefined
|
||||
}
|
||||
total={formatCurrency(total, currency)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
||||
{lineItemsError ? (
|
||||
<Text style={styles.error}>{lineItemsError}</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -367,7 +418,8 @@ export default function InvoiceEditScreen() {
|
||||
secondary={
|
||||
status !== "paid"
|
||||
? {
|
||||
title: status === "draft" ? "Send invoice" : "Resend invoice",
|
||||
title:
|
||||
status === "draft" ? "Send invoice" : "Resend invoice",
|
||||
subtitle: clientEmail
|
||||
? items.length === 0
|
||||
? "Add line items before sending"
|
||||
|
||||
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||
import {
|
||||
LineItemEditor,
|
||||
type EditableLineItem,
|
||||
} from "@/components/invoices/LineItemEditor";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
@@ -39,6 +42,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
export default function NewInvoiceScreen() {
|
||||
const styles = useThemedStyles(createNewInvoiceStyles);
|
||||
@@ -53,8 +57,12 @@ export default function NewInvoiceScreen() {
|
||||
const [businessId, setBusinessId] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
|
||||
const [issueDate, setIssueDate] = useState(() => new Date());
|
||||
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
|
||||
const [issueDate, setIssueDate] = useState(() =>
|
||||
calendarDateFromLocalDate(new Date()),
|
||||
);
|
||||
const [dueDate, setDueDate] = useState(() =>
|
||||
defaultDueDate(calendarDateFromLocalDate(new Date())),
|
||||
);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [taxRate, setTaxRate] = useState("0");
|
||||
const [items, setItems] = useState<EditableLineItem[]>(() =>
|
||||
@@ -62,7 +70,7 @@ export default function NewInvoiceScreen() {
|
||||
? []
|
||||
: [
|
||||
{
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: "0",
|
||||
@@ -96,9 +104,14 @@ export default function NewInvoiceScreen() {
|
||||
[clientsQuery.data],
|
||||
);
|
||||
|
||||
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
||||
const selectedClient = clientsQuery.data?.find(
|
||||
(client) => client.id === clientId,
|
||||
);
|
||||
const currency = selectedClient?.currency ?? "USD";
|
||||
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||
const resolvedBusinessId = resolveInvoiceBusinessId(
|
||||
businessId,
|
||||
businessesQuery.data,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedClient?.defaultHourlyRate) return;
|
||||
@@ -170,7 +183,9 @@ export default function NewInvoiceScreen() {
|
||||
const invoiceNumberError = isRequiredString(invoiceNumber)
|
||||
? undefined
|
||||
: "Invoice number is required";
|
||||
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
|
||||
const taxError = isValidTaxRate(taxRate)
|
||||
? undefined
|
||||
: "Tax rate must be between 0 and 100";
|
||||
const lineItemsError = validateLineItems(items);
|
||||
const canCreate =
|
||||
businessOptions.length > 0 &&
|
||||
@@ -187,7 +202,9 @@ export default function NewInvoiceScreen() {
|
||||
|
||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||
touch("lineItems");
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
setItems((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
|
||||
);
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
@@ -195,7 +212,7 @@ export default function NewInvoiceScreen() {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||
@@ -266,8 +283,13 @@ export default function NewInvoiceScreen() {
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={
|
||||
Platform.OS === "ios" ? "automatic" : undefined
|
||||
}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
@@ -287,7 +309,11 @@ export default function NewInvoiceScreen() {
|
||||
: "Add a client before creating an invoice."}
|
||||
</Text>
|
||||
<Button
|
||||
title={businessOptions.length === 0 ? "Add business" : "Add client"}
|
||||
title={
|
||||
businessOptions.length === 0
|
||||
? "Add business"
|
||||
: "Add client"
|
||||
}
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
router.push(
|
||||
@@ -303,7 +329,9 @@ export default function NewInvoiceScreen() {
|
||||
businessId={businessId}
|
||||
onBusinessIdChange={setBusinessId}
|
||||
businessOptions={businessOptions}
|
||||
businessError={visible("business") ? businessError : undefined}
|
||||
businessError={
|
||||
visible("business") ? businessError : undefined
|
||||
}
|
||||
onBusinessBlur={() => touch("business")}
|
||||
clientId={clientId}
|
||||
onClientIdChange={setClientId}
|
||||
@@ -334,8 +362,8 @@ export default function NewInvoiceScreen() {
|
||||
<Card title="Line items">
|
||||
{isBlank && items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Save this draft and clock time to it from the Timer tab,
|
||||
or add lines here.
|
||||
No line items yet. Save this draft and clock time to it from
|
||||
the Timer tab, or add lines here.
|
||||
</Text>
|
||||
) : null}
|
||||
{items.map((item, index) => (
|
||||
@@ -351,27 +379,41 @@ export default function NewInvoiceScreen() {
|
||||
/>
|
||||
))}
|
||||
|
||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={addItem}
|
||||
style={styles.addLine}
|
||||
>
|
||||
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||
</Pressable>
|
||||
|
||||
<InvoiceTotals
|
||||
subtotal={formatCurrency(subtotal, currency)}
|
||||
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
||||
taxLabel={
|
||||
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
|
||||
}
|
||||
taxAmount={
|
||||
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
|
||||
parsedTaxRate > 0
|
||||
? formatCurrency(taxAmount, currency)
|
||||
: undefined
|
||||
}
|
||||
total={formatCurrency(total, currency)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{visible("lineItems") && lineItemsError ? (
|
||||
<Text selectable style={styles.error}>{lineItemsError}</Text>
|
||||
<Text selectable style={styles.error}>
|
||||
{lineItemsError}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
|
||||
{error ? (
|
||||
<Text selectable style={styles.error}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<InvoiceEditorFooter
|
||||
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
||||
|
||||
@@ -20,7 +20,9 @@ import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import {
|
||||
formatZonedDateTime,
|
||||
getDefaultScheduledSendAt,
|
||||
getLocalTimeZone,
|
||||
DEFAULT_TIME_ZONE,
|
||||
toLocalDateTimeInputValue,
|
||||
zonedDateTimeToInstant,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
@@ -42,7 +44,8 @@ export default function InvoiceSendScreen() {
|
||||
const [scheduledAt, setScheduledAt] = useState(() =>
|
||||
getDefaultScheduledSendAt(),
|
||||
);
|
||||
const timeZone = useMemo(() => getLocalTimeZone(), []);
|
||||
const profileQuery = api.settings.getProfile.useQuery();
|
||||
const timeZone = profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE;
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
@@ -139,7 +142,21 @@ export default function InvoiceSendScreen() {
|
||||
|
||||
function handleSchedule() {
|
||||
if (!clientEmail || invoice.items.length === 0) return;
|
||||
if (scheduledAt.getTime() < Date.now() + 60_000) {
|
||||
let instant: Date;
|
||||
try {
|
||||
instant = zonedDateTimeToInstant(
|
||||
toLocalDateTimeInputValue(scheduledAt),
|
||||
timeZone,
|
||||
"earlier",
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Choose another time",
|
||||
error instanceof Error ? error.message : "Invalid local time",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (instant.getTime() < Date.now() + 60_000) {
|
||||
Alert.alert(
|
||||
"Choose a future time",
|
||||
"The scheduled time must be at least one minute from now.",
|
||||
@@ -148,7 +165,7 @@ export default function InvoiceSendScreen() {
|
||||
}
|
||||
scheduleInvoice.mutate({
|
||||
invoiceId: invoice.id,
|
||||
scheduledAt,
|
||||
scheduledAt: instant,
|
||||
timeZone,
|
||||
customMessage: customMessage.trim() || undefined,
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
type ReceiptSplitDraft = Pick<
|
||||
ReceiptScanResult,
|
||||
@@ -37,7 +38,7 @@ export default function ExpenseDetailScreen() {
|
||||
const [form, setForm] = useState<ExpenseFormState>({
|
||||
description: "",
|
||||
amountText: "",
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
category: "",
|
||||
businessId: "",
|
||||
clientId: "",
|
||||
@@ -165,7 +166,9 @@ export default function ExpenseDetailScreen() {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
|
||||
<TabScrollView
|
||||
header={<PageHeader title="Expense" subtitle="Expense details" />}
|
||||
>
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
Expense not found
|
||||
</Text>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +303,9 @@ export default function SettingsScreen() {
|
||||
Role: {profile.role}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Time zone: {profile?.timeZone ?? "America/New_York"}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card title="Accounts">
|
||||
|
||||
@@ -17,36 +17,53 @@ import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import { api } from "@/lib/trpc";
|
||||
import type { AppRouter } from "beenvoice/server/api/root";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import {
|
||||
DEFAULT_TIME_ZONE,
|
||||
getZonedDateTimeParts,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
|
||||
|
||||
function groupByDate(entries: TimeEntry[]) {
|
||||
function groupByDate(entries: TimeEntry[], timeZone: string) {
|
||||
const groups = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
const d = new Date(entry.startedAt);
|
||||
const key = d.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const list = groups.get(key) ?? [];
|
||||
const parts = getZonedDateTimeParts(d, timeZone);
|
||||
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
||||
const list = groups.get(dateKey) ?? [];
|
||||
list.push(entry);
|
||||
groups.set(key, list);
|
||||
groups.set(dateKey, list);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
return Array.from(groups.entries()).map(
|
||||
([, groupedEntries]) =>
|
||||
[
|
||||
new Date(groupedEntries[0]!.startedAt).toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone,
|
||||
}),
|
||||
groupedEntries,
|
||||
] as const,
|
||||
);
|
||||
}
|
||||
|
||||
export default function TimeEntriesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
const entriesQuery = api.timeEntries.getAll.useQuery();
|
||||
const profileQuery = api.settings.getProfile.useQuery();
|
||||
|
||||
const completed = useMemo(
|
||||
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
|
||||
[entriesQuery.data],
|
||||
);
|
||||
const grouped = useMemo(() => groupByDate(completed), [completed]);
|
||||
const grouped = useMemo(
|
||||
() =>
|
||||
groupByDate(completed, profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE),
|
||||
[completed, profileQuery.data?.timeZone],
|
||||
);
|
||||
|
||||
if (entriesQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading time entries…" />;
|
||||
@@ -57,7 +74,10 @@ export default function TimeEntriesScreen() {
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<View style={styles.errorBox}>
|
||||
<PageHeader title="Time entries" subtitle="Completed work history" />
|
||||
<PageHeader
|
||||
title="Time entries"
|
||||
subtitle="Completed work history"
|
||||
/>
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
{formatTrpcErrorMessage(entriesQuery.error)}
|
||||
</Text>
|
||||
@@ -72,7 +92,10 @@ export default function TimeEntriesScreen() {
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
|
||||
<PageHeader
|
||||
title="Time entries"
|
||||
subtitle={`${completed.length} completed entries`}
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<PullToRefresh
|
||||
@@ -82,7 +105,9 @@ export default function TimeEntriesScreen() {
|
||||
}
|
||||
>
|
||||
{grouped.length === 0 ? (
|
||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||
<Text
|
||||
style={{ color: colors.mutedForeground, fontFamily: fonts.body }}
|
||||
>
|
||||
No completed entries yet. Start the timer from the Timer tab.
|
||||
</Text>
|
||||
) : (
|
||||
@@ -104,17 +129,26 @@ export default function TimeEntriesScreen() {
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||
<Text
|
||||
style={[styles.title, { color: colors.foreground }]}
|
||||
>
|
||||
{formatRunningTimerLabel(entry.description)}
|
||||
</Text>
|
||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
}}
|
||||
>
|
||||
{entry.client?.name ?? "No client"}
|
||||
{entry.invoice
|
||||
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||
: " · not billed"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||
<Text
|
||||
style={[styles.title, { color: colors.foreground }]}
|
||||
>
|
||||
{entry.hours ?? "—"}h
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user