Add scheduled invoice delivery

This commit is contained in:
2026-08-17 13:54:45 -04:00
parent 29589c1f32
commit 67ab6b78bd
26 changed files with 2099 additions and 835 deletions
+124 -7
View File
@@ -16,6 +16,12 @@ 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,
getLocalTimeZone,
} from "@beenvoice/domain/time-zone";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
@@ -33,6 +39,10 @@ export default function InvoiceSendScreen() {
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [customMessage, setCustomMessage] = useState("");
const [scheduledAt, setScheduledAt] = useState(() =>
getDefaultScheduledSendAt(),
);
const timeZone = useMemo(() => getLocalTimeZone(), []);
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
@@ -51,9 +61,39 @@ export default function InvoiceSendScreen() {
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
? buildPreviewPdfInputFromInvoice(invoiceQuery.data)
: null,
[invoiceQuery.data],
);
@@ -97,22 +137,49 @@ export default function InvoiceSendScreen() {
});
}
function handleSchedule() {
if (!clientEmail || invoice.items.length === 0) return;
if (scheduledAt.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,
timeZone,
customMessage: customMessage.trim() || undefined,
});
}
return (
<AppBackground>
<Stack.Screen options={{ title: sendLabel, headerBackTitle: "Invoice" }} />
<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}
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="To"
value={clientEmail || "No client email on file"}
/>
<SummaryRow
label="Invoice"
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
@@ -130,7 +197,9 @@ export default function InvoiceSendScreen() {
</Card>
<Card title="Message">
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}>
<Text
style={[styles.messageHint, { color: colors.mutedForeground }]}
>
Optional note included in the email body.
</Text>
<Input
@@ -143,6 +212,52 @@ export default function InvoiceSendScreen() {
/>
</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}
@@ -176,7 +291,9 @@ function SummaryRow({
const { colors } = useAppTheme();
return (
<View style={summaryStyles.row}>
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text
style={[
summaryStyles.value,