6d2711e36e
Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius. Co-authored-by: Cursor <cursoragent@cursor.com>
412 lines
11 KiB
TypeScript
412 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 { 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 { 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 [expandedIndex, setExpandedIndex] = useState<number | null>(0);
|
|
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;
|
|
|
|
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() {
|
|
const nextIndex = items.length;
|
|
setItems((prev) => [
|
|
...prev,
|
|
{
|
|
date: new Date(),
|
|
description: "",
|
|
hours: "1",
|
|
rate: prev[prev.length - 1]?.rate ?? "0",
|
|
},
|
|
]);
|
|
setExpandedIndex(nextIndex);
|
|
}
|
|
|
|
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));
|
|
setExpandedIndex((current) => {
|
|
if (current === null) return null;
|
|
if (current === index) return null;
|
|
return current > index ? current - 1 : current;
|
|
});
|
|
}
|
|
|
|
function handleCreate() {
|
|
setError(null);
|
|
|
|
if (!clientId) {
|
|
setError("Select a client");
|
|
return;
|
|
}
|
|
|
|
if (!invoiceNumber.trim()) {
|
|
setError("Invoice number is required");
|
|
return;
|
|
}
|
|
|
|
const parsedItems: Array<{
|
|
date: Date;
|
|
description: string;
|
|
hours: number;
|
|
rate: number;
|
|
}> = [];
|
|
|
|
for (const item of items) {
|
|
const hours = Number(item.hours);
|
|
const rate = Number(item.rate);
|
|
if (!item.description.trim()) {
|
|
setError("Each line needs a description");
|
|
return;
|
|
}
|
|
if (Number.isNaN(hours) || hours < 0) {
|
|
setError("Hours must be a valid number");
|
|
return;
|
|
}
|
|
if (Number.isNaN(rate) || rate < 0) {
|
|
setError("Rate must be a valid number");
|
|
return;
|
|
}
|
|
parsedItems.push({
|
|
date: item.date,
|
|
description: item.description.trim(),
|
|
hours,
|
|
rate,
|
|
});
|
|
}
|
|
|
|
const tax = Number(taxRate);
|
|
if (Number.isNaN(tax) || tax < 0 || tax > 100) {
|
|
setError("Tax rate must be between 0 and 100");
|
|
return;
|
|
}
|
|
|
|
createInvoice.mutate({
|
|
clientId,
|
|
invoiceNumber: invoiceNumber.trim(),
|
|
issueDate,
|
|
dueDate,
|
|
notes,
|
|
taxRate: tax,
|
|
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"
|
|
>
|
|
<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}
|
|
onValueChange={setClientId}
|
|
/>
|
|
)}
|
|
<Input
|
|
label="Invoice number"
|
|
value={invoiceNumber}
|
|
onChangeText={setInvoiceNumber}
|
|
autoCapitalize="characters"
|
|
/>
|
|
<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"
|
|
/>
|
|
<Input
|
|
label="Notes"
|
|
value={notes}
|
|
onChangeText={setNotes}
|
|
placeholder="Optional notes for the client"
|
|
multiline
|
|
style={styles.notesInput}
|
|
/>
|
|
</Card>
|
|
|
|
<Card title="Line items">
|
|
{items.map((item, index) => (
|
|
<LineItemEditor
|
|
key={`new-${index}`}
|
|
item={item}
|
|
currency={currency}
|
|
expanded={expandedIndex === index}
|
|
onToggle={() =>
|
|
setExpandedIndex((current) => (current === index ? null : index))
|
|
}
|
|
onChange={(patch) => updateItem(index, patch)}
|
|
onRemove={() => removeItem(index)}
|
|
/>
|
|
))}
|
|
|
|
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
|
<Text style={styles.addLineText}>+ Add line</Text>
|
|
</Pressable>
|
|
|
|
<View style={styles.totals}>
|
|
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
|
|
{parsedTaxRate > 0 ? (
|
|
<TotalRow
|
|
label={`Tax (${parsedTaxRate}%)`}
|
|
value={formatCurrency(taxAmount, currency)}
|
|
/>
|
|
) : null}
|
|
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
|
|
</View>
|
|
</Card>
|
|
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
|
|
<Button
|
|
title="Create invoice"
|
|
loading={createInvoice.isPending}
|
|
disabled={clientOptions.length === 0}
|
|
onPress={handleCreate}
|
|
/>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
function TotalRow({
|
|
label,
|
|
value,
|
|
bold,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
bold?: boolean;
|
|
}) {
|
|
const { colors } = useAppTheme();
|
|
return (
|
|
<View style={totalStyles.row}>
|
|
<Text
|
|
style={[
|
|
totalStyles.label,
|
|
{ color: colors.mutedForeground },
|
|
bold && totalStyles.bold,
|
|
bold && { color: colors.foreground },
|
|
]}
|
|
>
|
|
{label}
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
totalStyles.value,
|
|
{ color: colors.foreground },
|
|
bold && totalStyles.bold,
|
|
]}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const totalStyles = StyleSheet.create({
|
|
row: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
},
|
|
label: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
},
|
|
value: {
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 14,
|
|
},
|
|
bold: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 15,
|
|
},
|
|
});
|
|
|
|
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,
|
|
},
|
|
totals: {
|
|
marginTop: spacing.sm,
|
|
paddingTop: spacing.sm,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
gap: 6,
|
|
},
|
|
error: {
|
|
color: colors.destructive,
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
},
|
|
});
|