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(null); const [items, setItems] = useState([]); const [section, setSection] = useState("setup"); const [error, setError] = useState(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 ; } if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) { return ; } if (!invoice) { return ; } const status = getInvoiceStatus(invoice); const clientEmail = invoice.client?.email?.trim() ?? ""; function updateItem(index: number, patch: Partial) { 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 ( {invoice.invoicePrefix} {invoice.invoiceNumber} {selectedClient?.name ?? invoice.client?.name ?? "Client"} {section === "preview" ? ( ) : section === "setup" ? ( {taxError ? {taxError} : null} ) : ( <> {!isDraft ? ( Line items are locked after an invoice is sent. Mark as draft on the invoice screen to edit entries. ) : items.length === 0 ? ( No line items yet. Add lines here or clock time to this invoice from the Timer tab. ) : null} {items.map((item, index) => ( updateItem(index, patch)} onRemove={() => removeItem(index)} readOnly={!isDraft} /> ))} {isDraft ? ( + Add another line ) : null} 0 ? `Tax (${parsedTaxRate}%)` : undefined} taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined} total={formatCurrency(total, currency)} /> {lineItemsError ? {lineItemsError} : null} )} {error ? {error} : null} { 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 } /> ); } 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, }, });