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>
444 lines
15 KiB
TypeScript
444 lines
15 KiB
TypeScript
import { router, Stack, useLocalSearchParams } from "expo-router";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Alert,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
|
|
import { AppBackground } from "@/components/AppBackground";
|
|
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, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { fonts, spacing } from "@/constants/theme";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { formatCurrency } from "@/lib/format";
|
|
import { isValidTaxRate, validateLineItems } from "@/lib/form-validation";
|
|
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
|
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
|
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
|
|
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 InvoiceEditScreen() {
|
|
const { colors } = useAppTheme();
|
|
const styles = useThemedStyles(createInvoiceEditStyles);
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const utils = api.useUtils();
|
|
const scrollPadding = useTabBarScrollPadding();
|
|
|
|
const invoiceQuery = api.invoices.getById.useQuery(
|
|
{ id: id ?? "" },
|
|
{ enabled: Boolean(id) },
|
|
);
|
|
const businessesQuery = api.businesses.getAll.useQuery();
|
|
const clientsQuery = api.clients.getAll.useQuery();
|
|
|
|
const [businessId, setBusinessId] = useState("");
|
|
const [clientId, setClientId] = useState("");
|
|
const [notes, setNotes] = useState("");
|
|
const [dueDate, setDueDate] = useState(() => new Date());
|
|
const [taxRate, setTaxRate] = useState("0");
|
|
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
|
const [items, setItems] = useState<EditableLineItem[]>([]);
|
|
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const invoice = invoiceQuery.data;
|
|
if (!invoice) return;
|
|
setBusinessId(invoice.businessId ?? invoice.business?.id ?? "");
|
|
setClientId(invoice.clientId);
|
|
setNotes(invoice.notes ?? "");
|
|
setDueDate(new Date(invoice.dueDate));
|
|
setTaxRate(String(invoice.taxRate));
|
|
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
|
|
setItems(
|
|
invoice.items.map((item) => ({
|
|
id: item.id,
|
|
date: new Date(item.date),
|
|
description: item.description,
|
|
hours: String(item.hours),
|
|
rate: String(item.rate),
|
|
})),
|
|
);
|
|
}, [invoiceQuery.data]);
|
|
|
|
useEffect(() => {
|
|
if (businessId || !businessesQuery.data?.length) return;
|
|
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
|
|
}, [businessId, businessesQuery.data]);
|
|
|
|
const updateInvoice = api.invoices.update.useMutation({
|
|
onSuccess: () => {
|
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
|
void utils.invoices.getAll.invalidate();
|
|
void utils.invoices.getAll.invalidate({ status: "draft" });
|
|
void utils.dashboard.getStats.invalidate();
|
|
Alert.alert("Saved", "Invoice updated", [
|
|
{ text: "OK", onPress: () => router.back() },
|
|
]);
|
|
},
|
|
onError: (err) => setError(err.message),
|
|
});
|
|
|
|
const invoice = invoiceQuery.data;
|
|
const isDraft = invoice?.status === "draft";
|
|
|
|
const businessOptions = useMemo(
|
|
() =>
|
|
(businessesQuery.data ?? []).map((business) => ({
|
|
label: business.name,
|
|
value: business.id,
|
|
})),
|
|
[businessesQuery.data],
|
|
);
|
|
|
|
const clientOptions = useMemo(
|
|
() =>
|
|
(clientsQuery.data ?? []).map((client) => ({
|
|
label: client.name,
|
|
value: client.id,
|
|
})),
|
|
[clientsQuery.data],
|
|
);
|
|
|
|
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
|
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
|
|
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
|
|
|
const subtotal = useMemo(
|
|
() =>
|
|
items.reduce((sum, item) => {
|
|
const hours = Number(item.hours) || 0;
|
|
const rate = Number(item.rate) || 0;
|
|
return sum + hours * rate;
|
|
}, 0),
|
|
[items],
|
|
);
|
|
|
|
const parsedTaxRate = Number(taxRate) || 0;
|
|
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 clientError = isDraft && !clientId ? "Select a client" : undefined;
|
|
const canSave = isDraft
|
|
? !lineItemsError && !taxError && !businessError && !clientError
|
|
: true;
|
|
|
|
const previewInput = useMemo(() => {
|
|
if (!invoice) return null;
|
|
return buildPreviewPdfInput({
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
invoicePrefix: invoice.invoicePrefix,
|
|
businessId: resolvedBusinessId,
|
|
clientId,
|
|
issueDate: new Date(invoice.issueDate),
|
|
dueDate,
|
|
status: invoice.status as "draft" | "sent" | "paid",
|
|
notes,
|
|
taxRate: 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) {
|
|
return <LoadingScreen message="Loading invoice…" />;
|
|
}
|
|
|
|
if (!invoice) {
|
|
return <LoadingScreen message="Invoice not found" />;
|
|
}
|
|
|
|
const status = getInvoiceStatus(invoice);
|
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
|
|
|
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
|
}
|
|
|
|
function addItem() {
|
|
setItems((prev) => [
|
|
...prev,
|
|
{
|
|
date: new Date(),
|
|
description: "",
|
|
hours: "1",
|
|
rate: prev[prev.length - 1]?.rate ?? "0",
|
|
},
|
|
]);
|
|
}
|
|
|
|
function removeItem(index: number) {
|
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!canSave) return;
|
|
setError(null);
|
|
|
|
if (isDraft && sendReminderAt) {
|
|
const granted = await ensureNotificationPermissions();
|
|
if (!granted) {
|
|
Alert.alert(
|
|
"Notifications disabled",
|
|
"Turn on notifications in Settings to get reminded when it's time to send this invoice.",
|
|
);
|
|
}
|
|
}
|
|
|
|
const parsedItems: Array<{
|
|
date: Date;
|
|
description: string;
|
|
hours: number;
|
|
rate: number;
|
|
}> = [];
|
|
|
|
for (const item of items) {
|
|
parsedItems.push({
|
|
date: item.date,
|
|
description: item.description.trim(),
|
|
hours: Number(item.hours),
|
|
rate: Number(item.rate),
|
|
});
|
|
}
|
|
|
|
updateInvoice.mutate({
|
|
id,
|
|
notes,
|
|
dueDate,
|
|
sendReminderAt,
|
|
...(isDraft
|
|
? {
|
|
businessId: resolvedBusinessId,
|
|
clientId,
|
|
taxRate: parsedTaxRate,
|
|
currency,
|
|
items: parsedItems,
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<AppBackground>
|
|
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
style={styles.flex}
|
|
>
|
|
<ScrollView
|
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
<View style={styles.hero}>
|
|
<Text style={styles.invoiceNumber}>
|
|
{invoice.invoicePrefix}
|
|
{invoice.invoiceNumber}
|
|
</Text>
|
|
<Text style={styles.clientName}>
|
|
{selectedClient?.name ?? invoice.client?.name ?? "Client"}
|
|
</Text>
|
|
</View>
|
|
|
|
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
|
|
|
|
{section === "preview" ? (
|
|
<Card title="PDF preview">
|
|
<InvoicePdfPreview input={previewInput} />
|
|
</Card>
|
|
) : section === "setup" ? (
|
|
<Card title="Invoice setup">
|
|
<InvoiceSetupForm
|
|
businessId={businessId}
|
|
onBusinessIdChange={setBusinessId}
|
|
businessOptions={businessOptions}
|
|
businessError={businessError}
|
|
businessReadOnly={!isDraft}
|
|
clientId={clientId}
|
|
onClientIdChange={setClientId}
|
|
clientOptions={clientOptions}
|
|
clientError={clientError}
|
|
clientReadOnly={!isDraft}
|
|
invoiceNumber={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
|
|
invoiceNumberReadOnly
|
|
issueDate={new Date(invoice.issueDate)}
|
|
issueDateReadOnly
|
|
dueDate={dueDate}
|
|
onDueDateChange={setDueDate}
|
|
taxRate={taxRate}
|
|
onTaxRateChange={isDraft ? setTaxRate : undefined}
|
|
taxRateReadOnly={!isDraft}
|
|
notes={notes}
|
|
onNotesChange={setNotes}
|
|
sendReminderAt={sendReminderAt}
|
|
onSendReminderAtChange={isDraft ? setSendReminderAt : undefined}
|
|
showSendReminder={isDraft}
|
|
/>
|
|
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
|
|
</Card>
|
|
) : (
|
|
<>
|
|
<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.
|
|
</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.
|
|
</Text>
|
|
) : null}
|
|
{items.map((item, index) => (
|
|
<LineItemEditor
|
|
key={item.id ?? `new-${index}`}
|
|
index={index}
|
|
item={item}
|
|
currency={currency}
|
|
isLast={index === items.length - 1}
|
|
onChange={(patch) => updateItem(index, patch)}
|
|
onRemove={() => removeItem(index)}
|
|
readOnly={!isDraft}
|
|
/>
|
|
))}
|
|
|
|
{isDraft ? (
|
|
<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}
|
|
total={formatCurrency(total, currency)}
|
|
/>
|
|
</Card>
|
|
|
|
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
|
</>
|
|
)}
|
|
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
|
|
<InvoiceEditorFooter
|
|
primaryTitle="Save changes"
|
|
onPrimary={handleSave}
|
|
primaryLoading={updateInvoice.isPending}
|
|
primaryDisabled={!canSave}
|
|
secondary={
|
|
status !== "paid"
|
|
? {
|
|
title: status === "draft" ? "Send invoice" : "Resend invoice",
|
|
subtitle: clientEmail
|
|
? items.length === 0
|
|
? "Add line items before sending"
|
|
: `Review PDF and email to ${clientEmail}`
|
|
: "Add a client email on the web app first",
|
|
icon: "mail-outline",
|
|
onPress: () => {
|
|
if (!clientEmail) {
|
|
Alert.alert(
|
|
"No client email",
|
|
"Add an email address to this client on the web app before sending invoices.",
|
|
);
|
|
return;
|
|
}
|
|
if (items.length === 0) {
|
|
Alert.alert(
|
|
"No line items",
|
|
"Add line items or clock time to this invoice before sending.",
|
|
);
|
|
return;
|
|
}
|
|
router.push(`/(app)/invoices/send/${invoice.id}`);
|
|
},
|
|
disabled: !clientEmail || items.length === 0,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|
StyleSheet.create({
|
|
flex: { flex: 1 },
|
|
container: {
|
|
padding: spacing.md,
|
|
gap: spacing.md,
|
|
},
|
|
hero: {
|
|
gap: 4,
|
|
},
|
|
invoiceNumber: {
|
|
fontSize: 24,
|
|
lineHeight: 28,
|
|
fontFamily: fonts.heading,
|
|
color: colors.foreground,
|
|
},
|
|
clientName: {
|
|
fontSize: 14,
|
|
fontFamily: fonts.body,
|
|
color: colors.mutedForeground,
|
|
},
|
|
lockedHint: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 13,
|
|
color: colors.mutedForeground,
|
|
marginBottom: spacing.sm,
|
|
},
|
|
emptyLines: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
color: colors.mutedForeground,
|
|
marginBottom: spacing.sm,
|
|
},
|
|
addLine: {
|
|
paddingTop: spacing.md,
|
|
paddingBottom: spacing.xs,
|
|
},
|
|
addLineText: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 14,
|
|
color: colors.primary,
|
|
},
|
|
error: {
|
|
color: colors.destructive,
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
},
|
|
});
|