373 lines
11 KiB
TypeScript
373 lines
11 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 { DateTimeField } from "@/components/ui/DateTimeField";
|
|
import {
|
|
formatZonedDateTime,
|
|
getDefaultScheduledSendAt,
|
|
DEFAULT_TIME_ZONE,
|
|
toLocalDateTimeInputValue,
|
|
zonedDateTimeToInstant,
|
|
} from "@beenvoice/domain/time-zone";
|
|
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 [scheduledAt, setScheduledAt] = useState(() =>
|
|
getDefaultScheduledSendAt(),
|
|
);
|
|
const profileQuery = api.settings.getProfile.useQuery();
|
|
const timeZone = profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE;
|
|
|
|
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 scheduleInvoice = api.email.scheduleInvoice.useMutation({
|
|
onSuccess: async (data) => {
|
|
await utils.invoices.getById.invalidate({ id: id ?? "" });
|
|
await utils.invoices.getAll.invalidate();
|
|
Alert.alert(
|
|
"Invoice scheduled",
|
|
`It will send ${formatZonedDateTime(data.scheduledAt, data.timeZone)}.`,
|
|
[
|
|
{
|
|
text: "OK",
|
|
onPress: () => router.replace(`/(app)/invoices/${id}`),
|
|
},
|
|
],
|
|
);
|
|
},
|
|
onError: (err) => Alert.alert("Could not schedule invoice", err.message),
|
|
});
|
|
|
|
const cancelScheduledInvoice = api.email.cancelScheduledInvoice.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.invoices.getById.invalidate({ id: id ?? "" });
|
|
await utils.invoices.getAll.invalidate();
|
|
Alert.alert("Scheduled send cancelled");
|
|
},
|
|
onError: (err) =>
|
|
Alert.alert("Could not cancel scheduled send", 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,
|
|
});
|
|
}
|
|
|
|
function handleSchedule() {
|
|
if (!clientEmail || invoice.items.length === 0) return;
|
|
let instant: Date;
|
|
try {
|
|
instant = zonedDateTimeToInstant(
|
|
toLocalDateTimeInputValue(scheduledAt),
|
|
timeZone,
|
|
"earlier",
|
|
);
|
|
} catch (error) {
|
|
Alert.alert(
|
|
"Choose another time",
|
|
error instanceof Error ? error.message : "Invalid local time",
|
|
);
|
|
return;
|
|
}
|
|
if (instant.getTime() < Date.now() + 60_000) {
|
|
Alert.alert(
|
|
"Choose a future time",
|
|
"The scheduled time must be at least one minute from now.",
|
|
);
|
|
return;
|
|
}
|
|
scheduleInvoice.mutate({
|
|
invoiceId: invoice.id,
|
|
scheduledAt: instant,
|
|
timeZone,
|
|
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>
|
|
|
|
{invoice.scheduledSendStatus === "pending" &&
|
|
invoice.scheduledSendAt ? (
|
|
<Card title="Scheduled send">
|
|
<Text
|
|
style={[styles.messageHint, { color: colors.mutedForeground }]}
|
|
>
|
|
{formatZonedDateTime(
|
|
invoice.scheduledSendAt,
|
|
invoice.scheduledSendTimeZone ?? timeZone,
|
|
)}{" "}
|
|
({invoice.scheduledSendTimeZone ?? timeZone})
|
|
</Text>
|
|
<Button
|
|
title="Cancel scheduled send"
|
|
variant="secondary"
|
|
onPress={() =>
|
|
cancelScheduledInvoice.mutate({ invoiceId: invoice.id })
|
|
}
|
|
loading={cancelScheduledInvoice.isPending}
|
|
/>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Card title="Send later">
|
|
<DateTimeField
|
|
label="Send date and time"
|
|
value={scheduledAt}
|
|
minimumDate={new Date(Date.now() + 60_000)}
|
|
maximumDate={new Date(2100, 0, 1)}
|
|
onChange={setScheduledAt}
|
|
/>
|
|
<Text
|
|
style={[styles.messageHint, { color: colors.mutedForeground }]}
|
|
>
|
|
Time zone: {timeZone}. The exact instant is preserved across
|
|
devices and daylight saving changes.
|
|
</Text>
|
|
<Button
|
|
title="Schedule invoice"
|
|
variant="secondary"
|
|
onPress={handleSchedule}
|
|
loading={scheduleInvoice.isPending}
|
|
disabled={!clientEmail || invoice.items.length === 0}
|
|
/>
|
|
</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",
|
|
},
|
|
});
|