14c880123c
Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities. Co-authored-by: Cursor <cursoragent@cursor.com>
379 lines
10 KiB
TypeScript
379 lines
10 KiB
TypeScript
import { router, useLocalSearchParams } from "expo-router";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Alert,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
|
|
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 { fonts, spacing } from "@/constants/theme";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { useNativeTabBarHeight, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
|
import { formatCurrency } from "@/lib/format";
|
|
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 insets = useSafeAreaInsets();
|
|
const tabBarHeight = useNativeTabBarHeight();
|
|
const scrollPadding = useTabBarScrollPadding();
|
|
|
|
const invoiceQuery = api.invoices.getById.useQuery(
|
|
{ id: id ?? "" },
|
|
{ enabled: Boolean(id) },
|
|
);
|
|
|
|
const [notes, setNotes] = useState("");
|
|
const [dueDate, setDueDate] = useState(() => new Date());
|
|
const [items, setItems] = useState<EditableLineItem[]>([]);
|
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [footerHeight, setFooterHeight] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const invoice = invoiceQuery.data;
|
|
if (!invoice) return;
|
|
setNotes(invoice.notes ?? "");
|
|
setDueDate(new Date(invoice.dueDate));
|
|
setItems(
|
|
invoice.items.map((item) => ({
|
|
id: item.id,
|
|
date: new Date(item.date),
|
|
description: item.description,
|
|
hours: String(item.hours),
|
|
rate: String(item.rate),
|
|
})),
|
|
);
|
|
setExpandedIndex(null);
|
|
}, [invoiceQuery.data]);
|
|
|
|
const updateInvoice = api.invoices.update.useMutation({
|
|
onSuccess: () => {
|
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
|
void utils.invoices.getAll.invalidate();
|
|
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 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 taxRate = invoice?.taxRate ?? 0;
|
|
const taxAmount = subtotal * (taxRate / 100);
|
|
const total = subtotal + taxAmount;
|
|
const currency = invoice?.currency ?? "USD";
|
|
|
|
if (!id) {
|
|
return <LoadingScreen message="Invalid invoice" />;
|
|
}
|
|
|
|
if (invoiceQuery.isLoading) {
|
|
return <LoadingScreen message="Loading invoice…" />;
|
|
}
|
|
|
|
if (!invoice) {
|
|
return <LoadingScreen message="Invoice not found" />;
|
|
}
|
|
|
|
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 handleSave() {
|
|
setError(null);
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
updateInvoice.mutate({
|
|
id,
|
|
notes,
|
|
dueDate,
|
|
items: parsedItems,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<AppBackground>
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
style={styles.flex}
|
|
>
|
|
<ScrollView
|
|
contentContainerStyle={[
|
|
styles.container,
|
|
{ paddingBottom: scrollPadding + footerHeight },
|
|
]}
|
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
|
scrollIndicatorInsets={{ bottom: scrollPadding + footerHeight }}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
<View style={styles.hero}>
|
|
<Text style={styles.invoiceNumber}>
|
|
{invoice.invoicePrefix}
|
|
{invoice.invoiceNumber}
|
|
</Text>
|
|
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
|
</View>
|
|
|
|
<Card>
|
|
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
|
|
<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={item.id ?? `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)} />
|
|
{taxRate > 0 ? (
|
|
<TotalRow
|
|
label={`Tax (${taxRate}%)`}
|
|
value={formatCurrency(taxAmount, currency)}
|
|
/>
|
|
) : null}
|
|
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
|
|
</View>
|
|
</Card>
|
|
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
</ScrollView>
|
|
|
|
<View
|
|
onLayout={(event) => setFooterHeight(event.nativeEvent.layout.height)}
|
|
style={[
|
|
styles.footer,
|
|
{
|
|
bottom: tabBarHeight,
|
|
paddingBottom: Math.max(insets.bottom, spacing.sm),
|
|
},
|
|
]}
|
|
>
|
|
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
|
</View>
|
|
</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 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,
|
|
},
|
|
notesInput: {
|
|
minHeight: 72,
|
|
textAlignVertical: "top",
|
|
},
|
|
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,
|
|
},
|
|
footer: {
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: spacing.md,
|
|
paddingTop: spacing.sm,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
backgroundColor: colors.cardGlass,
|
|
},
|
|
});
|