Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||||
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function InvoiceDetailScreen() {
|
||||
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||||
const { colors } = useAppTheme();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const utils = api.useUtils();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const [section, setSection] = useState<InvoiceViewSection>("details");
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
|
||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (err) => Alert.alert("Update failed", err.message),
|
||||
});
|
||||
|
||||
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
|
||||
onSuccess: () => {
|
||||
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
||||
});
|
||||
|
||||
const previewInput = useMemo(
|
||||
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
|
||||
[invoiceQuery.data],
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading invoice…" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.error || !invoiceQuery.data) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorTitle}>Could not load invoice</Text>
|
||||
<Text style={styles.errorText}>
|
||||
{invoiceQuery.error?.message ?? "Invoice not found"}
|
||||
</Text>
|
||||
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
|
||||
</View>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const invoice = invoiceQuery.data;
|
||||
const status = getInvoiceStatus(invoice);
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||
|
||||
function openSendScreen() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client before sending invoices.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (invoice.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}`);
|
||||
}
|
||||
|
||||
function promptPaymentReminder() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client before sending payment reminders.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
"Send payment reminder",
|
||||
`Email a payment reminder to ${clientEmail}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Send",
|
||||
onPress: () => sendPaymentReminder.mutate({ id: invoice.id }),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function promptStatusChange(current: InvoiceStatus) {
|
||||
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||
if (current !== "sent" && current !== "overdue") {
|
||||
options.push({ label: "Mark as sent", status: "sent" });
|
||||
}
|
||||
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||||
if (options.length === 0) return;
|
||||
|
||||
Alert.alert("Update status", "Choose a new status", [
|
||||
...options.map((option) => ({
|
||||
text: option.label,
|
||||
onPress: () => updateStatus.mutate({ id: invoice.id, status: option.status }),
|
||||
})),
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.headerMeta}>
|
||||
<Text style={styles.invoiceNumber}>
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
||||
</View>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
<Text style={styles.total}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<InvoiceViewChips
|
||||
section={section}
|
||||
onSectionChange={setSection}
|
||||
status={status}
|
||||
onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
|
||||
onSend={openSendScreen}
|
||||
/>
|
||||
|
||||
{section === "preview" ? (
|
||||
<Card title="PDF preview">
|
||||
<InvoicePdfPreview input={previewInput} />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title="Details">
|
||||
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
|
||||
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
|
||||
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
|
||||
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
|
||||
<DetailRow label="Currency" value={invoice.currency} />
|
||||
{invoice.taxRate > 0 ? (
|
||||
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
|
||||
) : null}
|
||||
{invoice.status === "draft" && invoice.sendReminderAt ? (
|
||||
<DetailRow
|
||||
label="Send reminder"
|
||||
value={
|
||||
new Date(invoice.sendReminderAt) <= new Date()
|
||||
? "Due now"
|
||||
: formatDate(invoice.sendReminderAt)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Line items">
|
||||
{invoice.items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Clock time to this invoice from the Timer tab, or edit to
|
||||
add lines manually.
|
||||
</Text>
|
||||
) : (
|
||||
invoice.items.map((item) => {
|
||||
const line = (
|
||||
<View style={styles.lineItem}>
|
||||
<View style={styles.lineMeta}>
|
||||
<Text style={styles.lineDescription}>{item.description}</Text>
|
||||
<Text style={styles.lineSub}>
|
||||
{formatDate(item.date)} · {item.hours}h ×{" "}
|
||||
{formatCurrency(item.rate, invoice.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.lineAmount}>
|
||||
{formatCurrency(item.amount, invoice.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (invoice.status !== "draft") {
|
||||
return <View key={item.id}>{line}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SwipeableRow
|
||||
key={item.id}
|
||||
backgroundColor={colors.card}
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{line}
|
||||
</SwipeableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<InvoiceTotals
|
||||
subtotal={formatCurrency(subtotal, invoice.currency)}
|
||||
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
|
||||
taxAmount={
|
||||
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined
|
||||
}
|
||||
total={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{invoice.notes ? (
|
||||
<Card title="Notes">
|
||||
<Text style={styles.notes}>{invoice.notes}</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<InvoiceDetailActions
|
||||
status={status}
|
||||
clientEmail={clientEmail}
|
||||
onPaymentReminder={
|
||||
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
|
||||
}
|
||||
paymentReminderLoading={sendPaymentReminder.isPending}
|
||||
onUpdateStatus={() => promptStatusChange(status)}
|
||||
updateStatusLoading={updateStatus.isPending}
|
||||
onTrackTime={() =>
|
||||
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={detailStyles.row}>
|
||||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const detailStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
});
|
||||
|
||||
const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: spacing.md,
|
||||
},
|
||||
headerMeta: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 22,
|
||||
lineHeight: 26,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
total: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: 28,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
lineItem: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
lineMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
lineDescription: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
},
|
||||
lineSub: {
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 12,
|
||||
},
|
||||
lineAmount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
},
|
||||
emptyLines: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notes: {
|
||||
fontFamily: fonts.body,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
import { fonts } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
export default function InvoicesLayout() {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
headerStyle: { backgroundColor: colors.cardGlass },
|
||||
headerTitleStyle: {
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 18,
|
||||
color: colors.foreground,
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: colors.foreground,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Invoices",
|
||||
headerShown: false,
|
||||
statusBarTranslucent: true,
|
||||
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="new"
|
||||
options={{
|
||||
title: "New invoice",
|
||||
headerBackTitle: "Invoices",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[id]"
|
||||
options={{
|
||||
title: "Invoice",
|
||||
headerBackTitle: "Invoices",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="send/[id]"
|
||||
options={{
|
||||
title: "Send invoice",
|
||||
headerBackTitle: "Invoice",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="edit/[id]"
|
||||
options={{
|
||||
title: "Edit invoice",
|
||||
headerBackTitle: "Invoice",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
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));
|
||||
}
|
||||
|
||||
function duplicateItem(index: number) {
|
||||
setItems((prev) => {
|
||||
const source = prev[index];
|
||||
if (!source) return prev;
|
||||
const copy = { ...source, id: undefined };
|
||||
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||
});
|
||||
}
|
||||
|
||||
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)}
|
||||
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
|
||||
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 first",
|
||||
icon: "mail-outline",
|
||||
onPress: () => {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client 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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { FloatingActionButton } from "@/components/FloatingActionButton";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||||
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Sent", value: "sent" },
|
||||
{ label: "Paid", value: "paid" },
|
||||
{ label: "Overdue", value: "overdue" },
|
||||
];
|
||||
|
||||
export default function InvoicesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createInvoicesStyles);
|
||||
const [filter, setFilter] = useState<(typeof filters)[number]["value"]>("all");
|
||||
const utils = api.useUtils();
|
||||
const invoicesQuery = api.invoices.getAll.useQuery();
|
||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.invoices.getAll.invalidate();
|
||||
utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (err) => Alert.alert("Update failed", err.message),
|
||||
});
|
||||
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.invoices.getAll.invalidate();
|
||||
utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (err) => Alert.alert("Delete failed", err.message),
|
||||
});
|
||||
|
||||
if (invoicesQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading invoices…" />;
|
||||
}
|
||||
|
||||
if (invoicesQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorTitle}>Could not load invoices</Text>
|
||||
<Text style={styles.errorText}>{formatTrpcErrorMessage(invoicesQuery.error)}</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const invoices = (invoicesQuery.data ?? []).filter((invoice) => {
|
||||
if (filter === "all") return true;
|
||||
return getInvoiceStatus(invoice) === filter;
|
||||
});
|
||||
|
||||
function promptStatusChange(invoiceId: string, current: InvoiceStatus) {
|
||||
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||
|
||||
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||
if (current !== "sent" && current !== "overdue") {
|
||||
options.push({ label: "Mark as sent", status: "sent" });
|
||||
}
|
||||
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||||
|
||||
if (options.length === 0) return;
|
||||
|
||||
Alert.alert("Update status", "Choose a new status", [
|
||||
...options.map((option) => ({
|
||||
text: option.label,
|
||||
onPress: () => {
|
||||
updateStatus.mutate({ id: invoiceId, status: option.status });
|
||||
},
|
||||
})),
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]);
|
||||
}
|
||||
|
||||
function confirmDelete(invoiceId: string, label: string) {
|
||||
Alert.alert("Delete invoice?", `Remove ${label}? This cannot be undone.`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => deleteInvoice.mutate({ id: invoiceId }),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={invoicesQuery.isRefetching}
|
||||
onRefresh={() => invoicesQuery.refetch()}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filterScroll}
|
||||
contentContainerStyle={styles.filters}
|
||||
>
|
||||
{filters.map((item) => (
|
||||
<FilterChip
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
active={filter === item.value}
|
||||
onPress={() => setFilter(item.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No invoices found</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Tap + to create your first invoice, or pull to refresh.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
invoices.map((invoice) => {
|
||||
const status = getInvoiceStatus(invoice);
|
||||
const label = `${invoice.invoicePrefix}${invoice.invoiceNumber}`;
|
||||
const actions = [
|
||||
{
|
||||
key: "open",
|
||||
label: "Open",
|
||||
icon: "open-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/invoices/${invoice.id}`),
|
||||
},
|
||||
...(status === "draft"
|
||||
? [
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
icon: "create-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.mutedForeground,
|
||||
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||
},
|
||||
{
|
||||
key: "send",
|
||||
label: "Send",
|
||||
icon: "send-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.success,
|
||||
onPress: () => router.push(`/(app)/invoices/send/${invoice.id}`),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: "trash-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.destructive,
|
||||
onPress: () => confirmDelete(invoice.id, label),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
icon: "flag-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.warning,
|
||||
onPress: () => promptStatusChange(invoice.id, status),
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
return (
|
||||
<SwipeableRow
|
||||
key={invoice.id}
|
||||
actions={actions}
|
||||
backgroundColor={colors.cardGlass}
|
||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||
onLongPress={() => promptStatusChange(invoice.id, status)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.cardTop}>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.invoiceNumber}>{label}</Text>
|
||||
<Text style={styles.clientName}>
|
||||
{invoice.client?.name ?? "Client"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.amount}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardBottom}>
|
||||
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</SwipeableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TabScrollView>
|
||||
<FloatingActionButton
|
||||
accessibilityLabel="Create invoice"
|
||||
onPress={() => {
|
||||
Alert.alert("Create invoice", "Choose how to start", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "With line items",
|
||||
onPress: () => router.push("/(app)/invoices/new"),
|
||||
},
|
||||
{
|
||||
text: "Blank (for timer)",
|
||||
onPress: () => router.push("/(app)/invoices/new?blank=1"),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createInvoicesStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
filterScroll: {
|
||||
flexGrow: 0,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
filters: {
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.md,
|
||||
},
|
||||
card: {},
|
||||
cardInner: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
cardTop: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
},
|
||||
cardMeta: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
cardBottom: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
date: {
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
empty: {
|
||||
padding: spacing.lg,
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
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 { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import {
|
||||
isRequiredString,
|
||||
isValidTaxRate,
|
||||
validateLineItems,
|
||||
} from "@/lib/form-validation";
|
||||
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
|
||||
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
||||
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 styles = useThemedStyles(createNewInvoiceStyles);
|
||||
const utils = api.useUtils();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const { blank } = useLocalSearchParams<{ blank?: string }>();
|
||||
const isBlank = blank === "1" || blank === "true";
|
||||
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
|
||||
const [businessId, setBusinessId] = useState("");
|
||||
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[]>(() =>
|
||||
isBlank
|
||||
? []
|
||||
: [
|
||||
{
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: "0",
|
||||
},
|
||||
],
|
||||
);
|
||||
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (businessId || !businessesQuery.data?.length) return;
|
||||
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
|
||||
}, [businessId, businessesQuery.data]);
|
||||
|
||||
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 ?? "USD";
|
||||
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||
|
||||
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,
|
||||
businessId: resolvedBusinessId,
|
||||
clientId,
|
||||
issueDate,
|
||||
dueDate,
|
||||
taxRate: parsedTaxRate,
|
||||
currency,
|
||||
notes,
|
||||
items,
|
||||
}),
|
||||
[
|
||||
invoiceNumber,
|
||||
resolvedBusinessId,
|
||||
clientId,
|
||||
issueDate,
|
||||
dueDate,
|
||||
parsedTaxRate,
|
||||
currency,
|
||||
notes,
|
||||
items,
|
||||
],
|
||||
);
|
||||
|
||||
const businessError = resolvedBusinessId ? undefined : "Select a business";
|
||||
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 =
|
||||
businessOptions.length > 0 &&
|
||||
clientOptions.length > 0 &&
|
||||
!businessError &&
|
||||
!clientError &&
|
||||
!invoiceNumberError &&
|
||||
!taxError &&
|
||||
!lineItemsError;
|
||||
|
||||
if (businessesQuery.isLoading || 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) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function duplicateItem(index: number) {
|
||||
setItems((prev) => {
|
||||
const source = prev[index];
|
||||
if (!source) return prev;
|
||||
const copy = { ...source, id: undefined };
|
||||
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
businessId: resolvedBusinessId,
|
||||
clientId,
|
||||
invoiceNumber: invoiceNumber.trim(),
|
||||
issueDate,
|
||||
dueDate,
|
||||
notes,
|
||||
taxRate: Number(taxRate),
|
||||
currency,
|
||||
items: parsedItems,
|
||||
status: "draft",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerBackTitle: "Invoices",
|
||||
title: isBlank ? "Blank invoice" : "New 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"
|
||||
>
|
||||
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
|
||||
|
||||
{section === "preview" ? (
|
||||
<Card title="PDF preview">
|
||||
<InvoicePdfPreview input={previewInput} />
|
||||
</Card>
|
||||
) : section === "setup" ? (
|
||||
<Card title="Invoice setup">
|
||||
{clientOptions.length === 0 || businessOptions.length === 0 ? (
|
||||
<View style={styles.noEntities}>
|
||||
<Text style={styles.noEntitiesText}>
|
||||
{businessOptions.length === 0
|
||||
? "Add a business before creating an invoice."
|
||||
: "Add a client before creating an invoice."}
|
||||
</Text>
|
||||
<Button
|
||||
title={businessOptions.length === 0 ? "Add business" : "Add client"}
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
router.push(
|
||||
businessOptions.length === 0
|
||||
? "/(app)/entities/businesses/new"
|
||||
: "/(app)/entities/clients/new",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<InvoiceSetupForm
|
||||
businessId={businessId}
|
||||
onBusinessIdChange={setBusinessId}
|
||||
businessOptions={businessOptions}
|
||||
businessError={businessError}
|
||||
clientId={clientId}
|
||||
onClientIdChange={setClientId}
|
||||
clientOptions={clientOptions}
|
||||
clientError={clientError}
|
||||
invoiceNumber={invoiceNumber}
|
||||
onInvoiceNumberChange={setInvoiceNumber}
|
||||
issueDate={issueDate}
|
||||
onIssueDateChange={setIssueDate}
|
||||
dueDate={dueDate}
|
||||
onDueDateChange={setDueDate}
|
||||
taxRate={taxRate}
|
||||
onTaxRateChange={setTaxRate}
|
||||
notes={notes}
|
||||
onNotesChange={setNotes}
|
||||
/>
|
||||
)}
|
||||
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
|
||||
{invoiceNumberError ? (
|
||||
<Text style={styles.error}>{invoiceNumberError}</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title="Line items">
|
||||
{isBlank && items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Save this draft and clock time to it from the Timer tab,
|
||||
or add lines here.
|
||||
</Text>
|
||||
) : null}
|
||||
{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)}
|
||||
onDuplicate={() => duplicateItem(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||
<Text style={styles.addLineText}>+ Add another 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}
|
||||
|
||||
<InvoiceEditorFooter
|
||||
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
||||
onPrimary={handleCreate}
|
||||
primaryLoading={createInvoice.isPending}
|
||||
primaryDisabled={!canCreate}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
noEntities: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
noEntitiesText: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.mutedForeground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
|
||||
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 InvoiceSendScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createSendStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const utils = api.useUtils();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
|
||||
const sendInvoice = api.email.sendInvoice.useMutation({
|
||||
onSuccess: (data) => {
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
Alert.alert("Invoice sent", data.message, [
|
||||
{ text: "OK", onPress: () => router.replace(`/(app)/invoices/${id}`) },
|
||||
]);
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||
});
|
||||
|
||||
const previewInput = useMemo(
|
||||
() =>
|
||||
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
|
||||
[invoiceQuery.data],
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading invoice…" />;
|
||||
}
|
||||
|
||||
if (!invoiceQuery.data) {
|
||||
return <LoadingScreen message="Invoice not found" />;
|
||||
}
|
||||
|
||||
const invoice = invoiceQuery.data;
|
||||
const status = getInvoiceStatus(invoice);
|
||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||
const businessName = invoice.business?.name ?? "Your business";
|
||||
const sendLabel = status === "draft" ? "Send invoice" : "Resend invoice";
|
||||
|
||||
function handleSend() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client before sending invoices.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (invoice.items.length === 0) {
|
||||
Alert.alert(
|
||||
"No line items",
|
||||
"Add line items or clock time to this invoice before sending.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sendInvoice.mutate({
|
||||
invoiceId: invoice.id,
|
||||
customMessage: customMessage.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ title: sendLabel, 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"
|
||||
>
|
||||
<Card title="Email summary">
|
||||
<SummaryRow label="From" value={businessName} />
|
||||
<SummaryRow label="To" value={clientEmail || "No client email on file"} />
|
||||
<SummaryRow
|
||||
label="Invoice"
|
||||
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
|
||||
/>
|
||||
<SummaryRow label="Due" value={formatDate(invoice.dueDate)} />
|
||||
<SummaryRow
|
||||
label="Amount"
|
||||
value={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
bold
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="PDF attachment">
|
||||
<InvoicePdfPreview input={previewInput} height={480} />
|
||||
</Card>
|
||||
|
||||
<Card title="Message">
|
||||
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}>
|
||||
Optional note included in the email body.
|
||||
</Text>
|
||||
<Input
|
||||
label="Personal message"
|
||||
value={customMessage}
|
||||
onChangeText={setCustomMessage}
|
||||
placeholder="Thanks for your business!"
|
||||
multiline
|
||||
style={styles.messageInput}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title={sendLabel}
|
||||
onPress={handleSend}
|
||||
loading={sendInvoice.isPending}
|
||||
disabled={!clientEmail || invoice.items.length === 0}
|
||||
/>
|
||||
{!clientEmail ? (
|
||||
<Text style={[styles.warning, { color: colors.destructive }]}>
|
||||
Add a client email address before sending.
|
||||
</Text>
|
||||
) : invoice.items.length === 0 ? (
|
||||
<Text style={[styles.warning, { color: colors.destructive }]}>
|
||||
Add line items before sending this invoice.
|
||||
</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({
|
||||
label,
|
||||
value,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={summaryStyles.row}>
|
||||
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text
|
||||
style={[
|
||||
summaryStyles.value,
|
||||
{ color: colors.foreground },
|
||||
bold && summaryStyles.bold,
|
||||
]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const summaryStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
textAlign: "right",
|
||||
},
|
||||
bold: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const createSendStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
messageHint: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
messageInput: {
|
||||
minHeight: 96,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
warning: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user