239 lines
6.9 KiB
TypeScript
239 lines
6.9 KiB
TypeScript
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 on the web app 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",
|
|
},
|
|
});
|