Move production to beenvoice.app with migrated accounts, refreshed auth and timer UX, and expanded invoice flows.

Official URL migration preserves sessions, shortcuts prefs, and last clock-in client; auth screens match web with legal links; time clock and invoice editor/send flows are updated for the new domain and UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:40:48 -04:00
co-authored by Cursor
parent e17c4c6854
commit 6762a9bff3
60 changed files with 2544 additions and 1091 deletions
+161 -125
View File
@@ -1,4 +1,4 @@
import { router, Stack } from "expo-router";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
@@ -16,55 +16,75 @@ import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
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, LineItemsTableHeader, 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";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import {
isRequiredString,
isValidTaxRate,
validateLineItems,
} from "@/lib/form-validation";
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
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";
export default function NewInvoiceScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createNewInvoiceStyles);
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const { blank } = useLocalSearchParams<{ blank?: string }>();
const isBlank = blank === "1" || blank === "true";
const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
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 [notes, setNotes] = useState("");
const [taxRate, setTaxRate] = useState("0");
const [items, setItems] = useState<EditableLineItem[]>([
{
date: new Date(),
description: "",
hours: "1",
rate: "0",
},
]);
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const [items, setItems] = useState<EditableLineItem[]>(() =>
isBlank
? []
: [
{
date: new Date(),
description: "",
hours: "1",
rate: "0",
},
],
);
const [section, setSection] = useState<InvoiceEditorSection>("setup");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (businessId || !businessesQuery.data?.length) return;
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
}, [businessId, businessesQuery.data]);
const businessOptions = useMemo(
() =>
(businessesQuery.data ?? []).map((business) => ({
label: business.name,
value: business.id,
})),
[businessesQuery.data],
);
const clientOptions = useMemo(
() =>
(clientsQuery.data ?? []).map((client) => ({
@@ -76,6 +96,7 @@ export default function NewInvoiceScreen() {
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const currency = selectedClient?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return;
@@ -120,6 +141,7 @@ export default function NewInvoiceScreen() {
() =>
buildPreviewPdfInput({
invoiceNumber,
businessId: resolvedBusinessId,
clientId,
issueDate,
dueDate,
@@ -128,9 +150,20 @@ export default function NewInvoiceScreen() {
notes,
items,
}),
[invoiceNumber, clientId, issueDate, dueDate, parsedTaxRate, currency, notes, items],
[
invoiceNumber,
resolvedBusinessId,
clientId,
issueDate,
dueDate,
parsedTaxRate,
currency,
notes,
items,
],
);
const businessError = resolvedBusinessId ? undefined : "Select a business";
const clientError = clientId ? undefined : "Select a client";
const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined
@@ -138,13 +171,15 @@ export default function NewInvoiceScreen() {
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
const lineItemsError = validateLineItems(items);
const canCreate =
businessOptions.length > 0 &&
clientOptions.length > 0 &&
!businessError &&
!clientError &&
!invoiceNumberError &&
!taxError &&
!lineItemsError;
if (clientsQuery.isLoading) {
if (businessesQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading…" />;
}
@@ -165,10 +200,6 @@ export default function NewInvoiceScreen() {
}
function removeItem(index: number) {
if (items.length <= 1) {
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
return;
}
setItems((prev) => prev.filter((_, i) => i !== index));
}
@@ -193,6 +224,7 @@ export default function NewInvoiceScreen() {
}
createInvoice.mutate({
businessId: resolvedBusinessId,
clientId,
invoiceNumber: invoiceNumber.trim(),
issueDate,
@@ -207,7 +239,12 @@ export default function NewInvoiceScreen() {
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
<Stack.Screen
options={{
headerBackTitle: "Invoices",
title: isBlank ? "Blank invoice" : "New invoice",
}}
/>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
@@ -224,105 +261,101 @@ export default function NewInvoiceScreen() {
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : section === "setup" ? (
<Card title="Invoice setup">
{clientOptions.length === 0 || businessOptions.length === 0 ? (
<View style={styles.noEntities}>
<Text style={styles.noEntitiesText}>
{businessOptions.length === 0
? "Add a business before creating an invoice."
: "Add a client before creating an invoice."}
</Text>
<Button
title={businessOptions.length === 0 ? "Add business" : "Add client"}
variant="secondary"
onPress={() =>
router.push(
businessOptions.length === 0
? "/(app)/entities/businesses/new"
: "/(app)/entities/clients/new",
)
}
/>
</View>
) : (
<InvoiceSetupForm
businessId={businessId}
onBusinessIdChange={setBusinessId}
businessOptions={businessOptions}
businessError={businessError}
clientId={clientId}
onClientIdChange={setClientId}
clientOptions={clientOptions}
clientError={clientError}
invoiceNumber={invoiceNumber}
onInvoiceNumberChange={setInvoiceNumber}
issueDate={issueDate}
onIssueDateChange={setIssueDate}
dueDate={dueDate}
onDueDateChange={setDueDate}
taxRate={taxRate}
onTaxRateChange={setTaxRate}
notes={notes}
onNotesChange={setNotes}
/>
)}
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
{invoiceNumberError ? (
<Text style={styles.error}>{invoiceNumberError}</Text>
) : null}
</Card>
) : (
<>
<Card title="Details">
{clientOptions.length === 0 ? (
<View style={styles.noClients}>
<Text style={styles.noClientsText}>
Add a client before creating an invoice.
</Text>
<Button
title="Add client"
variant="secondary"
onPress={() => router.push("/(app)/entities/clients/new")}
<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.
</Text>
) : null}
{items.map((item, index) => (
<LineItemEditor
key={`new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
/>
))}
<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}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
}
total={formatCurrency(total, currency)}
/>
</View>
) : (
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clientError}
onValueChange={setClientId}
/>
)}
<Input
label="Invoice number"
value={invoiceNumber}
onChangeText={setInvoiceNumber}
autoCapitalize="characters"
required
error={invoiceNumberError}
/>
<DateTimeField
label="Issue date"
mode="date"
value={issueDate}
onChange={(date) => {
setIssueDate(date);
setDueDate((current) => (current < date ? defaultDueDate(date) : current));
}}
/>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
<Input
label="Tax rate (%)"
value={taxRate}
onChangeText={setTaxRate}
keyboardType="decimal-pad"
error={taxError}
/>
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
</Card>
<Card title="Line items">
<LineItemsTableHeader />
{items.map((item, index) => (
<LineItemEditor
key={`new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
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}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Create invoice"
loading={createInvoice.isPending}
disabled={!canCreate}
onPress={handleCreate}
/>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
<InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
onPrimary={handleCreate}
primaryLoading={createInvoice.isPending}
primaryDisabled={!canCreate}
/>
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
@@ -336,21 +369,24 @@ const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
padding: spacing.md,
gap: spacing.md,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
noClients: {
noEntities: {
gap: spacing.sm,
},
noClientsText: {
noEntitiesText: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.mutedForeground,
lineHeight: 20,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
addLine: {
paddingTop: spacing.sm,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
addLineText: {