355b14faef
Enable App Store builds without EAS, iOS 18 App Intents plugins, and signing fixes for distribution export. Add mobile invoice PDF preview, compact line items, and more reliable shortcut deep-link handling. Co-authored-by: Cursor <cursoragent@cursor.com>
367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
import { router, Stack } 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 { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
|
import { LineItemEditor, LineItemsTableHeader, 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 { 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 clientsQuery = api.clients.getAll.useQuery();
|
|
|
|
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 [error, setError] = useState<string | null>(null);
|
|
|
|
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 ?? "USD";
|
|
|
|
useEffect(() => {
|
|
if (!selectedClient?.defaultHourlyRate) return;
|
|
setItems((prev) =>
|
|
prev.map((item, index) =>
|
|
index === 0 && (item.rate === "0" || item.rate === "")
|
|
? { ...item, rate: String(selectedClient.defaultHourlyRate) }
|
|
: item,
|
|
),
|
|
);
|
|
}, [selectedClient?.defaultHourlyRate, selectedClient?.id]);
|
|
|
|
const createInvoice = api.invoices.create.useMutation({
|
|
onSuccess: (invoice) => {
|
|
void utils.invoices.getAll.invalidate();
|
|
void utils.dashboard.getStats.invalidate();
|
|
Alert.alert("Invoice created", "Your draft invoice is ready.", [
|
|
{
|
|
text: "View invoice",
|
|
onPress: () => router.replace(`/(app)/invoices/${invoice.id}`),
|
|
},
|
|
]);
|
|
},
|
|
onError: (err) => setError(err.message),
|
|
});
|
|
|
|
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 previewInput = useMemo(
|
|
() =>
|
|
buildPreviewPdfInput({
|
|
invoiceNumber,
|
|
clientId,
|
|
issueDate,
|
|
dueDate,
|
|
taxRate: parsedTaxRate,
|
|
currency,
|
|
notes,
|
|
items,
|
|
}),
|
|
[invoiceNumber, clientId, issueDate, dueDate, parsedTaxRate, currency, notes, items],
|
|
);
|
|
|
|
const clientError = clientId ? undefined : "Select a client";
|
|
const invoiceNumberError = isRequiredString(invoiceNumber)
|
|
? undefined
|
|
: "Invoice number is required";
|
|
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
|
|
const lineItemsError = validateLineItems(items);
|
|
const canCreate =
|
|
clientOptions.length > 0 &&
|
|
!clientError &&
|
|
!invoiceNumberError &&
|
|
!taxError &&
|
|
!lineItemsError;
|
|
|
|
if (clientsQuery.isLoading) {
|
|
return <LoadingScreen message="Loading…" />;
|
|
}
|
|
|
|
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) {
|
|
if (items.length <= 1) {
|
|
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
|
|
return;
|
|
}
|
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
|
}
|
|
|
|
function handleCreate() {
|
|
if (!canCreate) return;
|
|
setError(null);
|
|
|
|
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),
|
|
});
|
|
}
|
|
|
|
createInvoice.mutate({
|
|
clientId,
|
|
invoiceNumber: invoiceNumber.trim(),
|
|
issueDate,
|
|
dueDate,
|
|
notes,
|
|
taxRate: Number(taxRate),
|
|
currency,
|
|
items: parsedItems,
|
|
status: "draft",
|
|
});
|
|
}
|
|
|
|
return (
|
|
<AppBackground>
|
|
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
|
<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"
|
|
>
|
|
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
|
|
|
|
{section === "preview" ? (
|
|
<Card title="PDF preview">
|
|
<InvoicePdfPreview input={previewInput} />
|
|
</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")}
|
|
/>
|
|
</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 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}
|
|
/>
|
|
</>
|
|
)}
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|
StyleSheet.create({
|
|
flex: { flex: 1 },
|
|
container: {
|
|
padding: spacing.md,
|
|
gap: spacing.md,
|
|
},
|
|
notesInput: {
|
|
minHeight: 72,
|
|
textAlignVertical: "top",
|
|
},
|
|
noClients: {
|
|
gap: spacing.sm,
|
|
},
|
|
noClientsText: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
color: colors.mutedForeground,
|
|
lineHeight: 20,
|
|
},
|
|
addLine: {
|
|
paddingTop: spacing.sm,
|
|
paddingBottom: spacing.xs,
|
|
},
|
|
addLineText: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 14,
|
|
color: colors.primary,
|
|
},
|
|
error: {
|
|
color: colors.destructive,
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
},
|
|
});
|