Add scheduled invoice delivery
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
|
||||
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";
|
||||
@@ -21,6 +31,7 @@ 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";
|
||||
import { formatZonedDateTime } from "@beenvoice/domain/time-zone";
|
||||
|
||||
export default function InvoiceDetailScreen() {
|
||||
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||||
@@ -53,7 +64,10 @@ export default function InvoiceDetailScreen() {
|
||||
});
|
||||
|
||||
const previewInput = useMemo(
|
||||
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
|
||||
() =>
|
||||
invoiceQuery.data
|
||||
? buildPreviewPdfInputFromInvoice(invoiceQuery.data)
|
||||
: null,
|
||||
[invoiceQuery.data],
|
||||
);
|
||||
|
||||
@@ -73,7 +87,11 @@ export default function InvoiceDetailScreen() {
|
||||
<Text style={styles.errorText}>
|
||||
{invoiceQuery.error?.message ?? "Invoice not found"}
|
||||
</Text>
|
||||
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
|
||||
<Button
|
||||
title="Go back"
|
||||
variant="secondary"
|
||||
onPress={() => router.back()}
|
||||
/>
|
||||
</View>
|
||||
</AppBackground>
|
||||
);
|
||||
@@ -126,18 +144,22 @@ export default function InvoiceDetailScreen() {
|
||||
}
|
||||
|
||||
function promptStatusChange(current: InvoiceStatus) {
|
||||
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||
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 (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 }),
|
||||
onPress: () =>
|
||||
updateStatus.mutate({ id: invoice.id, status: option.status }),
|
||||
})),
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]);
|
||||
@@ -148,8 +170,13 @@ export default function InvoiceDetailScreen() {
|
||||
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={
|
||||
Platform.OS === "ios" ? "never" : undefined
|
||||
}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
@@ -160,7 +187,9 @@ export default function InvoiceDetailScreen() {
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
||||
<Text style={styles.clientName}>
|
||||
{invoice.client?.name ?? "Client"}
|
||||
</Text>
|
||||
</View>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
@@ -183,103 +212,130 @@ export default function InvoiceDetailScreen() {
|
||||
</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)
|
||||
<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}
|
||||
{invoice.scheduledSendStatus === "pending" &&
|
||||
invoice.scheduledSendAt ? (
|
||||
<DetailRow
|
||||
label="Scheduled send"
|
||||
value={formatZonedDateTime(
|
||||
invoice.scheduledSendAt,
|
||||
invoice.scheduledSendTimeZone ?? "UTC",
|
||||
)}
|
||||
/>
|
||||
) : 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}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : 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>
|
||||
@@ -291,8 +347,12 @@ 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>
|
||||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[detailStyles.value, { color: colors.foreground }]}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -316,93 +376,93 @@ const detailStyles = StyleSheet.create({
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user