Make scheduling and dates timezone-safe
This commit is contained in:
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
|||||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||||
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
import {
|
||||||
|
LineItemEditor,
|
||||||
|
type EditableLineItem,
|
||||||
|
} from "@/components/invoices/LineItemEditor";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
@@ -35,6 +38,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
|||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
export default function InvoiceEditScreen() {
|
export default function InvoiceEditScreen() {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
@@ -53,7 +57,9 @@ export default function InvoiceEditScreen() {
|
|||||||
const [businessId, setBusinessId] = useState("");
|
const [businessId, setBusinessId] = useState("");
|
||||||
const [clientId, setClientId] = useState("");
|
const [clientId, setClientId] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [dueDate, setDueDate] = useState(() => new Date());
|
const [dueDate, setDueDate] = useState(() =>
|
||||||
|
calendarDateFromLocalDate(new Date()),
|
||||||
|
);
|
||||||
const [taxRate, setTaxRate] = useState("0");
|
const [taxRate, setTaxRate] = useState("0");
|
||||||
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
||||||
const [items, setItems] = useState<EditableLineItem[]>([]);
|
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||||
@@ -68,7 +74,9 @@ export default function InvoiceEditScreen() {
|
|||||||
setNotes(invoice.notes ?? "");
|
setNotes(invoice.notes ?? "");
|
||||||
setDueDate(new Date(invoice.dueDate));
|
setDueDate(new Date(invoice.dueDate));
|
||||||
setTaxRate(String(invoice.taxRate));
|
setTaxRate(String(invoice.taxRate));
|
||||||
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
|
setSendReminderAt(
|
||||||
|
invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null,
|
||||||
|
);
|
||||||
setItems(
|
setItems(
|
||||||
invoice.items.map((item) => ({
|
invoice.items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -119,9 +127,14 @@ export default function InvoiceEditScreen() {
|
|||||||
[clientsQuery.data],
|
[clientsQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
const selectedClient = clientsQuery.data?.find(
|
||||||
|
(client) => client.id === clientId,
|
||||||
|
);
|
||||||
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
|
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
|
||||||
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
const resolvedBusinessId = resolveInvoiceBusinessId(
|
||||||
|
businessId,
|
||||||
|
businessesQuery.data,
|
||||||
|
);
|
||||||
|
|
||||||
const subtotal = useMemo(
|
const subtotal = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -137,8 +150,12 @@ export default function InvoiceEditScreen() {
|
|||||||
const taxAmount = subtotal * (parsedTaxRate / 100);
|
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
const lineItemsError = isDraft ? validateLineItems(items) : null;
|
const lineItemsError = isDraft ? validateLineItems(items) : null;
|
||||||
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
|
const taxError =
|
||||||
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
|
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 clientError = isDraft && !clientId ? "Select a client" : undefined;
|
||||||
const canSave = isDraft
|
const canSave = isDraft
|
||||||
? !lineItemsError && !taxError && !businessError && !clientError
|
? !lineItemsError && !taxError && !businessError && !clientError
|
||||||
@@ -159,13 +176,26 @@ export default function InvoiceEditScreen() {
|
|||||||
currency,
|
currency,
|
||||||
items,
|
items,
|
||||||
});
|
});
|
||||||
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
|
}, [
|
||||||
|
invoice,
|
||||||
|
resolvedBusinessId,
|
||||||
|
clientId,
|
||||||
|
dueDate,
|
||||||
|
notes,
|
||||||
|
parsedTaxRate,
|
||||||
|
currency,
|
||||||
|
items,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return <LoadingScreen message="Invalid invoice" />;
|
return <LoadingScreen message="Invalid invoice" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
|
if (
|
||||||
|
invoiceQuery.isLoading ||
|
||||||
|
businessesQuery.isLoading ||
|
||||||
|
clientsQuery.isLoading
|
||||||
|
) {
|
||||||
return <LoadingScreen message="Loading invoice…" />;
|
return <LoadingScreen message="Loading invoice…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,14 +207,16 @@ export default function InvoiceEditScreen() {
|
|||||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
|
|
||||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
setItems((prev) =>
|
||||||
|
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addItem() {
|
function addItem() {
|
||||||
setItems((prev) => [
|
setItems((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
date: new Date(),
|
date: calendarDateFromLocalDate(new Date()),
|
||||||
description: "",
|
description: "",
|
||||||
hours: "1",
|
hours: "1",
|
||||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||||
@@ -260,8 +292,13 @@ export default function InvoiceEditScreen() {
|
|||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
contentContainerStyle={[
|
||||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
styles.container,
|
||||||
|
{ paddingBottom: scrollPadding },
|
||||||
|
]}
|
||||||
|
contentInsetAdjustmentBehavior={
|
||||||
|
Platform.OS === "ios" ? "automatic" : undefined
|
||||||
|
}
|
||||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
@@ -316,13 +353,13 @@ export default function InvoiceEditScreen() {
|
|||||||
<Card title="Line items">
|
<Card title="Line items">
|
||||||
{!isDraft ? (
|
{!isDraft ? (
|
||||||
<Text style={styles.lockedHint}>
|
<Text style={styles.lockedHint}>
|
||||||
Line items are locked after an invoice is sent. Mark as draft on the invoice
|
Line items are locked after an invoice is sent. Mark as
|
||||||
screen to edit entries.
|
draft on the invoice screen to edit entries.
|
||||||
</Text>
|
</Text>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<Text style={styles.emptyLines}>
|
<Text style={styles.emptyLines}>
|
||||||
No line items yet. Add lines here or clock time to this invoice from the
|
No line items yet. Add lines here or clock time to this
|
||||||
Timer tab.
|
invoice from the Timer tab.
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
@@ -334,26 +371,40 @@ export default function InvoiceEditScreen() {
|
|||||||
isLast={index === items.length - 1}
|
isLast={index === items.length - 1}
|
||||||
onChange={(patch) => updateItem(index, patch)}
|
onChange={(patch) => updateItem(index, patch)}
|
||||||
onRemove={() => removeItem(index)}
|
onRemove={() => removeItem(index)}
|
||||||
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
|
onDuplicate={
|
||||||
|
isDraft ? () => duplicateItem(index) : undefined
|
||||||
|
}
|
||||||
readOnly={!isDraft}
|
readOnly={!isDraft}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={addItem}
|
||||||
|
style={styles.addLine}
|
||||||
|
>
|
||||||
<Text style={styles.addLineText}>+ Add another line</Text>
|
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<InvoiceTotals
|
<InvoiceTotals
|
||||||
subtotal={formatCurrency(subtotal, currency)}
|
subtotal={formatCurrency(subtotal, currency)}
|
||||||
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
taxLabel={
|
||||||
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
|
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
|
||||||
|
}
|
||||||
|
taxAmount={
|
||||||
|
parsedTaxRate > 0
|
||||||
|
? formatCurrency(taxAmount, currency)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
total={formatCurrency(total, currency)}
|
total={formatCurrency(total, currency)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
{lineItemsError ? (
|
||||||
|
<Text style={styles.error}>{lineItemsError}</Text>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -367,7 +418,8 @@ export default function InvoiceEditScreen() {
|
|||||||
secondary={
|
secondary={
|
||||||
status !== "paid"
|
status !== "paid"
|
||||||
? {
|
? {
|
||||||
title: status === "draft" ? "Send invoice" : "Resend invoice",
|
title:
|
||||||
|
status === "draft" ? "Send invoice" : "Resend invoice",
|
||||||
subtitle: clientEmail
|
subtitle: clientEmail
|
||||||
? items.length === 0
|
? items.length === 0
|
||||||
? "Add line items before sending"
|
? "Add line items before sending"
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
|||||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||||
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
import {
|
||||||
|
LineItemEditor,
|
||||||
|
type EditableLineItem,
|
||||||
|
} from "@/components/invoices/LineItemEditor";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
@@ -39,6 +42,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
|||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
export default function NewInvoiceScreen() {
|
export default function NewInvoiceScreen() {
|
||||||
const styles = useThemedStyles(createNewInvoiceStyles);
|
const styles = useThemedStyles(createNewInvoiceStyles);
|
||||||
@@ -53,8 +57,12 @@ export default function NewInvoiceScreen() {
|
|||||||
const [businessId, setBusinessId] = useState("");
|
const [businessId, setBusinessId] = useState("");
|
||||||
const [clientId, setClientId] = useState("");
|
const [clientId, setClientId] = useState("");
|
||||||
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
|
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
|
||||||
const [issueDate, setIssueDate] = useState(() => new Date());
|
const [issueDate, setIssueDate] = useState(() =>
|
||||||
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
|
calendarDateFromLocalDate(new Date()),
|
||||||
|
);
|
||||||
|
const [dueDate, setDueDate] = useState(() =>
|
||||||
|
defaultDueDate(calendarDateFromLocalDate(new Date())),
|
||||||
|
);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [taxRate, setTaxRate] = useState("0");
|
const [taxRate, setTaxRate] = useState("0");
|
||||||
const [items, setItems] = useState<EditableLineItem[]>(() =>
|
const [items, setItems] = useState<EditableLineItem[]>(() =>
|
||||||
@@ -62,7 +70,7 @@ export default function NewInvoiceScreen() {
|
|||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
date: new Date(),
|
date: calendarDateFromLocalDate(new Date()),
|
||||||
description: "",
|
description: "",
|
||||||
hours: "1",
|
hours: "1",
|
||||||
rate: "0",
|
rate: "0",
|
||||||
@@ -96,9 +104,14 @@ export default function NewInvoiceScreen() {
|
|||||||
[clientsQuery.data],
|
[clientsQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
const selectedClient = clientsQuery.data?.find(
|
||||||
|
(client) => client.id === clientId,
|
||||||
|
);
|
||||||
const currency = selectedClient?.currency ?? "USD";
|
const currency = selectedClient?.currency ?? "USD";
|
||||||
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
const resolvedBusinessId = resolveInvoiceBusinessId(
|
||||||
|
businessId,
|
||||||
|
businessesQuery.data,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedClient?.defaultHourlyRate) return;
|
if (!selectedClient?.defaultHourlyRate) return;
|
||||||
@@ -170,7 +183,9 @@ export default function NewInvoiceScreen() {
|
|||||||
const invoiceNumberError = isRequiredString(invoiceNumber)
|
const invoiceNumberError = isRequiredString(invoiceNumber)
|
||||||
? undefined
|
? undefined
|
||||||
: "Invoice number is required";
|
: "Invoice number is required";
|
||||||
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
|
const taxError = isValidTaxRate(taxRate)
|
||||||
|
? undefined
|
||||||
|
: "Tax rate must be between 0 and 100";
|
||||||
const lineItemsError = validateLineItems(items);
|
const lineItemsError = validateLineItems(items);
|
||||||
const canCreate =
|
const canCreate =
|
||||||
businessOptions.length > 0 &&
|
businessOptions.length > 0 &&
|
||||||
@@ -187,7 +202,9 @@ export default function NewInvoiceScreen() {
|
|||||||
|
|
||||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||||
touch("lineItems");
|
touch("lineItems");
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
setItems((prev) =>
|
||||||
|
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addItem() {
|
function addItem() {
|
||||||
@@ -195,7 +212,7 @@ export default function NewInvoiceScreen() {
|
|||||||
setItems((prev) => [
|
setItems((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
date: new Date(),
|
date: calendarDateFromLocalDate(new Date()),
|
||||||
description: "",
|
description: "",
|
||||||
hours: "1",
|
hours: "1",
|
||||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||||
@@ -266,8 +283,13 @@ export default function NewInvoiceScreen() {
|
|||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
contentContainerStyle={[
|
||||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
styles.container,
|
||||||
|
{ paddingBottom: scrollPadding },
|
||||||
|
]}
|
||||||
|
contentInsetAdjustmentBehavior={
|
||||||
|
Platform.OS === "ios" ? "automatic" : undefined
|
||||||
|
}
|
||||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
@@ -287,7 +309,11 @@ export default function NewInvoiceScreen() {
|
|||||||
: "Add a client before creating an invoice."}
|
: "Add a client before creating an invoice."}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
title={businessOptions.length === 0 ? "Add business" : "Add client"}
|
title={
|
||||||
|
businessOptions.length === 0
|
||||||
|
? "Add business"
|
||||||
|
: "Add client"
|
||||||
|
}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push(
|
router.push(
|
||||||
@@ -303,7 +329,9 @@ export default function NewInvoiceScreen() {
|
|||||||
businessId={businessId}
|
businessId={businessId}
|
||||||
onBusinessIdChange={setBusinessId}
|
onBusinessIdChange={setBusinessId}
|
||||||
businessOptions={businessOptions}
|
businessOptions={businessOptions}
|
||||||
businessError={visible("business") ? businessError : undefined}
|
businessError={
|
||||||
|
visible("business") ? businessError : undefined
|
||||||
|
}
|
||||||
onBusinessBlur={() => touch("business")}
|
onBusinessBlur={() => touch("business")}
|
||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
onClientIdChange={setClientId}
|
onClientIdChange={setClientId}
|
||||||
@@ -334,8 +362,8 @@ export default function NewInvoiceScreen() {
|
|||||||
<Card title="Line items">
|
<Card title="Line items">
|
||||||
{isBlank && items.length === 0 ? (
|
{isBlank && items.length === 0 ? (
|
||||||
<Text style={styles.emptyLines}>
|
<Text style={styles.emptyLines}>
|
||||||
No line items yet. Save this draft and clock time to it from the Timer tab,
|
No line items yet. Save this draft and clock time to it from
|
||||||
or add lines here.
|
the Timer tab, or add lines here.
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
@@ -351,27 +379,41 @@ export default function NewInvoiceScreen() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={addItem}
|
||||||
|
style={styles.addLine}
|
||||||
|
>
|
||||||
<Text style={styles.addLineText}>+ Add another line</Text>
|
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<InvoiceTotals
|
<InvoiceTotals
|
||||||
subtotal={formatCurrency(subtotal, currency)}
|
subtotal={formatCurrency(subtotal, currency)}
|
||||||
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
taxLabel={
|
||||||
|
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
|
||||||
|
}
|
||||||
taxAmount={
|
taxAmount={
|
||||||
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
|
parsedTaxRate > 0
|
||||||
|
? formatCurrency(taxAmount, currency)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
total={formatCurrency(total, currency)}
|
total={formatCurrency(total, currency)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{visible("lineItems") && lineItemsError ? (
|
{visible("lineItems") && lineItemsError ? (
|
||||||
<Text selectable style={styles.error}>{lineItemsError}</Text>
|
<Text selectable style={styles.error}>
|
||||||
|
{lineItemsError}
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
|
{error ? (
|
||||||
|
<Text selectable style={styles.error}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<InvoiceEditorFooter
|
<InvoiceEditorFooter
|
||||||
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import { DateTimeField } from "@/components/ui/DateTimeField";
|
|||||||
import {
|
import {
|
||||||
formatZonedDateTime,
|
formatZonedDateTime,
|
||||||
getDefaultScheduledSendAt,
|
getDefaultScheduledSendAt,
|
||||||
getLocalTimeZone,
|
DEFAULT_TIME_ZONE,
|
||||||
|
toLocalDateTimeInputValue,
|
||||||
|
zonedDateTimeToInstant,
|
||||||
} from "@beenvoice/domain/time-zone";
|
} from "@beenvoice/domain/time-zone";
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
@@ -42,7 +44,8 @@ export default function InvoiceSendScreen() {
|
|||||||
const [scheduledAt, setScheduledAt] = useState(() =>
|
const [scheduledAt, setScheduledAt] = useState(() =>
|
||||||
getDefaultScheduledSendAt(),
|
getDefaultScheduledSendAt(),
|
||||||
);
|
);
|
||||||
const timeZone = useMemo(() => getLocalTimeZone(), []);
|
const profileQuery = api.settings.getProfile.useQuery();
|
||||||
|
const timeZone = profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE;
|
||||||
|
|
||||||
const invoiceQuery = api.invoices.getById.useQuery(
|
const invoiceQuery = api.invoices.getById.useQuery(
|
||||||
{ id: id ?? "" },
|
{ id: id ?? "" },
|
||||||
@@ -139,7 +142,21 @@ export default function InvoiceSendScreen() {
|
|||||||
|
|
||||||
function handleSchedule() {
|
function handleSchedule() {
|
||||||
if (!clientEmail || invoice.items.length === 0) return;
|
if (!clientEmail || invoice.items.length === 0) return;
|
||||||
if (scheduledAt.getTime() < Date.now() + 60_000) {
|
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(
|
Alert.alert(
|
||||||
"Choose a future time",
|
"Choose a future time",
|
||||||
"The scheduled time must be at least one minute from now.",
|
"The scheduled time must be at least one minute from now.",
|
||||||
@@ -148,7 +165,7 @@ export default function InvoiceSendScreen() {
|
|||||||
}
|
}
|
||||||
scheduleInvoice.mutate({
|
scheduleInvoice.mutate({
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
scheduledAt,
|
scheduledAt: instant,
|
||||||
timeZone,
|
timeZone,
|
||||||
customMessage: customMessage.trim() || undefined,
|
customMessage: customMessage.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
|
|||||||
import { formatCurrency, formatDate } from "@/lib/format";
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
|
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
type ReceiptSplitDraft = Pick<
|
type ReceiptSplitDraft = Pick<
|
||||||
ReceiptScanResult,
|
ReceiptScanResult,
|
||||||
@@ -37,7 +38,7 @@ export default function ExpenseDetailScreen() {
|
|||||||
const [form, setForm] = useState<ExpenseFormState>({
|
const [form, setForm] = useState<ExpenseFormState>({
|
||||||
description: "",
|
description: "",
|
||||||
amountText: "",
|
amountText: "",
|
||||||
date: new Date(),
|
date: calendarDateFromLocalDate(new Date()),
|
||||||
category: "",
|
category: "",
|
||||||
businessId: "",
|
businessId: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
@@ -165,7 +166,9 @@ export default function ExpenseDetailScreen() {
|
|||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
<TabPage showMoreBack>
|
<TabPage showMoreBack>
|
||||||
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
|
<TabScrollView
|
||||||
|
header={<PageHeader title="Expense" subtitle="Expense details" />}
|
||||||
|
>
|
||||||
<Text style={{ color: colors.mutedForeground }}>
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
Expense not found
|
Expense not found
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import type { AppRouter } from "beenvoice/server/api/root";
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
import type { inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
|
||||||
@@ -26,6 +21,7 @@ import { api } from "@/lib/trpc";
|
|||||||
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
type ExpenseFilter = "all" | "billable" | "receipts";
|
type ExpenseFilter = "all" | "billable" | "receipts";
|
||||||
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
|
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
|
||||||
@@ -120,17 +116,27 @@ export default function ExpensesScreen() {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.emptyCard,
|
styles.emptyCard,
|
||||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.cardGlass,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
|
<View
|
||||||
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
|
style={[styles.emptyIcon, { backgroundColor: colors.muted }]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="receipt-outline"
|
||||||
|
size={24}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
||||||
No expenses yet
|
No expenses yet
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||||
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
|
Scan a receipt or add a manual entry when something needs to be
|
||||||
|
tracked, billed, or reimbursed.
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
title="Add expense"
|
title="Add expense"
|
||||||
@@ -140,9 +146,18 @@ export default function ExpensesScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<View style={styles.summaryGrid}>
|
<View style={styles.summaryGrid}>
|
||||||
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
|
<SummaryTile
|
||||||
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
|
label="Visible total"
|
||||||
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
|
value={formatCurrency(summary.total)}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
label="Billable"
|
||||||
|
value={formatCurrency(summary.billable)}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
label="Receipts"
|
||||||
|
value={String(summary.receiptCount)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -174,14 +189,21 @@ export default function ExpensesScreen() {
|
|||||||
) : (
|
) : (
|
||||||
groupedExpenses.map(([monthLabel, group]) => (
|
groupedExpenses.map(([monthLabel, group]) => (
|
||||||
<View key={monthLabel} style={styles.monthGroup}>
|
<View key={monthLabel} style={styles.monthGroup}>
|
||||||
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.monthLabel,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{monthLabel}
|
{monthLabel}
|
||||||
</Text>
|
</Text>
|
||||||
{group.map((expense) => (
|
{group.map((expense) => (
|
||||||
<ExpenseRow
|
<ExpenseRow
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
expense={expense}
|
expense={expense}
|
||||||
onDelete={() => deleteExpense.mutate({ id: expense.id })}
|
onDelete={() =>
|
||||||
|
deleteExpense.mutate({ id: expense.id })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -209,14 +231,23 @@ function SummaryTile({ label, value }: { label: string; value: string }) {
|
|||||||
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
|
<Text
|
||||||
|
style={[styles.summaryValue, { color: colors.foreground }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
|
function ExpenseRow({
|
||||||
|
expense,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
expense: Expense;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = useThemedStyles(createStyles);
|
const styles = useThemedStyles(createStyles);
|
||||||
|
|
||||||
@@ -236,7 +267,8 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
|||||||
icon: "open-outline",
|
icon: "open-outline",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
onPress: () => router.push(`/(app)/more/expenses/${expense.id}` as never),
|
onPress: () =>
|
||||||
|
router.push(`/(app)/more/expenses/${expense.id}` as never),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
@@ -249,45 +281,77 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
|
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
|
||||||
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
|
<Ionicons
|
||||||
|
name={expenseIcon(expense.category)}
|
||||||
|
size={18}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.meta}>
|
<View style={styles.meta}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
|
<Text
|
||||||
|
style={[styles.title, { color: colors.foreground }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{expense.description}
|
{expense.description}
|
||||||
</Text>
|
</Text>
|
||||||
{expense.receiptCount ? (
|
{expense.receiptCount ? (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.receiptPill,
|
styles.receiptPill,
|
||||||
{ borderColor: colors.border, backgroundColor: colors.background },
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
|
<Ionicons
|
||||||
|
name="document-attach-outline"
|
||||||
|
size={13}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
|
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
|
||||||
{expense.receiptCount}
|
{expense.receiptCount}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
<Text
|
||||||
|
style={[styles.sub, { color: colors.mutedForeground }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{formatDate(expense.date)}
|
{formatDate(expense.date)}
|
||||||
{expense.category ? ` · ${expense.category}` : ""}
|
{expense.category ? ` · ${expense.category}` : ""}
|
||||||
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.tagRow}>
|
<View style={styles.tagRow}>
|
||||||
{expense.billable ? (
|
{expense.billable ? (
|
||||||
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tag,
|
||||||
|
{ color: colors.primary, borderColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
Billable
|
Billable
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{expense.reimbursable ? (
|
{expense.reimbursable ? (
|
||||||
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tag,
|
||||||
|
{ color: colors.foreground, borderColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
Reimbursable
|
Reimbursable
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{expense.taxDeductible ? (
|
{expense.taxDeductible ? (
|
||||||
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tag,
|
||||||
|
{ color: colors.success, borderColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
Tax
|
Tax
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -297,7 +361,11 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
|||||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||||
{formatCurrency(expense.amount, expense.currency)}
|
{formatCurrency(expense.amount, expense.currency)}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={16}
|
||||||
|
color={colors.mutedForeground}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</SwipeableRow>
|
</SwipeableRow>
|
||||||
);
|
);
|
||||||
@@ -306,8 +374,7 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
|||||||
function groupExpensesByMonth(expenses: Expense[]) {
|
function groupExpensesByMonth(expenses: Expense[]) {
|
||||||
const groups = new Map<string, Expense[]>();
|
const groups = new Map<string, Expense[]>();
|
||||||
for (const expense of expenses) {
|
for (const expense of expenses) {
|
||||||
const date = new Date(expense.date);
|
const key = formatCalendarDate(expense.date, {
|
||||||
const key = date.toLocaleDateString(undefined, {
|
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
@@ -320,11 +387,16 @@ function groupExpensesByMonth(expenses: Expense[]) {
|
|||||||
|
|
||||||
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
|
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
|
||||||
const normalized = category?.toLowerCase() ?? "";
|
const normalized = category?.toLowerCase() ?? "";
|
||||||
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
|
if (normalized.includes("travel") || normalized.includes("mileage"))
|
||||||
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline";
|
return "airplane-outline";
|
||||||
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline";
|
if (normalized.includes("meal") || normalized.includes("food"))
|
||||||
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline";
|
return "restaurant-outline";
|
||||||
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline";
|
if (normalized.includes("software") || normalized.includes("subscription"))
|
||||||
|
return "laptop-outline";
|
||||||
|
if (normalized.includes("office") || normalized.includes("supply"))
|
||||||
|
return "briefcase-outline";
|
||||||
|
if (normalized.includes("phone") || normalized.includes("internet"))
|
||||||
|
return "wifi-outline";
|
||||||
return "receipt-outline";
|
return "receipt-outline";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,9 @@ export default function SettingsScreen() {
|
|||||||
Role: {profile.role}
|
Role: {profile.role}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Time zone: {profile?.timeZone ?? "America/New_York"}
|
||||||
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Accounts">
|
<Card title="Accounts">
|
||||||
|
|||||||
@@ -17,36 +17,53 @@ import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
|||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
import type { AppRouter } from "beenvoice/server/api/root";
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
import type { inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
import {
|
||||||
|
DEFAULT_TIME_ZONE,
|
||||||
|
getZonedDateTimeParts,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
|
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
|
||||||
|
|
||||||
function groupByDate(entries: TimeEntry[]) {
|
function groupByDate(entries: TimeEntry[], timeZone: string) {
|
||||||
const groups = new Map<string, typeof entries>();
|
const groups = new Map<string, typeof entries>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const d = new Date(entry.startedAt);
|
const d = new Date(entry.startedAt);
|
||||||
const key = d.toLocaleDateString(undefined, {
|
const parts = getZonedDateTimeParts(d, timeZone);
|
||||||
|
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
||||||
|
const list = groups.get(dateKey) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
groups.set(dateKey, list);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).map(
|
||||||
|
([, groupedEntries]) =>
|
||||||
|
[
|
||||||
|
new Date(groupedEntries[0]!.startedAt).toLocaleDateString(undefined, {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
timeZone,
|
||||||
const list = groups.get(key) ?? [];
|
}),
|
||||||
list.push(entry);
|
groupedEntries,
|
||||||
groups.set(key, list);
|
] as const,
|
||||||
}
|
);
|
||||||
return Array.from(groups.entries());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeEntriesScreen() {
|
export default function TimeEntriesScreen() {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
const entriesQuery = api.timeEntries.getAll.useQuery();
|
const entriesQuery = api.timeEntries.getAll.useQuery();
|
||||||
|
const profileQuery = api.settings.getProfile.useQuery();
|
||||||
|
|
||||||
const completed = useMemo(
|
const completed = useMemo(
|
||||||
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
|
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
|
||||||
[entriesQuery.data],
|
[entriesQuery.data],
|
||||||
);
|
);
|
||||||
const grouped = useMemo(() => groupByDate(completed), [completed]);
|
const grouped = useMemo(
|
||||||
|
() =>
|
||||||
|
groupByDate(completed, profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE),
|
||||||
|
[completed, profileQuery.data?.timeZone],
|
||||||
|
);
|
||||||
|
|
||||||
if (entriesQuery.isLoading) {
|
if (entriesQuery.isLoading) {
|
||||||
return <LoadingScreen message="Loading time entries…" />;
|
return <LoadingScreen message="Loading time entries…" />;
|
||||||
@@ -57,7 +74,10 @@ export default function TimeEntriesScreen() {
|
|||||||
<AppBackground>
|
<AppBackground>
|
||||||
<TabPage showMoreBack>
|
<TabPage showMoreBack>
|
||||||
<View style={styles.errorBox}>
|
<View style={styles.errorBox}>
|
||||||
<PageHeader title="Time entries" subtitle="Completed work history" />
|
<PageHeader
|
||||||
|
title="Time entries"
|
||||||
|
subtitle="Completed work history"
|
||||||
|
/>
|
||||||
<Text style={{ color: colors.mutedForeground }}>
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
{formatTrpcErrorMessage(entriesQuery.error)}
|
{formatTrpcErrorMessage(entriesQuery.error)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -72,7 +92,10 @@ export default function TimeEntriesScreen() {
|
|||||||
<TabPage showMoreBack>
|
<TabPage showMoreBack>
|
||||||
<TabScrollView
|
<TabScrollView
|
||||||
header={
|
header={
|
||||||
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
|
<PageHeader
|
||||||
|
title="Time entries"
|
||||||
|
subtitle={`${completed.length} completed entries`}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<PullToRefresh
|
<PullToRefresh
|
||||||
@@ -82,7 +105,9 @@ export default function TimeEntriesScreen() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{grouped.length === 0 ? (
|
{grouped.length === 0 ? (
|
||||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
<Text
|
||||||
|
style={{ color: colors.mutedForeground, fontFamily: fonts.body }}
|
||||||
|
>
|
||||||
No completed entries yet. Start the timer from the Timer tab.
|
No completed entries yet. Start the timer from the Timer tab.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
@@ -104,17 +129,26 @@ export default function TimeEntriesScreen() {
|
|||||||
>
|
>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={{ flex: 1, gap: 2 }}>
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
<Text style={[styles.title, { color: colors.foreground }]}>
|
<Text
|
||||||
|
style={[styles.title, { color: colors.foreground }]}
|
||||||
|
>
|
||||||
{formatRunningTimerLabel(entry.description)}
|
{formatRunningTimerLabel(entry.description)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{entry.client?.name ?? "No client"}
|
{entry.client?.name ?? "No client"}
|
||||||
{entry.invoice
|
{entry.invoice
|
||||||
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||||
: " · not billed"}
|
: " · not billed"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.title, { color: colors.foreground }]}>
|
<Text
|
||||||
|
style={[styles.title, { color: colors.foreground }]}
|
||||||
|
>
|
||||||
{entry.hours ?? "—"}h
|
{entry.hours ?? "—"}h
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
|
import Constants from "expo-constants";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, Platform, type AppStateStatus } from "react-native";
|
||||||
|
|
||||||
import { syncInvoiceSendReminders } from "@/lib/invoice-send-reminders";
|
import {
|
||||||
|
ensureNotificationPermissions,
|
||||||
|
syncInvoiceSendReminders,
|
||||||
|
} from "@/lib/invoice-send-reminders";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) {
|
function openInvoiceFromNotification(
|
||||||
|
data: Record<string, unknown> | undefined,
|
||||||
|
) {
|
||||||
if (data?.type !== "invoice-send-reminder") return;
|
if (data?.type !== "invoice-send-reminder") return;
|
||||||
const invoiceId = data.invoiceId;
|
const invoiceId = data.invoiceId;
|
||||||
if (typeof invoiceId !== "string" || !invoiceId) return;
|
if (typeof invoiceId !== "string" || !invoiceId) return;
|
||||||
@@ -21,14 +27,37 @@ export function InvoiceReminderSync() {
|
|||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
const wasBackgrounded = useRef(false);
|
const wasBackgrounded = useRef(false);
|
||||||
|
const [remotePushReady, setRemotePushReady] = useState(false);
|
||||||
|
const registerPushToken = api.notifications.registerPushToken.useMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== "ios" && Platform.OS !== "android") return;
|
||||||
|
void (async () => {
|
||||||
|
if (!(await ensureNotificationPermissions())) return;
|
||||||
|
const projectId =
|
||||||
|
Constants.easConfig?.projectId ??
|
||||||
|
(Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)
|
||||||
|
?.projectId;
|
||||||
|
if (!projectId) return;
|
||||||
|
const { data: token } = await Notifications.getExpoPushTokenAsync({
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
await registerPushToken.mutateAsync({ token, platform: Platform.OS });
|
||||||
|
setRemotePushReady(true);
|
||||||
|
})().catch(() => {
|
||||||
|
// Local reminders remain available when remote push registration is unavailable.
|
||||||
|
});
|
||||||
|
}, [registerPushToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!invoicesQuery.data) return;
|
if (!invoicesQuery.data) return;
|
||||||
void syncInvoiceSendReminders(invoicesQuery.data);
|
void syncInvoiceSendReminders(remotePushReady ? [] : invoicesQuery.data);
|
||||||
}, [invoicesQuery.data]);
|
}, [invoicesQuery.data, remotePushReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
(nextState: AppStateStatus) => {
|
||||||
if (nextState === "background" || nextState === "inactive") {
|
if (nextState === "background" || nextState === "inactive") {
|
||||||
wasBackgrounded.current = true;
|
wasBackgrounded.current = true;
|
||||||
return;
|
return;
|
||||||
@@ -37,19 +66,19 @@ export function InvoiceReminderSync() {
|
|||||||
if (nextState !== "active" || !wasBackgrounded.current) return;
|
if (nextState !== "active" || !wasBackgrounded.current) return;
|
||||||
wasBackgrounded.current = false;
|
wasBackgrounded.current = false;
|
||||||
void utils.invoices.getAll.invalidate({ status: "draft" });
|
void utils.invoices.getAll.invalidate({ status: "draft" });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return () => subscription.remove();
|
return () => subscription.remove();
|
||||||
}, [utils.invoices.getAll]);
|
}, [utils.invoices.getAll]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
|
const responseSubscription =
|
||||||
(response) => {
|
Notifications.addNotificationResponseReceivedListener((response) => {
|
||||||
openInvoiceFromNotification(
|
openInvoiceFromNotification(
|
||||||
response.notification.request.content.data as Record<string, unknown>,
|
response.notification.request.content.data as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||||
if (!response) return;
|
if (!response) return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SelectField, type SelectOption } from "@/components/ui/SelectField";
|
|||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
|
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
|
||||||
|
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
const NONE = "__none__";
|
const NONE = "__none__";
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export function defaultExpenseFormState(
|
|||||||
return {
|
return {
|
||||||
description: "",
|
description: "",
|
||||||
amountText: "",
|
amountText: "",
|
||||||
date: new Date(),
|
date: calendarDateFromLocalDate(new Date()),
|
||||||
category: "",
|
category: "",
|
||||||
businessId: defaultBusinessId,
|
businessId: defaultBusinessId,
|
||||||
clientId: "",
|
clientId: "",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SelectField } from "@/components/ui/SelectField";
|
|||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { defaultDueDate } from "@/lib/invoice-number";
|
import { defaultDueDate } from "@/lib/invoice-number";
|
||||||
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
type SelectOption = { label: string; value: string };
|
type SelectOption = { label: string; value: string };
|
||||||
|
|
||||||
@@ -120,7 +121,9 @@ export function InvoiceSetupForm({
|
|||||||
|
|
||||||
{invoiceNumberReadOnly ? (
|
{invoiceNumberReadOnly ? (
|
||||||
<View style={styles.readOnlyField}>
|
<View style={styles.readOnlyField}>
|
||||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
<Text
|
||||||
|
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
Invoice number
|
Invoice number
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||||
@@ -141,11 +144,13 @@ export function InvoiceSetupForm({
|
|||||||
|
|
||||||
{issueDateReadOnly ? (
|
{issueDateReadOnly ? (
|
||||||
<View style={styles.readOnlyField}>
|
<View style={styles.readOnlyField}>
|
||||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
<Text
|
||||||
|
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
Issue date
|
Issue date
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||||
{issueDate.toLocaleDateString()}
|
{formatCalendarDate(issueDate)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -160,11 +165,18 @@ export function InvoiceSetupForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} />
|
<DateTimeField
|
||||||
|
label="Due date"
|
||||||
|
mode="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={onDueDateChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{taxRateReadOnly ? (
|
{taxRateReadOnly ? (
|
||||||
<View style={styles.readOnlyField}>
|
<View style={styles.readOnlyField}>
|
||||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
<Text
|
||||||
|
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
Tax rate
|
Tax rate
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||||
@@ -186,7 +198,7 @@ export function InvoiceSetupForm({
|
|||||||
<>
|
<>
|
||||||
<DateTimeField
|
<DateTimeField
|
||||||
label="Remind me to send"
|
label="Remind me to send"
|
||||||
mode="date"
|
mode="datetime"
|
||||||
value={sendReminderAt ?? dueDate}
|
value={sendReminderAt ?? dueDate}
|
||||||
minimumDate={new Date()}
|
minimumDate={new Date()}
|
||||||
maximumDate={new Date(2100, 0, 1)}
|
maximumDate={new Date(2100, 0, 1)}
|
||||||
|
|||||||
@@ -3,11 +3,22 @@ import DateTimePicker, {
|
|||||||
type DateTimePickerEvent,
|
type DateTimePickerEvent,
|
||||||
} from "@react-native-community/datetimepicker";
|
} from "@react-native-community/datetimepicker";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Modal, Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
import {
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
import { fonts, radii, spacing } from "@/constants/theme";
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { formatDate, formatDateTime } from "@/lib/format";
|
import { formatDate, formatDateTime } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
calendarDateFromLocalDate,
|
||||||
|
calendarDateToLocalDate,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
type DateTimeFieldProps = {
|
type DateTimeFieldProps = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -31,17 +42,18 @@ export function DateTimeField({
|
|||||||
const [draft, setDraft] = useState(value);
|
const [draft, setDraft] = useState(value);
|
||||||
|
|
||||||
function openPicker() {
|
function openPicker() {
|
||||||
setDraft(value);
|
setDraft(mode === "date" ? calendarDateToLocalDate(value) : value);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDate(next: Date) {
|
function applyDate(next: Date) {
|
||||||
|
const normalized = mode === "date" ? calendarDateFromLocalDate(next) : next;
|
||||||
const clamped =
|
const clamped =
|
||||||
next.getTime() > maximumDate.getTime()
|
normalized.getTime() > maximumDate.getTime()
|
||||||
? maximumDate
|
? maximumDate
|
||||||
: minimumDate && next.getTime() < minimumDate.getTime()
|
: minimumDate && normalized.getTime() < minimumDate.getTime()
|
||||||
? minimumDate
|
? minimumDate
|
||||||
: next;
|
: normalized;
|
||||||
onChange(clamped);
|
onChange(clamped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +72,9 @@ export function DateTimeField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrapper}>
|
<View style={styles.wrapper}>
|
||||||
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
<Text style={[styles.label, { color: colors.mutedForeground }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessible
|
accessible
|
||||||
accessibilityLabel={`${label}, ${
|
accessibilityLabel={`${label}, ${
|
||||||
@@ -81,28 +95,53 @@ export function DateTimeField({
|
|||||||
<Text style={[styles.value, { color: colors.foreground }]}>
|
<Text style={[styles.value, { color: colors.foreground }]}>
|
||||||
{mode === "date" ? formatDate(value) : formatDateTime(value)}
|
{mode === "date" ? formatDate(value) : formatDateTime(value)}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons name="calendar-outline" size={18} color={colors.mutedForeground} />
|
<Ionicons
|
||||||
|
name="calendar-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.mutedForeground}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{Platform.OS === "ios" ? (
|
{Platform.OS === "ios" ? (
|
||||||
<Modal visible={open} transparent animationType="slide" onRequestClose={() => setOpen(false)}>
|
<Modal
|
||||||
|
visible={open}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.sheet, { backgroundColor: colors.card }]}
|
style={[styles.sheet, { backgroundColor: colors.card }]}
|
||||||
onPress={(event) => event.stopPropagation()}
|
onPress={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.sheetHeader,
|
||||||
|
{ borderBottomColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Pressable onPress={() => setOpen(false)}>
|
<Pressable onPress={() => setOpen(false)}>
|
||||||
<Text style={[styles.sheetAction, { color: colors.mutedForeground }]}>Cancel</Text>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sheetAction,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
|
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
applyDate(draft);
|
applyDate(draft);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={[styles.sheetAction, { color: colors.primary }]}>Done</Text>
|
<Text style={[styles.sheetAction, { color: colors.primary }]}>
|
||||||
|
Done
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
export function formatCurrency(amount: number, currency = "USD") {
|
export function formatCurrency(amount: number, currency = "USD") {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -7,7 +9,7 @@ export function formatCurrency(amount: number, currency = "USD") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: Date | string) {
|
export function formatDate(date: Date | string) {
|
||||||
return new Date(date).toLocaleDateString("en-US", {
|
return formatCalendarDate(date, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -15,7 +17,7 @@ export function formatDate(date: Date | string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatShortDate(date: Date | string) {
|
export function formatShortDate(date: Date | string) {
|
||||||
return new Date(date).toLocaleDateString("en-US", {
|
return formatCalendarDate(date, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { addCalendarDays } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
/** Matches web invoice-form default numbering. */
|
/** Matches web invoice-form default numbering. */
|
||||||
export function generateInvoiceNumber(now = new Date()): string {
|
export function generateInvoiceNumber(now = new Date()): string {
|
||||||
const date = [
|
const date = [
|
||||||
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function defaultDueDate(issueDate: Date): Date {
|
export function defaultDueDate(issueDate: Date): Date {
|
||||||
const due = new Date(issueDate);
|
return addCalendarDays(issueDate, 30);
|
||||||
due.setDate(due.getDate() + 30);
|
|
||||||
return due;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ export type InvoiceStatus = EffectiveInvoiceStatus;
|
|||||||
export function getInvoiceStatus(invoice: {
|
export function getInvoiceStatus(invoice: {
|
||||||
status: string;
|
status: string;
|
||||||
dueDate: Date | string;
|
dueDate: Date | string;
|
||||||
|
createdBy?: { timeZone: string } | null;
|
||||||
}): InvoiceStatus {
|
}): InvoiceStatus {
|
||||||
if (invoice.status === "paid" || invoice.status === "draft") {
|
if (invoice.status === "paid" || invoice.status === "draft") {
|
||||||
return invoice.status;
|
return invoice.status;
|
||||||
}
|
}
|
||||||
return getEffectiveInvoiceStatus("sent", invoice.dueDate);
|
return getEffectiveInvoiceStatus(
|
||||||
|
"sent",
|
||||||
|
invoice.dueDate,
|
||||||
|
invoice.createdBy?.timeZone ?? "America/New_York",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const statusLabels: Record<InvoiceStatus, string> = {
|
export const statusLabels: Record<InvoiceStatus, string> = {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "timeZone" varchar(100) DEFAULT 'America/New_York' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_recurring_invoice" ADD COLUMN IF NOT EXISTS "timeZone" varchar(100) DEFAULT 'America/New_York' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "sendReminderJobId" varchar(255);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_push_token" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"userId" varchar(255) NOT NULL REFERENCES "beenvoice_user"("id") ON DELETE cascade,
|
||||||
|
"token" varchar(255) NOT NULL UNIQUE,
|
||||||
|
"platform" varchar(20) NOT NULL,
|
||||||
|
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "push_token_user_id_idx" ON "beenvoice_push_token" USING btree ("userId");
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "issueDate" TYPE date USING "issueDate"::date;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "dueDate" TYPE date USING "dueDate"::date;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_item" ALTER COLUMN "date" TYPE date USING "date"::date;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ALTER COLUMN "date" TYPE date USING "date"::date;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_payment" ALTER COLUMN "date" TYPE date USING "date"::date;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" ALTER COLUMN "resetTokenExpiry" TYPE timestamp with time zone USING "resetTokenExpiry" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" ALTER COLUMN "onboardingCompletedAt" TYPE timestamp with time zone USING "onboardingCompletedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_audit_log" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_account" ALTER COLUMN "accessTokenExpiresAt" TYPE timestamp with time zone USING "accessTokenExpiresAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_account" ALTER COLUMN "refreshTokenExpiresAt" TYPE timestamp with time zone USING "refreshTokenExpiresAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_account" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_account" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_session" ALTER COLUMN "expiresAt" TYPE timestamp with time zone USING "expiresAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_session" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_session" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "lastUsedAt" TYPE timestamp with time zone USING "lastUsedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "expiresAt" TYPE timestamp with time zone USING "expiresAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "revokedAt" TYPE timestamp with time zone USING "revokedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "expiresAt" TYPE timestamp with time zone USING "expiresAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_sso_provider" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_sso_provider" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_client" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_client" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_business" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_business" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "publicTokenExpiresAt" TYPE timestamp with time zone USING "publicTokenExpiresAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "lastReminderSentAt" TYPE timestamp with time zone USING "lastReminderSentAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "sendReminderAt" TYPE timestamp with time zone USING "sendReminderAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_item" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense_receipt" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_template" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_template" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_payment" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "nextDueAt" TYPE timestamp with time zone USING "nextDueAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "lastGeneratedAt" TYPE timestamp with time zone USING "lastGeneratedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_recurring_invoice_item" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "startedAt" TYPE timestamp with time zone USING "startedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "endedAt" TYPE timestamp with time zone USING "endedAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||||
@@ -218,6 +218,13 @@
|
|||||||
"when": 1786946793000,
|
"when": 1786946793000,
|
||||||
"tag": "0030_scheduled_invoice_sends",
|
"tag": "0030_scheduled_invoice_sends",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786950000000,
|
||||||
|
"tag": "0031_timezone_safety",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type ToolResult = {
|
|||||||
type McpCaller = ReturnType<typeof createCaller>;
|
type McpCaller = ReturnType<typeof createCaller>;
|
||||||
|
|
||||||
const dateString = z.string().min(1);
|
const dateString = z.string().min(1);
|
||||||
|
const calendarDateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
const emptyableString = z.string().optional().or(z.literal(""));
|
const emptyableString = z.string().optional().or(z.literal(""));
|
||||||
const invoiceStatus = z.enum(["draft", "sent", "paid"]);
|
const invoiceStatus = z.enum(["draft", "sent", "paid"]);
|
||||||
const paymentMethod = z.enum([
|
const paymentMethod = z.enum([
|
||||||
@@ -26,7 +27,7 @@ const paymentMethod = z.enum([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const invoiceItemSchema = z.object({
|
const invoiceItemSchema = z.object({
|
||||||
date: dateString,
|
date: calendarDateString,
|
||||||
description: z.string().min(1),
|
description: z.string().min(1),
|
||||||
hours: z.number().min(0),
|
hours: z.number().min(0),
|
||||||
rate: z.number().min(0),
|
rate: z.number().min(0),
|
||||||
@@ -68,8 +69,8 @@ const invoiceCreateSchema = z.object({
|
|||||||
invoicePrefix: z.string().optional(),
|
invoicePrefix: z.string().optional(),
|
||||||
businessId: emptyableString,
|
businessId: emptyableString,
|
||||||
clientId: z.string().min(1),
|
clientId: z.string().min(1),
|
||||||
issueDate: dateString,
|
issueDate: calendarDateString,
|
||||||
dueDate: dateString,
|
dueDate: calendarDateString,
|
||||||
status: invoiceStatus.default("draft"),
|
status: invoiceStatus.default("draft"),
|
||||||
notes: emptyableString,
|
notes: emptyableString,
|
||||||
emailMessage: emptyableString,
|
emailMessage: emptyableString,
|
||||||
@@ -83,7 +84,7 @@ const invoiceUpdateSchema = invoiceCreateSchema.partial().extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const expenseCreateSchema = z.object({
|
const expenseCreateSchema = z.object({
|
||||||
date: dateString,
|
date: calendarDateString,
|
||||||
description: z.string().min(1),
|
description: z.string().min(1),
|
||||||
amount: z.number().min(0),
|
amount: z.number().min(0),
|
||||||
currency: z.string().length(3).default("USD"),
|
currency: z.string().length(3).default("USD"),
|
||||||
@@ -118,6 +119,9 @@ const recurringCreateSchema = z.object({
|
|||||||
currency: z.string().length(3).default("USD"),
|
currency: z.string().length(3).default("USD"),
|
||||||
notes: z.string().optional().or(z.literal("")),
|
notes: z.string().optional().or(z.literal("")),
|
||||||
emailMessage: z.string().optional().or(z.literal("")),
|
emailMessage: z.string().optional().or(z.literal("")),
|
||||||
|
timeZone: z.string().default("America/New_York"),
|
||||||
|
nextRunLocal: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/),
|
||||||
|
disambiguation: z.enum(["earlier", "later", "reject"]).default("reject"),
|
||||||
items: z.array(recurringItemSchema).min(1),
|
items: z.array(recurringItemSchema).min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,7 +155,7 @@ const jsonSchemas = {
|
|||||||
properties: {
|
properties: {
|
||||||
invoiceId: { type: "string" },
|
invoiceId: { type: "string" },
|
||||||
amount: { type: "number", exclusiveMinimum: 0 },
|
amount: { type: "number", exclusiveMinimum: 0 },
|
||||||
date: { type: "string", format: "date-time" },
|
date: { type: "string", format: "date" },
|
||||||
method: {
|
method: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: [
|
enum: [
|
||||||
@@ -193,11 +197,17 @@ const jsonSchemas = {
|
|||||||
invoicePrefix: { type: "string" },
|
invoicePrefix: { type: "string" },
|
||||||
businessId: { type: "string" },
|
businessId: { type: "string" },
|
||||||
clientId: { type: "string", minLength: 1 },
|
clientId: { type: "string", minLength: 1 },
|
||||||
issueDate: { type: "string", format: "date-time" },
|
issueDate: { type: "string", format: "date" },
|
||||||
dueDate: { type: "string", format: "date-time" },
|
dueDate: { type: "string", format: "date" },
|
||||||
status: { type: "string", enum: ["draft", "sent", "paid"] },
|
status: { type: "string", enum: ["draft", "sent", "paid"] },
|
||||||
notes: { type: "string" },
|
notes: { type: "string" },
|
||||||
emailMessage: { type: "string" },
|
emailMessage: { type: "string" },
|
||||||
|
timeZone: { type: "string", description: "IANA time zone" },
|
||||||
|
nextRunLocal: {
|
||||||
|
type: "string",
|
||||||
|
description: "First/next wall time as YYYY-MM-DDTHH:mm in timeZone",
|
||||||
|
},
|
||||||
|
disambiguation: { type: "string", enum: ["earlier", "later", "reject"] },
|
||||||
taxRate: { type: "number", minimum: 0, maximum: 100 },
|
taxRate: { type: "number", minimum: 0, maximum: 100 },
|
||||||
currency: { type: "string", minLength: 3, maxLength: 3 },
|
currency: { type: "string", minLength: 3, maxLength: 3 },
|
||||||
items: {
|
items: {
|
||||||
@@ -206,7 +216,7 @@ const jsonSchemas = {
|
|||||||
items: {
|
items: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
date: { type: "string", format: "date-time" },
|
date: { type: "string", format: "date" },
|
||||||
description: { type: "string", minLength: 1 },
|
description: { type: "string", minLength: 1 },
|
||||||
hours: { type: "number", minimum: 0 },
|
hours: { type: "number", minimum: 0 },
|
||||||
rate: { type: "number", minimum: 0 },
|
rate: { type: "number", minimum: 0 },
|
||||||
@@ -243,7 +253,7 @@ const jsonSchemas = {
|
|||||||
expenseCreate: {
|
expenseCreate: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
date: { type: "string", format: "date-time" },
|
date: { type: "string", format: "date" },
|
||||||
description: { type: "string", minLength: 1 },
|
description: { type: "string", minLength: 1 },
|
||||||
amount: { type: "number", minimum: 0 },
|
amount: { type: "number", minimum: 0 },
|
||||||
currency: { type: "string", minLength: 3, maxLength: 3 },
|
currency: { type: "string", minLength: 3, maxLength: 3 },
|
||||||
@@ -314,7 +324,7 @@ const jsonSchemas = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ["name", "clientId", "schedule", "items"],
|
required: ["name", "clientId", "schedule", "nextRunLocal", "items"],
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
invoiceSend: {
|
invoiceSend: {
|
||||||
@@ -413,10 +423,30 @@ function parseDate(value: string, fieldName: string) {
|
|||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCalendarDate(value: string, fieldName: string) {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `${fieldName} must use YYYY-MM-DD`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const date = new Date(`${value}T12:00:00.000Z`);
|
||||||
|
if (
|
||||||
|
Number.isNaN(date.getTime()) ||
|
||||||
|
date.toISOString().slice(0, 10) !== value
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `${fieldName} is not a valid date`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
function parseInvoiceItems(items: z.infer<typeof invoiceItemSchema>[]) {
|
function parseInvoiceItems(items: z.infer<typeof invoiceItemSchema>[]) {
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
date: parseDate(item.date, "item.date"),
|
date: parseCalendarDate(item.date, "item.date"),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,8 +494,8 @@ const tools = {
|
|||||||
handler: async (input, caller) =>
|
handler: async (input, caller) =>
|
||||||
caller.invoices.create({
|
caller.invoices.create({
|
||||||
...input,
|
...input,
|
||||||
issueDate: parseDate(input.issueDate, "issueDate"),
|
issueDate: parseCalendarDate(input.issueDate, "issueDate"),
|
||||||
dueDate: parseDate(input.dueDate, "dueDate"),
|
dueDate: parseCalendarDate(input.dueDate, "dueDate"),
|
||||||
items: parseInvoiceItems(input.items),
|
items: parseInvoiceItems(input.items),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -484,10 +514,10 @@ const tools = {
|
|||||||
caller.invoices.update({
|
caller.invoices.update({
|
||||||
...input,
|
...input,
|
||||||
issueDate: input.issueDate
|
issueDate: input.issueDate
|
||||||
? parseDate(input.issueDate, "issueDate")
|
? parseCalendarDate(input.issueDate, "issueDate")
|
||||||
: undefined,
|
: undefined,
|
||||||
dueDate: input.dueDate
|
dueDate: input.dueDate
|
||||||
? parseDate(input.dueDate, "dueDate")
|
? parseCalendarDate(input.dueDate, "dueDate")
|
||||||
: undefined,
|
: undefined,
|
||||||
items: input.items ? parseInvoiceItems(input.items) : undefined,
|
items: input.items ? parseInvoiceItems(input.items) : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -516,14 +546,14 @@ const tools = {
|
|||||||
schema: z.object({
|
schema: z.object({
|
||||||
invoiceId: z.string(),
|
invoiceId: z.string(),
|
||||||
amount: z.number().positive(),
|
amount: z.number().positive(),
|
||||||
date: dateString,
|
date: calendarDateString,
|
||||||
method: paymentMethod.default("other"),
|
method: paymentMethod.default("other"),
|
||||||
notes: z.string().max(500).optional(),
|
notes: z.string().max(500).optional(),
|
||||||
}),
|
}),
|
||||||
handler: async (input, caller) =>
|
handler: async (input, caller) =>
|
||||||
caller.payments.create({
|
caller.payments.create({
|
||||||
...input,
|
...input,
|
||||||
date: parseDate(input.date, "date"),
|
date: parseCalendarDate(input.date, "date"),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
payments_delete: defineTool({
|
payments_delete: defineTool({
|
||||||
@@ -829,7 +859,7 @@ const tools = {
|
|||||||
handler: async (input, caller) =>
|
handler: async (input, caller) =>
|
||||||
caller.expenses.create({
|
caller.expenses.create({
|
||||||
...input,
|
...input,
|
||||||
date: parseDate(input.date, "date"),
|
date: parseCalendarDate(input.date, "date"),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
expenses_update: defineTool({
|
expenses_update: defineTool({
|
||||||
@@ -847,7 +877,7 @@ const tools = {
|
|||||||
handler: async (input, caller) =>
|
handler: async (input, caller) =>
|
||||||
caller.expenses.update({
|
caller.expenses.update({
|
||||||
...input,
|
...input,
|
||||||
date: input.date ? parseDate(input.date, "date") : undefined,
|
date: input.date ? parseCalendarDate(input.date, "date") : undefined,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
expenses_delete: defineTool({
|
expenses_delete: defineTool({
|
||||||
@@ -876,7 +906,7 @@ const tools = {
|
|||||||
description: "Update a recurring invoice template. Replaces all items.",
|
description: "Update a recurring invoice template. Replaces all items.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
...jsonSchemas.recurringCreate,
|
...jsonSchemas.recurringCreate,
|
||||||
required: ["id", "name", "clientId", "schedule", "items"],
|
required: ["id", "name", "clientId", "schedule", "nextRunLocal", "items"],
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: "string" },
|
id: { type: "string" },
|
||||||
...jsonSchemas.recurringCreate.properties,
|
...jsonSchemas.recurringCreate.properties,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
interface ClientDetailPageProps {
|
interface ClientDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -34,17 +35,19 @@ export default async function ClientDetailPage({
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const client = await api.clients.getById({ id });
|
const client = await api.clients.getById({ id });
|
||||||
|
const profile = await api.settings.getProfile();
|
||||||
|
const timeZone = profile?.timeZone ?? "America/New_York";
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return formatCalendarDate(date, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(date);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
@@ -249,16 +252,19 @@ export default async function ClientDetailPage({
|
|||||||
getEffectiveInvoiceStatus(
|
getEffectiveInvoiceStatus(
|
||||||
invoice.status as StoredInvoiceStatus,
|
invoice.status as StoredInvoiceStatus,
|
||||||
invoice.dueDate,
|
invoice.dueDate,
|
||||||
|
timeZone,
|
||||||
) === "paid"
|
) === "paid"
|
||||||
? "default"
|
? "default"
|
||||||
: getEffectiveInvoiceStatus(
|
: getEffectiveInvoiceStatus(
|
||||||
invoice.status as StoredInvoiceStatus,
|
invoice.status as StoredInvoiceStatus,
|
||||||
invoice.dueDate,
|
invoice.dueDate,
|
||||||
|
timeZone,
|
||||||
) === "sent"
|
) === "sent"
|
||||||
? "secondary"
|
? "secondary"
|
||||||
: getEffectiveInvoiceStatus(
|
: getEffectiveInvoiceStatus(
|
||||||
invoice.status as StoredInvoiceStatus,
|
invoice.status as StoredInvoiceStatus,
|
||||||
invoice.dueDate,
|
invoice.dueDate,
|
||||||
|
timeZone,
|
||||||
) === "overdue"
|
) === "overdue"
|
||||||
? "destructive"
|
? "destructive"
|
||||||
: "outline"
|
: "outline"
|
||||||
@@ -268,6 +274,7 @@ export default async function ClientDetailPage({
|
|||||||
{getEffectiveInvoiceStatus(
|
{getEffectiveInvoiceStatus(
|
||||||
invoice.status as StoredInvoiceStatus,
|
invoice.status as StoredInvoiceStatus,
|
||||||
invoice.dueDate,
|
invoice.dueDate,
|
||||||
|
timeZone,
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
import {
|
||||||
|
calendarDateFromLocalDate,
|
||||||
|
formatCalendarDate,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -66,7 +70,7 @@ interface ExpenseFormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultForm: ExpenseFormData = {
|
const defaultForm: ExpenseFormData = {
|
||||||
date: new Date(),
|
date: calendarDateFromLocalDate(new Date()),
|
||||||
description: "",
|
description: "",
|
||||||
amount: 0,
|
amount: 0,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
@@ -473,11 +477,11 @@ export default function ExpensesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
{new Intl.DateTimeFormat("en-US", {
|
{formatCalendarDate(expense.date, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(new Date(expense.date))}
|
})}
|
||||||
{expense.business ? ` · ${expense.business.name}` : ""}
|
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||||
</p>
|
</p>
|
||||||
@@ -690,7 +694,10 @@ export default function ExpensesPage() {
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
date={form.date}
|
date={form.date}
|
||||||
onDateChange={(d) =>
|
onDateChange={(d) =>
|
||||||
setForm((p) => ({ ...p, date: d ?? new Date() }))
|
setForm((p) => ({
|
||||||
|
...p,
|
||||||
|
date: d ?? calendarDateFromLocalDate(new Date()),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,17 +2,15 @@
|
|||||||
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { DataTable } from "~/components/data/data-table";
|
import { DataTable } from "~/components/data/data-table";
|
||||||
import {
|
import { formatLineItemDetail, isFixedLineItem } from "~/lib/invoice-line-item";
|
||||||
formatLineItemDetail,
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
isFixedLineItem,
|
|
||||||
} from "~/lib/invoice-line-item";
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return formatCalendarDate(date, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(new Date(date));
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatZonedDateTime } from "@beenvoice/domain/time-zone";
|
import {
|
||||||
|
DEFAULT_TIME_ZONE,
|
||||||
|
calendarDateFromLocalDate,
|
||||||
|
formatCalendarDate,
|
||||||
|
formatZonedDateTime,
|
||||||
|
toZonedDateTimeInputValue,
|
||||||
|
zonedDateTimeToInstant,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
notFound,
|
notFound,
|
||||||
@@ -65,7 +72,6 @@ import { Separator } from "~/components/ui/separator";
|
|||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
|
||||||
import {
|
import {
|
||||||
getEffectiveInvoiceStatus,
|
getEffectiveInvoiceStatus,
|
||||||
isInvoiceOverdue,
|
isInvoiceOverdue,
|
||||||
@@ -110,6 +116,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
});
|
});
|
||||||
|
const { data: profile } = api.settings.getProfile.useQuery();
|
||||||
|
const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
|
||||||
const { data: payments, isLoading: paymentsLoading } =
|
const { data: payments, isLoading: paymentsLoading } =
|
||||||
api.payments.getByInvoice.useQuery({ invoiceId });
|
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -201,11 +209,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
if (!invoice) notFound();
|
if (!invoice) notFound();
|
||||||
|
|
||||||
const formatDate = (date: Date) =>
|
const formatDate = (date: Date) =>
|
||||||
new Intl.DateTimeFormat("en-US", {
|
formatCalendarDate(date, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(new Date(date));
|
});
|
||||||
|
|
||||||
const formatCurrency = (amount: number, currency = invoice.currency) =>
|
const formatCurrency = (amount: number, currency = invoice.currency) =>
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
|
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
|
||||||
@@ -221,8 +229,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
storedStatus,
|
storedStatus,
|
||||||
invoice.dueDate,
|
invoice.dueDate,
|
||||||
|
timeZone,
|
||||||
);
|
);
|
||||||
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
|
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate, timeZone);
|
||||||
const canSendReminder =
|
const canSendReminder =
|
||||||
effectiveStatus === "sent" || effectiveStatus === "overdue";
|
effectiveStatus === "sent" || effectiveStatus === "overdue";
|
||||||
|
|
||||||
@@ -246,7 +255,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
createPayment.mutate({
|
createPayment.mutate({
|
||||||
invoiceId,
|
invoiceId,
|
||||||
amount,
|
amount,
|
||||||
date: new Date(),
|
date: calendarDateFromLocalDate(new Date()),
|
||||||
method: paymentMethod as Parameters<
|
method: paymentMethod as Parameters<
|
||||||
typeof createPayment.mutate
|
typeof createPayment.mutate
|
||||||
>[0]["method"],
|
>[0]["method"],
|
||||||
@@ -694,7 +703,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
|
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
|
||||||
invoiceId={invoiceId}
|
invoiceId={invoiceId}
|
||||||
savedReminderAt={invoice.sendReminderAt}
|
savedReminderAt={invoice.sendReminderAt}
|
||||||
formatDate={formatDate}
|
timeZone={timeZone}
|
||||||
isSaving={updateInvoice.isPending}
|
isSaving={updateInvoice.isPending}
|
||||||
onSave={(sendReminderAt) =>
|
onSave={(sendReminderAt) =>
|
||||||
updateInvoice.mutate({
|
updateInvoice.mutate({
|
||||||
@@ -991,28 +1000,30 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
function SendReminderEditor({
|
function SendReminderEditor({
|
||||||
invoiceId,
|
invoiceId,
|
||||||
savedReminderAt,
|
savedReminderAt,
|
||||||
formatDate,
|
timeZone,
|
||||||
isSaving,
|
isSaving,
|
||||||
onSave,
|
onSave,
|
||||||
onClear,
|
onClear,
|
||||||
}: {
|
}: {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
savedReminderAt: Date | null | undefined;
|
savedReminderAt: Date | null | undefined;
|
||||||
formatDate: (date: Date) => string;
|
timeZone: string;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onSave: (sendReminderAt: Date | null) => void;
|
onSave: (sendReminderAt: Date | null) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() =>
|
const [sendReminderAt, setSendReminderAt] = useState(() =>
|
||||||
savedReminderAt ? new Date(savedReminderAt) : undefined,
|
savedReminderAt ? toZonedDateTimeInputValue(savedReminderAt, timeZone) : "",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 rounded-lg border p-3">
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
|
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
|
||||||
<DatePicker
|
<Input
|
||||||
date={sendReminderAt}
|
id={`send-reminder-at-${invoiceId}`}
|
||||||
onDateChange={setSendReminderAt}
|
type="datetime-local"
|
||||||
|
value={sendReminderAt}
|
||||||
|
onChange={(event) => setSendReminderAt(event.target.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -1020,7 +1031,21 @@ function SendReminderEditor({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => onSave(sendReminderAt ?? null)}
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
onSave(
|
||||||
|
sendReminderAt
|
||||||
|
? zonedDateTimeToInstant(sendReminderAt, timeZone, "earlier")
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Invalid reminder time",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
Save reminder
|
Save reminder
|
||||||
@@ -1030,7 +1055,7 @@ function SendReminderEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSendReminderAt(undefined);
|
setSendReminderAt("");
|
||||||
onClear();
|
onClear();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1042,7 +1067,7 @@ function SendReminderEditor({
|
|||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{new Date(savedReminderAt) <= new Date()
|
{new Date(savedReminderAt) <= new Date()
|
||||||
? "Reminder is due — time to send this invoice."
|
? "Reminder is due — time to send this invoice."
|
||||||
: `Scheduled for ${formatDate(savedReminderAt)}`}
|
: `Scheduled for ${formatZonedDateTime(savedReminderAt, timeZone)}`}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ import { Input } from "~/components/ui/input";
|
|||||||
import {
|
import {
|
||||||
formatZonedDateTime,
|
formatZonedDateTime,
|
||||||
getDefaultScheduledSendAt,
|
getDefaultScheduledSendAt,
|
||||||
getLocalTimeZone,
|
DEFAULT_TIME_ZONE,
|
||||||
toLocalDateTimeInputValue,
|
toZonedDateTimeInputValue,
|
||||||
|
zonedDateTimeToInstant,
|
||||||
} from "@beenvoice/domain/time-zone";
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -114,6 +122,9 @@ export default function SendEmailPage() {
|
|||||||
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
||||||
const [scheduledAt, setScheduledAt] = useState("");
|
const [scheduledAt, setScheduledAt] = useState("");
|
||||||
const [minimumScheduledAt, setMinimumScheduledAt] = useState("");
|
const [minimumScheduledAt, setMinimumScheduledAt] = useState("");
|
||||||
|
const [scheduleDisambiguation, setScheduleDisambiguation] = useState<
|
||||||
|
"earlier" | "later"
|
||||||
|
>("earlier");
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
|
||||||
// Email content state
|
// Email content state
|
||||||
@@ -128,10 +139,11 @@ export default function SendEmailPage() {
|
|||||||
api.invoices.getById.useQuery({
|
api.invoices.getById.useQuery({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
});
|
});
|
||||||
|
const { data: profile } = api.settings.getProfile.useQuery();
|
||||||
|
|
||||||
// Get utils for cache invalidation
|
// Get utils for cache invalidation
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const timeZone = useMemo(() => getLocalTimeZone(), []);
|
const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
|
||||||
|
|
||||||
// Email sending mutation
|
// Email sending mutation
|
||||||
const sendEmailMutation = api.email.sendInvoice.useMutation({
|
const sendEmailMutation = api.email.sendInvoice.useMutation({
|
||||||
@@ -330,7 +342,20 @@ export default function SendEmailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmScheduleEmail = async () => {
|
const confirmScheduleEmail = async () => {
|
||||||
const sendAt = new Date(scheduledAt);
|
let sendAt: Date;
|
||||||
|
try {
|
||||||
|
sendAt = zonedDateTimeToInstant(
|
||||||
|
scheduledAt,
|
||||||
|
timeZone,
|
||||||
|
scheduleDisambiguation,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Choose a valid local send time", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Invalid date and time",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
Number.isNaN(sendAt.getTime()) ||
|
Number.isNaN(sendAt.getTime()) ||
|
||||||
sendAt.getTime() < Date.now() + 60_000
|
sendAt.getTime() < Date.now() + 60_000
|
||||||
@@ -340,13 +365,6 @@ export default function SendEmailPage() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (toLocalDateTimeInputValue(sendAt) !== scheduledAt) {
|
|
||||||
toast.error("That local time does not exist", {
|
|
||||||
description:
|
|
||||||
"Choose another time. The selected value falls inside a daylight-saving clock change.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await scheduleEmailMutation.mutateAsync({
|
await scheduleEmailMutation.mutateAsync({
|
||||||
invoiceId,
|
invoiceId,
|
||||||
@@ -685,10 +703,13 @@ export default function SendEmailPage() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMinimumScheduledAt(
|
setMinimumScheduledAt(
|
||||||
toLocalDateTimeInputValue(new Date(Date.now() + 60_000)),
|
toZonedDateTimeInputValue(
|
||||||
|
new Date(Date.now() + 60_000),
|
||||||
|
timeZone,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setScheduledAt(
|
setScheduledAt(
|
||||||
toLocalDateTimeInputValue(getDefaultScheduledSendAt()),
|
toZonedDateTimeInputValue(getDefaultScheduledSendAt(), timeZone),
|
||||||
);
|
);
|
||||||
setShowScheduleDialog(true);
|
setShowScheduleDialog(true);
|
||||||
}}
|
}}
|
||||||
@@ -800,6 +821,23 @@ export default function SendEmailPage() {
|
|||||||
instant, so daylight saving changes and other devices will not
|
instant, so daylight saving changes and other devices will not
|
||||||
shift this send.
|
shift this send.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Repeated DST hour</Label>
|
||||||
|
<Select
|
||||||
|
value={scheduleDisambiguation}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setScheduleDisambiguation(value as "earlier" | "later")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="earlier">First occurrence</SelectItem>
|
||||||
|
<SelectItem value="later">Second occurrence</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { toast } from "sonner";
|
|||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import { formatCurrency } from "~/lib/currency";
|
import { formatCurrency } from "~/lib/currency";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -81,22 +82,27 @@ interface Invoice {
|
|||||||
|
|
||||||
interface InvoicesDataTableProps {
|
interface InvoicesDataTableProps {
|
||||||
invoices: Invoice[];
|
invoices: Invoice[];
|
||||||
|
timeZone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusType = (invoice: Invoice): StatusType =>
|
const getStatusType = (invoice: Invoice, timeZone: string): StatusType =>
|
||||||
getEffectiveInvoiceStatus(
|
getEffectiveInvoiceStatus(
|
||||||
invoice.status as StoredInvoiceStatus,
|
invoice.status as StoredInvoiceStatus,
|
||||||
invoice.dueDate,
|
invoice.dueDate,
|
||||||
|
timeZone,
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatDate = (date: Date) =>
|
const formatDate = (date: Date) =>
|
||||||
new Intl.DateTimeFormat("en-US", {
|
formatCalendarDate(date, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(new Date(date));
|
});
|
||||||
|
|
||||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
export function InvoicesDataTable({
|
||||||
|
invoices,
|
||||||
|
timeZone,
|
||||||
|
}: InvoicesDataTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
||||||
@@ -183,7 +189,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={getStatusType(invoice)}
|
status={getStatusType(invoice, timeZone)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<span className="text-foreground text-xs font-semibold">
|
<span className="text-foreground text-xs font-semibold">
|
||||||
@@ -218,14 +224,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={getStatusType(row.original)}
|
status={getStatusType(row.original, timeZone)}
|
||||||
className={
|
className={
|
||||||
getStatusType(row.original) === "sent" ? "status-pending" : ""
|
getStatusType(row.original, timeZone) === "sent"
|
||||||
|
? "status-pending"
|
||||||
|
: ""
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
filterFn: (row, _id, value: string[]) =>
|
filterFn: (row, _id, value: string[]) =>
|
||||||
value.includes(getStatusType(row.original)),
|
value.includes(getStatusType(row.original, timeZone)),
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden sm:table-cell",
|
headerClassName: "hidden sm:table-cell",
|
||||||
cellClassName: "hidden sm:table-cell",
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ import { DataTableSkeleton } from "~/components/data/data-table";
|
|||||||
// Invoices Table Component
|
// Invoices Table Component
|
||||||
async function InvoicesTable() {
|
async function InvoicesTable() {
|
||||||
const invoices = await api.invoices.getAll();
|
const invoices = await api.invoices.getAll();
|
||||||
|
const profile = await api.settings.getProfile();
|
||||||
|
|
||||||
return <InvoicesDataTable invoices={invoices} />;
|
return (
|
||||||
|
<InvoicesDataTable
|
||||||
|
invoices={invoices}
|
||||||
|
timeZone={profile?.timeZone ?? "America/New_York"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function InvoicesPage() {
|
export default async function InvoicesPage() {
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ import {
|
|||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
DEFAULT_TIME_ZONE,
|
||||||
|
formatZonedDateTime,
|
||||||
|
getDefaultScheduledSendAt,
|
||||||
|
toZonedDateTimeInputValue,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
const SCHEDULES = [
|
const SCHEDULES = [
|
||||||
{ value: "weekly", label: "Weekly" },
|
{ value: "weekly", label: "Weekly" },
|
||||||
@@ -66,10 +72,13 @@ interface RecurringFormState {
|
|||||||
currency: string;
|
currency: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
emailMessage: string;
|
emailMessage: string;
|
||||||
|
timeZone: string;
|
||||||
|
nextRunLocal: string;
|
||||||
|
disambiguation: "earlier" | "later" | "reject";
|
||||||
items: RecurringItemInput[];
|
items: RecurringItemInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultForm = (): RecurringFormState => ({
|
const defaultForm = (timeZone = DEFAULT_TIME_ZONE): RecurringFormState => ({
|
||||||
name: "",
|
name: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
businessId: "",
|
businessId: "",
|
||||||
@@ -79,15 +88,17 @@ const defaultForm = (): RecurringFormState => ({
|
|||||||
currency: "USD",
|
currency: "USD",
|
||||||
notes: "",
|
notes: "",
|
||||||
emailMessage: "",
|
emailMessage: "",
|
||||||
|
timeZone,
|
||||||
|
nextRunLocal: toZonedDateTimeInputValue(
|
||||||
|
getDefaultScheduledSendAt(),
|
||||||
|
timeZone,
|
||||||
|
),
|
||||||
|
disambiguation: "reject",
|
||||||
items: [{ description: "", hours: 0, rate: 0 }],
|
items: [{ description: "", hours: 0, rate: 0 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(date: Date) {
|
function formatDate(date: Date, timeZone: string) {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return formatZonedDateTime(date, timeZone);
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(new Date(date));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleLabel(s: string) {
|
function scheduleLabel(s: string) {
|
||||||
@@ -106,19 +117,28 @@ function RecurringForm({
|
|||||||
businesses: { id: string; name: string }[];
|
businesses: { id: string; name: string }[];
|
||||||
}) {
|
}) {
|
||||||
const addItem = () =>
|
const addItem = () =>
|
||||||
setForm((f) => ({ ...f, items: [...f.items, { description: "", hours: 0, rate: 0 }] }));
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
items: [...f.items, { description: "", hours: 0, rate: 0 }],
|
||||||
|
}));
|
||||||
|
|
||||||
const removeItem = (idx: number) =>
|
const removeItem = (idx: number) =>
|
||||||
setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
|
setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
|
||||||
|
|
||||||
const updateItem = (idx: number, field: keyof RecurringItemInput, value: string | number) =>
|
const updateItem = (
|
||||||
|
idx: number,
|
||||||
|
field: keyof RecurringItemInput,
|
||||||
|
value: string | number,
|
||||||
|
) =>
|
||||||
setForm((f) => ({
|
setForm((f) => ({
|
||||||
...f,
|
...f,
|
||||||
items: f.items.map((item, i) => (i === idx ? { ...item, [field]: value } : item)),
|
items: f.items.map((item, i) =>
|
||||||
|
i === idx ? { ...item, [field]: value } : item,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Template name</Label>
|
<Label>Template name</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -173,7 +193,9 @@ function RecurringForm({
|
|||||||
<Label>Schedule</Label>
|
<Label>Schedule</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.schedule}
|
value={form.schedule}
|
||||||
onValueChange={(v) => setForm((f) => ({ ...f, schedule: v as Schedule }))}
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({ ...f, schedule: v as Schedule }))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -193,11 +215,65 @@ function RecurringForm({
|
|||||||
maxLength={3}
|
maxLength={3}
|
||||||
placeholder="USD"
|
placeholder="USD"
|
||||||
value={form.currency}
|
value={form.currency}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value.toUpperCase() }))}
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, currency: e.target.value.toUpperCase() }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="recurring-next-run">First/next run</Label>
|
||||||
|
<Input
|
||||||
|
id="recurring-next-run"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.nextRunLocal}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
nextRunLocal: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="recurring-time-zone">Time zone</Label>
|
||||||
|
<Input
|
||||||
|
id="recurring-time-zone"
|
||||||
|
value={form.timeZone}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
timeZone: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="America/New_York"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Repeated DST hour</Label>
|
||||||
|
<Select
|
||||||
|
value={form.disambiguation}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
disambiguation: value as RecurringFormState["disambiguation"],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="reject">Reject ambiguous time</SelectItem>
|
||||||
|
<SelectItem value="earlier">First occurrence</SelectItem>
|
||||||
|
<SelectItem value="later">Second occurrence</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Tax rate (%)</Label>
|
<Label>Tax rate (%)</Label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -226,7 +302,7 @@ function RecurringForm({
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive h-8 w-8 p-0 shrink-0"
|
className="text-destructive h-8 w-8 shrink-0 p-0"
|
||||||
onClick={() => removeItem(idx)}
|
onClick={() => removeItem(idx)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
@@ -281,7 +357,9 @@ export default function RecurringInvoicesPage() {
|
|||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState<RecurringFormState>(defaultForm());
|
const [form, setForm] = useState<RecurringFormState>(defaultForm());
|
||||||
|
|
||||||
const { data: recurring, isLoading } = api.recurringInvoices.getAll.useQuery();
|
const { data: recurring, isLoading } =
|
||||||
|
api.recurringInvoices.getAll.useQuery();
|
||||||
|
const { data: profile } = api.settings.getProfile.useQuery();
|
||||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||||
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -289,27 +367,47 @@ export default function RecurringInvoicesPage() {
|
|||||||
const invalidate = () => void utils.recurringInvoices.getAll.invalidate();
|
const invalidate = () => void utils.recurringInvoices.getAll.invalidate();
|
||||||
|
|
||||||
const create = api.recurringInvoices.create.useMutation({
|
const create = api.recurringInvoices.create.useMutation({
|
||||||
onSuccess: () => { toast.success("Recurring invoice created"); setCreateOpen(false); setForm(defaultForm()); invalidate(); },
|
onSuccess: () => {
|
||||||
|
toast.success("Recurring invoice created");
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm(defaultForm());
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
onError: (e) => toast.error(e.message ?? "Failed to create"),
|
onError: (e) => toast.error(e.message ?? "Failed to create"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const update = api.recurringInvoices.update.useMutation({
|
const update = api.recurringInvoices.update.useMutation({
|
||||||
onSuccess: () => { toast.success("Updated"); setEditId(null); setForm(defaultForm()); invalidate(); },
|
onSuccess: () => {
|
||||||
|
toast.success("Updated");
|
||||||
|
setEditId(null);
|
||||||
|
setForm(defaultForm());
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
onError: (e) => toast.error(e.message ?? "Failed to update"),
|
onError: (e) => toast.error(e.message ?? "Failed to update"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pause = api.recurringInvoices.pause.useMutation({
|
const pause = api.recurringInvoices.pause.useMutation({
|
||||||
onSuccess: () => { toast.success("Paused"); invalidate(); },
|
onSuccess: () => {
|
||||||
|
toast.success("Paused");
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resume = api.recurringInvoices.resume.useMutation({
|
const resume = api.recurringInvoices.resume.useMutation({
|
||||||
onSuccess: () => { toast.success("Resumed"); invalidate(); },
|
onSuccess: () => {
|
||||||
|
toast.success("Resumed");
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const del = api.recurringInvoices.delete.useMutation({
|
const del = api.recurringInvoices.delete.useMutation({
|
||||||
onSuccess: () => { toast.success("Deleted"); setDeleteId(null); invalidate(); },
|
onSuccess: () => {
|
||||||
|
toast.success("Deleted");
|
||||||
|
setDeleteId(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,6 +431,9 @@ export default function RecurringInvoicesPage() {
|
|||||||
currency: rec.currency,
|
currency: rec.currency,
|
||||||
notes: rec.notes ?? "",
|
notes: rec.notes ?? "",
|
||||||
emailMessage: rec.emailMessage ?? "",
|
emailMessage: rec.emailMessage ?? "",
|
||||||
|
timeZone: rec.timeZone,
|
||||||
|
nextRunLocal: toZonedDateTimeInputValue(rec.nextDueAt, rec.timeZone),
|
||||||
|
disambiguation: "reject",
|
||||||
items: rec.items.map((i) => ({
|
items: rec.items.map((i) => ({
|
||||||
description: i.description,
|
description: i.description,
|
||||||
hours: i.hours,
|
hours: i.hours,
|
||||||
@@ -365,7 +466,12 @@ export default function RecurringInvoicesPage() {
|
|||||||
title="Recurring Invoices"
|
title="Recurring Invoices"
|
||||||
description="Schedule automatic invoice generation"
|
description="Schedule automatic invoice generation"
|
||||||
>
|
>
|
||||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setForm(defaultForm(profile?.timeZone));
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New recurring
|
New recurring
|
||||||
</Button>
|
</Button>
|
||||||
@@ -383,7 +489,12 @@ export default function RecurringInvoicesPage() {
|
|||||||
title="Create your first recurring invoice"
|
title="Create your first recurring invoice"
|
||||||
description="Automatically generate draft invoices on a schedule you choose."
|
description="Automatically generate draft invoices on a schedule you choose."
|
||||||
action={
|
action={
|
||||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setForm(defaultForm(profile?.timeZone));
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create recurring invoice
|
Create recurring invoice
|
||||||
</Button>
|
</Button>
|
||||||
@@ -400,7 +511,11 @@ export default function RecurringInvoicesPage() {
|
|||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="font-semibold">{rec.name}</p>
|
<p className="font-semibold">{rec.name}</p>
|
||||||
<Badge variant={rec.status === "active" ? "default" : "secondary"}>
|
<Badge
|
||||||
|
variant={
|
||||||
|
rec.status === "active" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
{rec.status}
|
{rec.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,14 +523,18 @@ export default function RecurringInvoicesPage() {
|
|||||||
{rec.client.name} · {scheduleLabel(rec.schedule)}
|
{rec.client.name} · {scheduleLabel(rec.schedule)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Next: {formatDate(rec.nextDueAt)}
|
Next: {formatDate(rec.nextDueAt, rec.timeZone)}
|
||||||
{rec.lastGeneratedAt && (
|
{rec.lastGeneratedAt && (
|
||||||
<> · Last generated: {formatDate(rec.lastGeneratedAt)}</>
|
<>
|
||||||
|
{" "}
|
||||||
|
· Last generated:{" "}
|
||||||
|
{formatDate(rec.lastGeneratedAt, rec.timeZone)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 shrink-0">
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -473,14 +592,21 @@ export default function RecurringInvoicesPage() {
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={createOpen || editId !== null}
|
open={createOpen || editId !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }
|
if (!open) {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setEditId(null);
|
||||||
|
setForm(defaultForm());
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editId ? "Edit recurring invoice" : "New recurring invoice"}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{editId ? "Edit recurring invoice" : "New recurring invoice"}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Configure the template. Invoices will be generated as drafts on the selected schedule.
|
Configure the template. Invoices will be generated as drafts on
|
||||||
|
the selected schedule.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<RecurringForm
|
<RecurringForm
|
||||||
@@ -492,17 +618,30 @@ export default function RecurringInvoicesPage() {
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }}
|
onClick={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setEditId(null);
|
||||||
|
setForm(defaultForm());
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={isSubmitting || !form.name || !form.clientId}>
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !form.name || !form.clientId}
|
||||||
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…</>
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…
|
||||||
|
</>
|
||||||
) : editId ? (
|
) : editId ? (
|
||||||
<><Check className="mr-2 h-4 w-4" /> Save changes</>
|
<>
|
||||||
|
<Check className="mr-2 h-4 w-4" /> Save changes
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<><Plus className="mr-2 h-4 w-4" /> Create</>
|
<>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Create
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -510,12 +649,18 @@ export default function RecurringInvoicesPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<Dialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
|
<Dialog
|
||||||
|
open={deleteId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleteId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete recurring invoice</DialogTitle>
|
<DialogTitle>Delete recurring invoice</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will stop automatic generation. Already-generated invoices are not affected.
|
This will stop automatic generation. Already-generated invoices
|
||||||
|
are not affected.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
import {
|
||||||
|
DashboardPage,
|
||||||
|
dashboardStatGridClass,
|
||||||
|
} from "~/components/layout/dashboard-page";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -24,6 +27,10 @@ import {
|
|||||||
import { formatCurrency } from "~/lib/currency";
|
import { formatCurrency } from "~/lib/currency";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import {
|
||||||
|
formatCalendarDate,
|
||||||
|
getZonedDateTimeParts,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
@@ -63,7 +70,9 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
const isLoading = invoicesLoading || expensesLoading;
|
const isLoading = invoicesLoading || expensesLoading;
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const { data: profile } = api.settings.getProfile.useQuery();
|
||||||
|
const reportTimeZone = profile?.timeZone ?? "America/New_York";
|
||||||
|
const currentYear = getZonedDateTimeParts(new Date(), reportTimeZone).year;
|
||||||
const [taxYear, setTaxYear] = useState(String(currentYear));
|
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||||
|
|
||||||
const filteredInvoices = useMemo(() => {
|
const filteredInvoices = useMemo(() => {
|
||||||
@@ -76,10 +85,11 @@ export default function ReportsPage() {
|
|||||||
if (!filteredInvoices.length) return null;
|
if (!filteredInvoices.length) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const current = getZonedDateTimeParts(now, reportTimeZone);
|
||||||
const monthMap: Record<string, number> = {};
|
const monthMap: Record<string, number> = {};
|
||||||
for (let i = 11; i >= 0; i--) {
|
for (let i = 11; i >= 0; i--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const d = new Date(Date.UTC(current.year, current.month - 1 - i, 1));
|
||||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
monthMap[key] = 0;
|
monthMap[key] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +101,11 @@ export default function ReportsPage() {
|
|||||||
const status = getEffectiveInvoiceStatus(
|
const status = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
|
reportTimeZone,
|
||||||
);
|
);
|
||||||
if (status === "paid") {
|
if (status === "paid") {
|
||||||
totalRevenue += inv.totalAmount;
|
totalRevenue += inv.totalAmount;
|
||||||
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
const key = `${new Date(inv.issueDate).getUTCFullYear()}-${String(new Date(inv.issueDate).getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
|
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
|
||||||
} else if (status === "sent" || status === "overdue") {
|
} else if (status === "sent" || status === "overdue") {
|
||||||
totalPending += inv.totalAmount;
|
totalPending += inv.totalAmount;
|
||||||
@@ -103,7 +114,7 @@ export default function ReportsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
||||||
month: new Date(month + "-01").toLocaleDateString("en-US", {
|
month: formatCalendarDate(month + "-01", {
|
||||||
month: "short",
|
month: "short",
|
||||||
year: "2-digit",
|
year: "2-digit",
|
||||||
}),
|
}),
|
||||||
@@ -115,6 +126,7 @@ export default function ReportsPage() {
|
|||||||
const status = getEffectiveInvoiceStatus(
|
const status = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
|
reportTimeZone,
|
||||||
);
|
);
|
||||||
if (status === "paid" && inv.client) {
|
if (status === "paid" && inv.client) {
|
||||||
const id = inv.client.id;
|
const id = inv.client.id;
|
||||||
@@ -139,6 +151,7 @@ export default function ReportsPage() {
|
|||||||
const s = getEffectiveInvoiceStatus(
|
const s = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
|
reportTimeZone,
|
||||||
);
|
);
|
||||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
@@ -151,7 +164,7 @@ export default function ReportsPage() {
|
|||||||
totalHours,
|
totalHours,
|
||||||
statusCount,
|
statusCount,
|
||||||
};
|
};
|
||||||
}, [filteredInvoices]);
|
}, [filteredInvoices, reportTimeZone]);
|
||||||
|
|
||||||
// Tax summary for selected year
|
// Tax summary for selected year
|
||||||
const taxData = useMemo(() => {
|
const taxData = useMemo(() => {
|
||||||
@@ -161,13 +174,14 @@ export default function ReportsPage() {
|
|||||||
const status = getEffectiveInvoiceStatus(
|
const status = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
|
reportTimeZone,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
status === "paid" && new Date(inv.issueDate).getFullYear() === year
|
status === "paid" && new Date(inv.issueDate).getUTCFullYear() === year
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const yearExpenses = expenses.filter(
|
const yearExpenses = expenses.filter(
|
||||||
(exp) => new Date(exp.date).getFullYear() === year,
|
(exp) => new Date(exp.date).getUTCFullYear() === year,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
|
const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
|
||||||
@@ -211,10 +225,12 @@ export default function ReportsPage() {
|
|||||||
return {
|
return {
|
||||||
label: `Q${q}`,
|
label: `Q${q}`,
|
||||||
income: yearInvoices
|
income: yearInvoices
|
||||||
.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth()))
|
.filter((inv) =>
|
||||||
|
qMonths.includes(new Date(inv.issueDate).getUTCMonth()),
|
||||||
|
)
|
||||||
.reduce((s, inv) => s + getSubtotal(inv), 0),
|
.reduce((s, inv) => s + getSubtotal(inv), 0),
|
||||||
expenses: yearExpenses
|
expenses: yearExpenses
|
||||||
.filter((exp) => qMonths.includes(new Date(exp.date).getMonth()))
|
.filter((exp) => qMonths.includes(new Date(exp.date).getUTCMonth()))
|
||||||
.reduce((s, exp) => s + exp.amount, 0),
|
.reduce((s, exp) => s + exp.amount, 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -233,13 +249,13 @@ export default function ReportsPage() {
|
|||||||
yearInvoices,
|
yearInvoices,
|
||||||
yearExpenses,
|
yearExpenses,
|
||||||
};
|
};
|
||||||
}, [filteredInvoices, expenses, taxYear]);
|
}, [filteredInvoices, expenses, taxYear, reportTimeZone]);
|
||||||
|
|
||||||
const availableYears = useMemo(() => {
|
const availableYears = useMemo(() => {
|
||||||
const years = new Set<number>([currentYear, currentYear - 1]);
|
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||||
for (const inv of filteredInvoices)
|
for (const inv of filteredInvoices)
|
||||||
years.add(new Date(inv.issueDate).getFullYear());
|
years.add(new Date(inv.issueDate).getUTCFullYear());
|
||||||
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
for (const exp of expenses) years.add(new Date(exp.date).getUTCFullYear());
|
||||||
return Array.from(years).sort((a, b) => b - a);
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
}, [filteredInvoices, expenses, currentYear]);
|
}, [filteredInvoices, expenses, currentYear]);
|
||||||
|
|
||||||
@@ -251,6 +267,7 @@ export default function ReportsPage() {
|
|||||||
getEffectiveInvoiceStatus(
|
getEffectiveInvoiceStatus(
|
||||||
i.status as StoredInvoiceStatus,
|
i.status as StoredInvoiceStatus,
|
||||||
i.dueDate,
|
i.dueDate,
|
||||||
|
reportTimeZone,
|
||||||
) === "paid",
|
) === "paid",
|
||||||
).length || 1)
|
).length || 1)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -272,7 +289,7 @@ export default function ReportsPage() {
|
|||||||
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
|
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
|
||||||
const taxAmt = inv.totalAmount - invoiceSubtotal;
|
const taxAmt = inv.totalAmount - invoiceSubtotal;
|
||||||
return [
|
return [
|
||||||
new Date(inv.issueDate).toLocaleDateString("en-US"),
|
formatCalendarDate(inv.issueDate),
|
||||||
inv.invoiceNumber,
|
inv.invoiceNumber,
|
||||||
`"${inv.client?.name ?? ""}"`,
|
`"${inv.client?.name ?? ""}"`,
|
||||||
invoiceSubtotal.toFixed(2),
|
invoiceSubtotal.toFixed(2),
|
||||||
@@ -287,7 +304,7 @@ export default function ReportsPage() {
|
|||||||
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
||||||
...taxData.yearExpenses.map((exp) =>
|
...taxData.yearExpenses.map((exp) =>
|
||||||
[
|
[
|
||||||
new Date(exp.date).toLocaleDateString("en-US"),
|
formatCalendarDate(exp.date),
|
||||||
`"${exp.description}"`,
|
`"${exp.description}"`,
|
||||||
`"${exp.category ?? ""}"`,
|
`"${exp.category ?? ""}"`,
|
||||||
exp.amount.toFixed(2),
|
exp.amount.toFixed(2),
|
||||||
@@ -634,7 +651,7 @@ export default function ReportsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{new Date(inv.issueDate).toLocaleDateString("en-US", {
|
{formatCalendarDate(inv.issueDate, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -647,6 +664,7 @@ export default function ReportsPage() {
|
|||||||
getEffectiveInvoiceStatus(
|
getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
|
reportTimeZone,
|
||||||
) as never
|
) as never
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance";
|
|||||||
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
|
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
|
||||||
import { ApiAccessSettings } from "./api-access-settings";
|
import { ApiAccessSettings } from "./api-access-settings";
|
||||||
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
|
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
|
||||||
|
import { DEFAULT_TIME_ZONE } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
const InvoiceImportPage = dynamic(
|
const InvoiceImportPage = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@@ -147,6 +148,7 @@ export function SettingsContent({
|
|||||||
|
|
||||||
const { data: session } = useAuthSession();
|
const { data: session } = useAuthSession();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [timeZone, setTimeZone] = useState(DEFAULT_TIME_ZONE);
|
||||||
const [nameInitialized, setNameInitialized] = useState(false);
|
const [nameInitialized, setNameInitialized] = useState(false);
|
||||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||||
const [importData, setImportData] = useState("");
|
const [importData, setImportData] = useState("");
|
||||||
@@ -309,7 +311,7 @@ export function SettingsContent({
|
|||||||
toast.error("Please enter your name");
|
toast.error("Please enter your name");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateProfileMutation.mutate({ name: name.trim() });
|
updateProfileMutation.mutate({ name: name.trim(), timeZone });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangePassword = (e: React.FormEvent) => {
|
const handleChangePassword = (e: React.FormEvent) => {
|
||||||
@@ -423,8 +425,15 @@ export function SettingsContent({
|
|||||||
if (nameInitialized || !profileFetched) return;
|
if (nameInitialized || !profileFetched) return;
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
|
||||||
setName(profile?.name ?? session?.user?.name ?? "");
|
setName(profile?.name ?? session?.user?.name ?? "");
|
||||||
|
setTimeZone(profile?.timeZone ?? DEFAULT_TIME_ZONE);
|
||||||
setNameInitialized(true);
|
setNameInitialized(true);
|
||||||
}, [profile?.name, profileFetched, session?.user?.name, nameInitialized]);
|
}, [
|
||||||
|
profile?.name,
|
||||||
|
profile?.timeZone,
|
||||||
|
profileFetched,
|
||||||
|
session?.user?.name,
|
||||||
|
nameInitialized,
|
||||||
|
]);
|
||||||
|
|
||||||
// (Removed direct DOM mutation; provider handles applying preferences globally)
|
// (Removed direct DOM mutation; provider handles applying preferences globally)
|
||||||
|
|
||||||
@@ -497,6 +506,19 @@ export function SettingsContent({
|
|||||||
Email address cannot be changed
|
Email address cannot be changed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="time-zone">Time zone</Label>
|
||||||
|
<Input
|
||||||
|
id="time-zone"
|
||||||
|
value={timeZone}
|
||||||
|
onChange={(event) => setTimeZone(event.target.value)}
|
||||||
|
placeholder="America/New_York"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
IANA time zone used for recurring schedules, reminders, and
|
||||||
|
reports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateProfileMutation.isPending}
|
disabled={updateProfileMutation.isPending}
|
||||||
|
|||||||
@@ -9,29 +9,52 @@ import { api } from "~/trpc/react";
|
|||||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||||
import { formatLineItemDetail } from "~/lib/invoice-line-item";
|
import { formatLineItemDetail } from "~/lib/invoice-line-item";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
formatCalendarDate,
|
||||||
|
getEffectiveInvoiceStatus,
|
||||||
|
} from "@beenvoice/domain";
|
||||||
|
|
||||||
function formatDate(date: Date) {
|
function formatDate(date: Date) {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return formatCalendarDate(date, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(new Date(date));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(amount: number, currency = "USD") {
|
function formatCurrency(amount: number, currency = "USD") {
|
||||||
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
|
||||||
|
amount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
|
function StatusPill({
|
||||||
const overdue = status === "sent" && new Date(dueDate) < new Date();
|
status,
|
||||||
const label = overdue ? "Overdue" : status.charAt(0).toUpperCase() + status.slice(1);
|
dueDate,
|
||||||
|
timeZone,
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
dueDate: Date;
|
||||||
|
timeZone: string;
|
||||||
|
}) {
|
||||||
|
const overdue =
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
status as "draft" | "sent" | "paid",
|
||||||
|
dueDate,
|
||||||
|
timeZone,
|
||||||
|
) === "overdue";
|
||||||
|
const label = overdue
|
||||||
|
? "Overdue"
|
||||||
|
: status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
const cls = overdue
|
const cls = overdue
|
||||||
? "bg-red-50 text-red-700 border-red-200"
|
? "bg-red-50 text-red-700 border-red-200"
|
||||||
: status === "paid"
|
: status === "paid"
|
||||||
? "bg-green-50 text-green-700 border-green-200"
|
? "bg-green-50 text-green-700 border-green-200"
|
||||||
: "bg-yellow-50 text-yellow-700 border-yellow-200";
|
: "bg-yellow-50 text-yellow-700 border-yellow-200";
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center rounded-full border px-3 py-0.5 text-xs font-semibold ${cls}`}>
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-3 py-0.5 text-xs font-semibold ${cls}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -40,7 +63,11 @@ function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
|
|||||||
function PublicInvoiceView({ token }: { token: string }) {
|
function PublicInvoiceView({ token }: { token: string }) {
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
const { data: invoice, isLoading, error } = api.invoices.getByPublicToken.useQuery({ token });
|
const {
|
||||||
|
data: invoice,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = api.invoices.getByPublicToken.useQuery({ token });
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (!invoice || downloading) return;
|
if (!invoice || downloading) return;
|
||||||
@@ -79,7 +106,9 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-center">
|
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-center">
|
||||||
<p className="text-2xl font-bold text-gray-800">Invoice not found</p>
|
<p className="text-2xl font-bold text-gray-800">Invoice not found</p>
|
||||||
<p className="text-sm text-gray-500">This link may have expired or been revoked.</p>
|
<p className="text-sm text-gray-500">
|
||||||
|
This link may have expired or been revoked.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,7 +125,7 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
|
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-10 px-4">
|
<div className="min-h-screen bg-gray-50 px-4 py-10">
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||||
@@ -114,31 +143,46 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{!hideName && (
|
{!hideName && (
|
||||||
<p className="truncate text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
|
<p className="truncate text-lg font-bold text-white">
|
||||||
|
{senderName ?? "Invoice"}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{invoice.business?.email && (
|
{invoice.business?.email && (
|
||||||
<p className="mt-0.5 truncate text-sm text-gray-400">{invoice.business.email}</p>
|
<p className="mt-0.5 truncate text-sm text-gray-400">
|
||||||
|
{invoice.business.email}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-8 py-6 space-y-6">
|
<div className="space-y-6 px-8 py-6">
|
||||||
{/* Invoice meta */}
|
{/* Invoice meta */}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-gray-900">{invoice.invoiceNumber}</p>
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</p>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Issued {formatDate(invoice.issueDate)} · Due {formatDate(invoice.dueDate)}
|
Issued {formatDate(invoice.issueDate)} · Due{" "}
|
||||||
|
{formatDate(invoice.dueDate)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusPill status={invoice.status} dueDate={invoice.dueDate} />
|
<StatusPill
|
||||||
|
status={invoice.status}
|
||||||
|
dueDate={invoice.dueDate}
|
||||||
|
timeZone={invoice.createdBy.timeZone}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bill to */}
|
{/* Bill to */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Bill to</p>
|
<p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
|
||||||
<p className="font-semibold text-gray-900">{invoice.client.name}</p>
|
Bill to
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{invoice.client.name}
|
||||||
|
</p>
|
||||||
{invoice.client.email && (
|
{invoice.client.email && (
|
||||||
<p className="text-sm text-gray-500">{invoice.client.email}</p>
|
<p className="text-sm text-gray-500">{invoice.client.email}</p>
|
||||||
)}
|
)}
|
||||||
@@ -149,18 +193,21 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
{/* Line items */}
|
{/* Line items */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoice.items.map((item) => (
|
{invoice.items.map((item) => (
|
||||||
<div key={item.id} className="flex justify-between gap-4 text-sm">
|
<div
|
||||||
<div className="flex-1 min-w-0">
|
key={item.id}
|
||||||
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
className="flex justify-between gap-4 text-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium break-words text-gray-900">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
{formatLineItemDetail(
|
{formatLineItemDetail(item.hours, item.rate, (amount) =>
|
||||||
item.hours,
|
formatCurrency(amount, invoice.currency ?? "USD"),
|
||||||
item.rate,
|
|
||||||
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-gray-900 shrink-0">
|
<p className="shrink-0 font-semibold text-gray-900">
|
||||||
{formatCurrency(item.amount, invoice.currency ?? "USD")}
|
{formatCurrency(item.amount, invoice.currency ?? "USD")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,15 +220,19 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between text-gray-500">
|
<div className="flex justify-between text-gray-500">
|
||||||
<span>Subtotal</span>
|
<span>Subtotal</span>
|
||||||
<span>{formatCurrency(subtotal, invoice.currency ?? "USD")}</span>
|
<span>
|
||||||
|
{formatCurrency(subtotal, invoice.currency ?? "USD")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{invoice.taxRate > 0 && (
|
{invoice.taxRate > 0 && (
|
||||||
<div className="flex justify-between text-gray-500">
|
<div className="flex justify-between text-gray-500">
|
||||||
<span>Tax ({invoice.taxRate}%)</span>
|
<span>Tax ({invoice.taxRate}%)</span>
|
||||||
<span>{formatCurrency(taxAmount, invoice.currency ?? "USD")}</span>
|
<span>
|
||||||
|
{formatCurrency(taxAmount, invoice.currency ?? "USD")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between text-base font-bold text-gray-900 pt-1">
|
<div className="flex justify-between pt-1 text-base font-bold text-gray-900">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>{formatCurrency(total, invoice.currency ?? "USD")}</span>
|
<span>{formatCurrency(total, invoice.currency ?? "USD")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,8 +243,12 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Notes</p>
|
<p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
|
||||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
|
Notes
|
||||||
|
</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap text-gray-700">
|
||||||
|
{invoice.notes}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -206,9 +261,14 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{downloading ? (
|
{downloading ? (
|
||||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating PDF…</>
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating
|
||||||
|
PDF…
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<><Download className="mr-2 h-4 w-4" /> Download PDF</>
|
<>
|
||||||
|
<Download className="mr-2 h-4 w-4" /> Download PDF
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
export function CurrentOpenInvoiceCard() {
|
export function CurrentOpenInvoiceCard() {
|
||||||
const { data: currentInvoice, isLoading } =
|
const { data: currentInvoice, isLoading } =
|
||||||
@@ -20,10 +21,10 @@ export function CurrentOpenInvoiceCard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return formatCalendarDate(date, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(new Date(date));
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
export function InvoiceList() {
|
export function InvoiceList() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -72,7 +73,7 @@ export function InvoiceList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Date(date).toLocaleDateString();
|
return formatCalendarDate(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
|
import {
|
||||||
|
calendarDateFromLocalDate,
|
||||||
|
calendarDateToLocalDate,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -77,7 +81,7 @@ export function InvoiceCalendarView({
|
|||||||
return items
|
return items
|
||||||
.map((item, index) => ({ item, index }))
|
.map((item, index) => ({ item, index }))
|
||||||
.filter((wrapper) => {
|
.filter((wrapper) => {
|
||||||
const itemDate = new Date(wrapper.item.date);
|
const itemDate = calendarDateToLocalDate(wrapper.item.date);
|
||||||
return isSameDay(itemDate, date);
|
return isSameDay(itemDate, date);
|
||||||
});
|
});
|
||||||
}, [items, date]);
|
}, [items, date]);
|
||||||
@@ -88,7 +92,7 @@ export function InvoiceCalendarView({
|
|||||||
return items
|
return items
|
||||||
.map((item, index) => ({ item, index }))
|
.map((item, index) => ({ item, index }))
|
||||||
.filter((wrapper) => {
|
.filter((wrapper) => {
|
||||||
const itemDate = new Date(wrapper.item.date);
|
const itemDate = calendarDateToLocalDate(wrapper.item.date);
|
||||||
return isSameDay(itemDate, targetDate);
|
return isSameDay(itemDate, targetDate);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -103,7 +107,7 @@ export function InvoiceCalendarView({
|
|||||||
|
|
||||||
const handleAddNewItem = () => {
|
const handleAddNewItem = () => {
|
||||||
if (date) {
|
if (date) {
|
||||||
onAddItem(date);
|
onAddItem(calendarDateFromLocalDate(date));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -407,7 +411,11 @@ export function InvoiceCalendarView({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
<Button
|
||||||
|
onClick={handleAddNewItem}
|
||||||
|
className="mt-2"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Log Time
|
Log Time
|
||||||
</Button>
|
</Button>
|
||||||
@@ -494,7 +502,11 @@ export function InvoiceCalendarView({
|
|||||||
Total
|
Total
|
||||||
</span>
|
</span>
|
||||||
<span className="text-primary text-lg font-bold">
|
<span className="text-primary text-lg font-bold">
|
||||||
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
|
$
|
||||||
|
{calculateLineItemAmount(
|
||||||
|
item.hours,
|
||||||
|
item.rate,
|
||||||
|
).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
import { generateInvoiceNumber } from "~/lib/draft-invoice";
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
|
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -108,13 +109,14 @@ function plainTextToHtml(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultInvoiceFormData(): InvoiceFormData {
|
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||||
|
const today = calendarDateFromLocalDate(new Date());
|
||||||
return {
|
return {
|
||||||
invoiceNumber: generateInvoiceNumber(),
|
invoiceNumber: generateInvoiceNumber(),
|
||||||
invoicePrefix: "#",
|
invoicePrefix: "#",
|
||||||
businessId: "",
|
businessId: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
issueDate: new Date(),
|
issueDate: today,
|
||||||
dueDate: new Date(),
|
dueDate: defaultDueDate(today),
|
||||||
status: "draft",
|
status: "draft",
|
||||||
notes: "",
|
notes: "",
|
||||||
emailMessage: "",
|
emailMessage: "",
|
||||||
@@ -124,7 +126,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
date: new Date(),
|
date: today,
|
||||||
description: "",
|
description: "",
|
||||||
hours: 1,
|
hours: 1,
|
||||||
rate: 0,
|
rate: 0,
|
||||||
@@ -209,7 +211,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
date: new Date(),
|
date: calendarDateFromLocalDate(new Date()),
|
||||||
description: "",
|
description: "",
|
||||||
hours: 1,
|
hours: 1,
|
||||||
rate: 0,
|
rate: 0,
|
||||||
@@ -320,7 +322,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
...prev.items,
|
...prev.items,
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
date: new Date(),
|
date: calendarDateFromLocalDate(new Date()),
|
||||||
description: parsed.description,
|
description: parsed.description,
|
||||||
hours: parsed.hours ?? 1,
|
hours: parsed.hours ?? 1,
|
||||||
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
||||||
@@ -350,7 +352,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
items: prev.items.map((item, i) => {
|
items: prev.items.map((item, i) => {
|
||||||
if (i !== idx) return item;
|
if (i !== idx) return item;
|
||||||
|
|
||||||
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
|
if (
|
||||||
|
field === "billingType" &&
|
||||||
|
(value === "hourly" || value === "fixed")
|
||||||
|
) {
|
||||||
const next = applyBillingTypeChange(value, item);
|
const next = applyBillingTypeChange(value, item);
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
@@ -401,7 +406,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsToSave = formData.items.filter((item) => item.description?.trim());
|
const itemsToSave = formData.items.filter((item) =>
|
||||||
|
item.description?.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
let invalidItemIndex = -1;
|
let invalidItemIndex = -1;
|
||||||
for (let i = 0; i < formData.items.length; i++) {
|
for (let i = 0; i < formData.items.length; i++) {
|
||||||
@@ -515,7 +522,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DashboardPageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
<PageTabs
|
||||||
|
value={activeTab}
|
||||||
|
className="w-full"
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
>
|
||||||
<PageTabsList>
|
<PageTabsList>
|
||||||
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
||||||
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
||||||
@@ -606,7 +617,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
date={formData.issueDate}
|
date={formData.issueDate}
|
||||||
onDateChange={(d) =>
|
onDateChange={(d) =>
|
||||||
updateField("issueDate", d ?? new Date())
|
updateField(
|
||||||
|
"issueDate",
|
||||||
|
d ?? calendarDateFromLocalDate(new Date()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -616,7 +630,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
date={formData.dueDate}
|
date={formData.dueDate}
|
||||||
onDateChange={(d) =>
|
onDateChange={(d) =>
|
||||||
updateField("dueDate", d ?? new Date())
|
updateField(
|
||||||
|
"dueDate",
|
||||||
|
d ?? calendarDateFromLocalDate(new Date()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -721,7 +738,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.emailMessage}
|
value={formData.emailMessage}
|
||||||
onChange={(e) => updateField("emailMessage", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("emailMessage", e.target.value)
|
||||||
|
}
|
||||||
placeholder="Add a note that appears only in the email body..."
|
placeholder="Add a note that appears only in the email body..."
|
||||||
className="min-h-[140px]"
|
className="min-h-[140px]"
|
||||||
/>
|
/>
|
||||||
@@ -818,7 +837,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
onRemoveItem={removeItem}
|
onRemoveItem={removeItem}
|
||||||
onUpdateItem={updateItem}
|
onUpdateItem={updateItem}
|
||||||
onAddItemWithValues={addItemWithValues}
|
onAddItemWithValues={addItemWithValues}
|
||||||
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
invoiceId={
|
||||||
|
invoiceId && invoiceId !== "new" ? invoiceId : undefined
|
||||||
|
}
|
||||||
clientId={formData.clientId || undefined}
|
clientId={formData.clientId || undefined}
|
||||||
defaultRate={formData.items[0]?.rate}
|
defaultRate={formData.items[0]?.rate}
|
||||||
readOnly={formData.status !== "draft"}
|
readOnly={formData.status !== "draft"}
|
||||||
@@ -925,7 +946,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
amount: calculateLineItemAmount(
|
||||||
|
item.hours,
|
||||||
|
item.rate,
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ import {
|
|||||||
} from "~/lib/invoice-import";
|
} from "~/lib/invoice-import";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
addCalendarDays,
|
||||||
|
formatCalendarDate,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
interface StagedInvoice extends ImportInvoice {
|
interface StagedInvoice extends ImportInvoice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -173,9 +177,10 @@ export function InvoiceImportPage() {
|
|||||||
if (inv.id !== id) return inv;
|
if (inv.id !== id) return inv;
|
||||||
const updated = { ...inv, ...updates };
|
const updated = { ...inv, ...updates };
|
||||||
if (updates.issueDate !== undefined && !updates.dueDate) {
|
if (updates.issueDate !== undefined && !updates.dueDate) {
|
||||||
const due = new Date(updated.issueDate ?? new Date());
|
updated.dueDate = addCalendarDays(
|
||||||
due.setDate(due.getDate() + 30);
|
updated.issueDate ?? new Date(),
|
||||||
updated.dueDate = due;
|
30,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
@@ -628,12 +633,14 @@ export function InvoiceImportPage() {
|
|||||||
{previewInvoice.items.map((item, idx) => (
|
{previewInvoice.items.map((item, idx) => (
|
||||||
<tr key={idx} className="border-border border-b">
|
<tr key={idx} className="border-border border-b">
|
||||||
<td className="p-2 text-sm whitespace-nowrap">
|
<td className="p-2 text-sm whitespace-nowrap">
|
||||||
{item.date?.toLocaleDateString() ?? "—"}
|
{item.date ? formatCalendarDate(item.date) : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="max-w-xs truncate p-2 text-sm">
|
<td className="max-w-xs truncate p-2 text-sm">
|
||||||
{item.description}
|
{item.description}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 text-right text-sm">{item.quantity}</td>
|
<td className="p-2 text-right text-sm">
|
||||||
|
{item.quantity}
|
||||||
|
</td>
|
||||||
<td className="p-2 text-right text-sm">
|
<td className="p-2 text-right text-sm">
|
||||||
{item.rate.toLocaleString("en-US", {
|
{item.rate.toLocaleString("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { TimeEntryListItem } from "~/lib/time-entry-display";
|
|||||||
|
|
||||||
export function TimeEntriesHistory() {
|
export function TimeEntriesHistory() {
|
||||||
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
||||||
|
const { data: profile } = api.settings.getProfile.useQuery();
|
||||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
|
|
||||||
const completedEntries = useMemo(
|
const completedEntries = useMemo(
|
||||||
@@ -22,8 +23,8 @@ export function TimeEntriesHistory() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const grouped = useMemo(
|
const grouped = useMemo(
|
||||||
() => groupEntriesByDate(completedEntries),
|
() => groupEntriesByDate(completedEntries, profile?.timeZone),
|
||||||
[completedEntries],
|
[completedEntries, profile?.timeZone],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "~/components/ui/popover";
|
} from "~/components/ui/popover";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import {
|
||||||
|
calendarDateFromLocalDate,
|
||||||
|
calendarDateToLocalDate,
|
||||||
|
formatCalendarDate,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -25,7 +30,7 @@ function formatDate(date: Date | undefined) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS);
|
return formatCalendarDate(date, DATE_FORMAT_OPTIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Longest month name in en-US long format (September 30, 2026).
|
// Longest month name in en-US long format (September 30, 2026).
|
||||||
@@ -54,7 +59,9 @@ export function DatePicker({
|
|||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [value, setValue] = React.useState(formatDate(date));
|
const [value, setValue] = React.useState(formatDate(date));
|
||||||
const [month, setMonth] = React.useState<Date | undefined>(date);
|
const [month, setMonth] = React.useState<Date | undefined>(
|
||||||
|
date ? calendarDateToLocalDate(date) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: "h-9 text-xs",
|
sm: "h-9 text-xs",
|
||||||
@@ -67,7 +74,7 @@ export function DatePicker({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
|
||||||
setValue(formatDate(date));
|
setValue(formatDate(date));
|
||||||
setMonth(date);
|
setMonth(date ? calendarDateToLocalDate(date) : undefined);
|
||||||
}, [date]);
|
}, [date]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,7 +88,7 @@ export function DatePicker({
|
|||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
"invisible block whitespace-nowrap px-3 pr-10",
|
"invisible block px-3 pr-10 whitespace-nowrap",
|
||||||
sizeClasses[size],
|
sizeClasses[size],
|
||||||
inputClassName,
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
@@ -102,7 +109,8 @@ export function DatePicker({
|
|||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
const parsedDate = parseDate(e.target.value);
|
const parsedDate = parseDate(e.target.value);
|
||||||
if (parsedDate) {
|
if (parsedDate) {
|
||||||
onDateChange(parsedDate);
|
const calendarDate = calendarDateFromLocalDate(parsedDate);
|
||||||
|
onDateChange(calendarDate);
|
||||||
setMonth(parsedDate);
|
setMonth(parsedDate);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -130,13 +138,16 @@ export function DatePicker({
|
|||||||
>
|
>
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={date}
|
selected={date ? calendarDateToLocalDate(date) : undefined}
|
||||||
captionLayout="dropdown"
|
captionLayout="dropdown"
|
||||||
month={month}
|
month={month}
|
||||||
onMonthChange={setMonth}
|
onMonthChange={setMonth}
|
||||||
onSelect={(selectedDate) => {
|
onSelect={(selectedDate) => {
|
||||||
onDateChange(selectedDate);
|
const calendarDate = selectedDate
|
||||||
setValue(formatDate(selectedDate));
|
? calendarDateFromLocalDate(selectedDate)
|
||||||
|
: undefined;
|
||||||
|
onDateChange(calendarDate);
|
||||||
|
setValue(formatDate(calendarDate));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { addCalendarDays } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
/** Default invoice number format (matches web/mobile create forms). */
|
/** Default invoice number format (matches web/mobile create forms). */
|
||||||
export function generateInvoiceNumber(now = new Date()): string {
|
export function generateInvoiceNumber(now = new Date()): string {
|
||||||
const date = [
|
const date = [
|
||||||
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function defaultDueDate(issueDate: Date): Date {
|
export function defaultDueDate(issueDate: Date): Date {
|
||||||
const due = new Date(issueDate);
|
return addCalendarDays(issueDate, 30);
|
||||||
due.setDate(due.getDate() + 30);
|
|
||||||
return due;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { getAppUrl } from "~/lib/app-url";
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
// Most email clients render <img src> fine for PNG/JPEG but are inconsistent
|
// Most email clients render <img src> fine for PNG/JPEG but are inconsistent
|
||||||
// with SVG (Outlook and several webmail clients strip or refuse it), so
|
// with SVG (Outlook and several webmail clients strip or refuse it), so
|
||||||
// non-raster logos are requested through the same on-the-fly PNG
|
// non-raster logos are requested through the same on-the-fly PNG
|
||||||
// rasterization the PDF export uses.
|
// rasterization the PDF export uses.
|
||||||
function resolveEmailLogoUrl(
|
function resolveEmailLogoUrl(
|
||||||
business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined,
|
business:
|
||||||
|
| {
|
||||||
|
id?: string;
|
||||||
|
logoStorageKey?: string | null;
|
||||||
|
logoMimeType?: string | null;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!business?.id || !business.logoStorageKey) return null;
|
if (!business?.id || !business.logoStorageKey) return null;
|
||||||
@@ -57,6 +65,7 @@ interface InvoiceEmailTemplateProps {
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateInvoiceEmailTemplate({
|
export function generateInvoiceEmailTemplate({
|
||||||
@@ -66,13 +75,14 @@ export function generateInvoiceEmailTemplate({
|
|||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
baseUrl = getAppUrl(),
|
baseUrl = getAppUrl(),
|
||||||
|
timeZone = "America/New_York",
|
||||||
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return formatCalendarDate(date, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(new Date(date));
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
@@ -83,7 +93,13 @@ export function generateInvoiceEmailTemplate({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTimeOfDayGreeting = () => {
|
const getTimeOfDayGreeting = () => {
|
||||||
const hour = new Date().getHours();
|
const hour = Number(
|
||||||
|
new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hour: "numeric",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).format(new Date()),
|
||||||
|
);
|
||||||
if (hour < 12) return "Good morning";
|
if (hour < 12) return "Good morning";
|
||||||
if (hour < 17) return "Good afternoon";
|
if (hour < 17) return "Good afternoon";
|
||||||
return "Good evening";
|
return "Good evening";
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
formatCalendarDate,
|
||||||
|
getEffectiveInvoiceStatus,
|
||||||
|
} from "@beenvoice/domain";
|
||||||
|
|
||||||
interface ReminderEmailTemplateProps {
|
interface ReminderEmailTemplateProps {
|
||||||
invoice: {
|
invoice: {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
@@ -15,6 +20,7 @@ interface ReminderEmailTemplateProps {
|
|||||||
customMessage?: string;
|
customMessage?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateReminderEmailTemplate({
|
export function generateReminderEmailTemplate({
|
||||||
@@ -22,11 +28,18 @@ export function generateReminderEmailTemplate({
|
|||||||
customMessage,
|
customMessage,
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } {
|
timeZone = "America/New_York",
|
||||||
|
}: ReminderEmailTemplateProps): {
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
subject: string;
|
||||||
|
} {
|
||||||
const formatDate = (date: Date) =>
|
const formatDate = (date: Date) =>
|
||||||
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format(
|
formatCalendarDate(date, {
|
||||||
new Date(date),
|
year: "numeric",
|
||||||
);
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
const formatCurrency = (amount: number) =>
|
const formatCurrency = (amount: number) =>
|
||||||
new Intl.NumberFormat("en-US", {
|
new Intl.NumberFormat("en-US", {
|
||||||
@@ -34,14 +47,14 @@ export function generateReminderEmailTemplate({
|
|||||||
currency: invoice.currency ?? "USD",
|
currency: invoice.currency ?? "USD",
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
|
|
||||||
const senderName =
|
const senderName = invoice.business?.name
|
||||||
invoice.business?.name
|
|
||||||
? invoice.business.nickname
|
? invoice.business.nickname
|
||||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||||
: invoice.business.name
|
: invoice.business.name
|
||||||
: userName ?? "Your service provider";
|
: (userName ?? "Your service provider");
|
||||||
|
|
||||||
const isOverdue = new Date(invoice.dueDate) < new Date();
|
const isOverdue =
|
||||||
|
getEffectiveInvoiceStatus("sent", invoice.dueDate, timeZone) === "overdue";
|
||||||
|
|
||||||
const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber} — ${formatCurrency(invoice.totalAmount)}`;
|
const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber} — ${formatCurrency(invoice.totalAmount)}`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
addCalendarDays,
|
||||||
|
calendarDateFromLocalDate,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
export type ImportFormat = "csv" | "json";
|
export type ImportFormat = "csv" | "json";
|
||||||
|
|
||||||
export interface ImportItem {
|
export interface ImportItem {
|
||||||
@@ -86,8 +91,9 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
|
|||||||
// ISO date (YYYY-MM-DD)
|
// ISO date (YYYY-MM-DD)
|
||||||
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
|
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
|
||||||
if (isoMatch) {
|
if (isoMatch) {
|
||||||
const d = new Date(trimmed);
|
const key = `${isoMatch[1]}-${isoMatch[2]}-${isoMatch[3]}`;
|
||||||
if (!isNaN(d.getTime())) return d;
|
const d = new Date(`${key}T12:00:00.000Z`);
|
||||||
|
if (!isNaN(d.getTime()) && d.toISOString().slice(0, 10) === key) return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
// M/DD/YY or M/DD/YYYY
|
// M/DD/YY or M/DD/YYYY
|
||||||
@@ -98,11 +104,11 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
|
|||||||
let year = parseInt(slashParts[2] ?? "2000", 10);
|
let year = parseInt(slashParts[2] ?? "2000", 10);
|
||||||
if (year < 100) year += 2000;
|
if (year < 100) year += 2000;
|
||||||
const d = new Date(year, month, day);
|
const d = new Date(year, month, day);
|
||||||
if (!isNaN(d.getTime())) return d;
|
if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = new Date(trimmed);
|
const d = new Date(trimmed);
|
||||||
if (!isNaN(d.getTime())) return d;
|
if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,13 +134,11 @@ function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
|
|||||||
if (itemDates.length > 0) {
|
if (itemDates.length > 0) {
|
||||||
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
|
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
|
||||||
}
|
}
|
||||||
return fallback ?? new Date();
|
return fallback ?? calendarDateFromLocalDate(new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultDueDate(issueDate: Date): Date {
|
function defaultDueDate(issueDate: Date): Date {
|
||||||
const due = new Date(issueDate);
|
return addCalendarDays(issueDate, 30);
|
||||||
due.setDate(due.getDate() + 30);
|
|
||||||
return due;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseInvoiceCSV(
|
export function parseInvoiceCSV(
|
||||||
@@ -262,7 +266,9 @@ function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
|
|||||||
const rate = item.rate ?? 0;
|
const rate = item.rate ?? 0;
|
||||||
|
|
||||||
if (!description || description === "Imported item") {
|
if (!description || description === "Imported item") {
|
||||||
errors.push(`Invoice "${name}" item ${itemIdx + 1}: description required`);
|
errors.push(
|
||||||
|
`Invoice "${name}" item ${itemIdx + 1}: description required`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (quantity <= 0) {
|
if (quantity <= 0) {
|
||||||
errors.push(
|
errors.push(
|
||||||
@@ -356,7 +362,9 @@ export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
|
|||||||
{
|
{
|
||||||
name: "JSON Import",
|
name: "JSON Import",
|
||||||
items: [],
|
items: [],
|
||||||
errors: ['No invoices found (expected { "invoices": [...] } or an array)'],
|
errors: [
|
||||||
|
'No invoices found (expected { "invoices": [...] } or an array)',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,25 @@ import type {
|
|||||||
export function getEffectiveInvoiceStatus(
|
export function getEffectiveInvoiceStatus(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
|
timeZone?: string,
|
||||||
): EffectiveInvoiceStatus {
|
): EffectiveInvoiceStatus {
|
||||||
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
|
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate, timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isInvoiceOverdue(
|
export function isInvoiceOverdue(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
|
timeZone?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
return isSharedInvoiceOverdue(storedStatus, dueDate);
|
return isSharedInvoiceOverdue(storedStatus, dueDate, timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDaysPastDue(
|
export function getDaysPastDue(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
|
timeZone?: string,
|
||||||
): number {
|
): number {
|
||||||
return getSharedDaysPastDue(storedStatus, dueDate);
|
return getSharedDaysPastDue(storedStatus, dueDate, timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const statusConfig = {
|
export const statusConfig = {
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import {
|
|||||||
type Styles,
|
type Styles,
|
||||||
} from "@react-pdf/renderer";
|
} from "@react-pdf/renderer";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import {
|
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||||
isFixedLineItem,
|
import { isFixedLineItem } from "~/lib/invoice-line-item";
|
||||||
} from "~/lib/invoice-line-item";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
type PdfFontFamily,
|
type PdfFontFamily,
|
||||||
@@ -136,10 +135,7 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
|
|||||||
return { ...defaultPDFSettings, ...settings };
|
return { ...defaultPDFSettings, ...settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapLegacyPdfFont(
|
function mapLegacyPdfFont(fontFamily: string, fonts: ResolvedPdfFonts): string {
|
||||||
fontFamily: string,
|
|
||||||
fonts: ResolvedPdfFonts,
|
|
||||||
): string {
|
|
||||||
switch (fontFamily) {
|
switch (fontFamily) {
|
||||||
case "Helvetica-Bold":
|
case "Helvetica-Bold":
|
||||||
return fonts.bold;
|
return fonts.bold;
|
||||||
@@ -177,9 +173,7 @@ type PdfStyleBundle = {
|
|||||||
styles: typeof baseStyles;
|
styles: typeof baseStyles;
|
||||||
minimalStyles: typeof baseMinimalStyles;
|
minimalStyles: typeof baseMinimalStyles;
|
||||||
fonts: ResolvedPdfFonts;
|
fonts: ResolvedPdfFonts;
|
||||||
getStatusStyle: (
|
getStatusStyle: (status: string) => Array<Record<string, string | number>>;
|
||||||
status: string,
|
|
||||||
) => Array<Record<string, string | number>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pdfStyleCache = new Map<string, PdfStyleBundle>();
|
const pdfStyleCache = new Map<string, PdfStyleBundle>();
|
||||||
@@ -816,7 +810,7 @@ const formatCurrency = (amount: number, currency = "USD") => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Date(date).toLocaleDateString("en-US", {
|
return formatCalendarDate(date, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_TIME_ZONE,
|
||||||
|
getZonedDateTimeParts,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
export function invoiceLabel(inv: {
|
export function invoiceLabel(inv: {
|
||||||
invoicePrefix: string | null;
|
invoicePrefix: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
@@ -37,12 +42,13 @@ export type TimeEntryListItem = {
|
|||||||
|
|
||||||
export function groupEntriesByDate<T extends { startedAt: Date }>(
|
export function groupEntriesByDate<T extends { startedAt: Date }>(
|
||||||
entries: T[],
|
entries: T[],
|
||||||
|
timeZone = DEFAULT_TIME_ZONE,
|
||||||
): { dateKey: string; label: string; entries: T[] }[] {
|
): { dateKey: string; label: string; entries: T[] }[] {
|
||||||
const groups = new Map<string, T[]>();
|
const groups = new Map<string, T[]>();
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const d = new Date(entry.startedAt);
|
const parts = getZonedDateTimeParts(entry.startedAt, timeZone);
|
||||||
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
||||||
const existing = groups.get(dateKey);
|
const existing = groups.get(dateKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.push(entry);
|
existing.push(entry);
|
||||||
@@ -58,6 +64,7 @@ export function groupEntriesByDate<T extends { startedAt: Date }>(
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
timeZone,
|
||||||
});
|
});
|
||||||
return { dateKey, label, entries: groupEntries };
|
return { dateKey, label, entries: groupEntries };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import { invoiceItems, invoices, timeEntries } from "~/server/db/schema";
|
import { invoiceItems, invoices, timeEntries, users } from "~/server/db/schema";
|
||||||
import { resolveBillingDescription } from "~/lib/time-clock";
|
import { resolveBillingDescription } from "~/lib/time-clock";
|
||||||
|
import { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
type Db = typeof db;
|
type Db = typeof db;
|
||||||
|
|
||||||
@@ -110,6 +111,10 @@ export async function syncLinkedInvoiceItem(
|
|||||||
const rate = entry.rate ?? 0;
|
const rate = entry.rate ?? 0;
|
||||||
const amount = hours * rate;
|
const amount = hours * rate;
|
||||||
const description = resolveBillingDescription(entry.description ?? "");
|
const description = resolveBillingDescription(entry.description ?? "");
|
||||||
|
const owner = await database.query.users.findFirst({
|
||||||
|
where: eq(users.id, linked.invoice.createdById),
|
||||||
|
columns: { timeZone: true },
|
||||||
|
});
|
||||||
|
|
||||||
await database
|
await database
|
||||||
.update(invoiceItems)
|
.update(invoiceItems)
|
||||||
@@ -118,7 +123,10 @@ export async function syncLinkedInvoiceItem(
|
|||||||
hours,
|
hours,
|
||||||
rate,
|
rate,
|
||||||
amount,
|
amount,
|
||||||
date: entry.endedAt ?? entry.startedAt,
|
date: calendarDateFromInstant(
|
||||||
|
entry.endedAt ?? entry.startedAt,
|
||||||
|
owner?.timeZone ?? "America/New_York",
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.where(eq(invoiceItems.id, linked.id));
|
.where(eq(invoiceItems.id, linked.id));
|
||||||
|
|
||||||
@@ -136,7 +144,10 @@ export async function syncLinkedInvoiceItem(
|
|||||||
.where(eq(invoices.id, linked.invoiceId));
|
.where(eq(invoices.id, linked.invoiceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeLinkedInvoiceItem(database: Db, timeEntryId: string) {
|
export async function removeLinkedInvoiceItem(
|
||||||
|
database: Db,
|
||||||
|
timeEntryId: string,
|
||||||
|
) {
|
||||||
const linked = await findLinkedInvoiceItem(database, timeEntryId);
|
const linked = await findLinkedInvoiceItem(database, timeEntryId);
|
||||||
if (!linked?.invoice) return;
|
if (!linked?.invoice) return;
|
||||||
|
|
||||||
@@ -190,6 +201,10 @@ export async function relinkTimeEntryToInvoice(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!invoice) return null;
|
if (!invoice) return null;
|
||||||
|
const owner = await database.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
columns: { timeZone: true },
|
||||||
|
});
|
||||||
|
|
||||||
return insertInvoiceLineForTimeEntry(database, {
|
return insertInvoiceLineForTimeEntry(database, {
|
||||||
invoice,
|
invoice,
|
||||||
@@ -197,6 +212,9 @@ export async function relinkTimeEntryToInvoice(
|
|||||||
description: resolveBillingDescription(entry.description ?? ""),
|
description: resolveBillingDescription(entry.description ?? ""),
|
||||||
hours: entry.hours,
|
hours: entry.hours,
|
||||||
rate: entry.rate ?? 0,
|
rate: entry.rate ?? 0,
|
||||||
date: entry.endedAt,
|
date: calendarDateFromInstant(
|
||||||
|
entry.endedAt,
|
||||||
|
owner?.timeZone ?? "America/New_York",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices
|
|||||||
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
|
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
|
||||||
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
|
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
|
||||||
import { adminRouter } from "~/server/api/routers/admin";
|
import { adminRouter } from "~/server/api/routers/admin";
|
||||||
|
import { notificationsRouter } from "~/server/api/routers/notifications";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
apiKeys: apiKeysRouter,
|
apiKeys: apiKeysRouter,
|
||||||
timeEntries: timeEntriesRouter,
|
timeEntries: timeEntriesRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
|
notifications: notificationsRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { and, desc, eq, gte, lt } from "drizzle-orm";
|
import { and, desc, eq, gte, lt } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import { clients, invoices } from "~/server/db/schema";
|
import { clients, invoices, users } from "~/server/db/schema";
|
||||||
|
import {
|
||||||
|
formatCalendarDate,
|
||||||
|
getZonedDateTimeParts,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
type LiteInvoice = {
|
type LiteInvoice = {
|
||||||
@@ -12,20 +16,28 @@ type LiteInvoice = {
|
|||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildRevenueMonthKeys(now: Date, count: number) {
|
function buildRevenueMonthKeys(now: Date, count: number, timeZone: string) {
|
||||||
|
const current = getZonedDateTimeParts(now, timeZone);
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for (let i = count - 1; i >= 0; i--) {
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const d = new Date(Date.UTC(current.year, current.month - 1 - i, 1));
|
||||||
keys.push(
|
keys.push(
|
||||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
|
`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
function aggregateDashboardMetrics(
|
||||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
userInvoices: LiteInvoice[],
|
||||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
now: Date,
|
||||||
|
timeZone: string,
|
||||||
|
) {
|
||||||
|
const current = getZonedDateTimeParts(now, timeZone);
|
||||||
|
const currentMonthStart = new Date(
|
||||||
|
Date.UTC(current.year, current.month - 1, 1),
|
||||||
|
);
|
||||||
|
const lastMonthStart = new Date(Date.UTC(current.year, current.month - 2, 1));
|
||||||
|
|
||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
let pendingAmount = 0;
|
let pendingAmount = 0;
|
||||||
@@ -34,7 +46,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
|||||||
let lastMonthRevenue = 0;
|
let lastMonthRevenue = 0;
|
||||||
|
|
||||||
const revenueByMonth = Object.fromEntries(
|
const revenueByMonth = Object.fromEntries(
|
||||||
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]),
|
buildRevenueMonthKeys(now, 6, timeZone).map((key) => [key, 0]),
|
||||||
) as Record<string, number>;
|
) as Record<string, number>;
|
||||||
|
|
||||||
const statusTotals: Record<
|
const statusTotals: Record<
|
||||||
@@ -58,6 +70,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
|||||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
inv.status as StoredInvoiceStatus,
|
inv.status as StoredInvoiceStatus,
|
||||||
inv.dueDate,
|
inv.dueDate,
|
||||||
|
timeZone,
|
||||||
);
|
);
|
||||||
const amount = inv.totalAmount;
|
const amount = inv.totalAmount;
|
||||||
const issueDate = new Date(inv.issueDate);
|
const issueDate = new Date(inv.issueDate);
|
||||||
@@ -67,14 +80,11 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
|||||||
|
|
||||||
if (issueDate >= currentMonthStart) {
|
if (issueDate >= currentMonthStart) {
|
||||||
currentMonthRevenue += amount;
|
currentMonthRevenue += amount;
|
||||||
} else if (
|
} else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) {
|
||||||
issueDate >= lastMonthStart &&
|
|
||||||
issueDate < currentMonthStart
|
|
||||||
) {
|
|
||||||
lastMonthRevenue += amount;
|
lastMonthRevenue += amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
const revenueKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
|
const revenueKey = `${issueDate.getUTCFullYear()}-${String(issueDate.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
const monthRevenue = revenueByMonth[revenueKey];
|
const monthRevenue = revenueByMonth[revenueKey];
|
||||||
if (monthRevenue !== undefined) {
|
if (monthRevenue !== undefined) {
|
||||||
revenueByMonth[revenueKey] = monthRevenue + amount;
|
revenueByMonth[revenueKey] = monthRevenue + amount;
|
||||||
@@ -95,7 +105,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
|||||||
statusTotals[effectiveStatus].count += 1;
|
statusTotals[effectiveStatus].count += 1;
|
||||||
statusTotals[effectiveStatus].value += amount;
|
statusTotals[effectiveStatus].value += amount;
|
||||||
|
|
||||||
const monthKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
|
const monthKey = `${issueDate.getUTCFullYear()}-${String(issueDate.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
monthlyTotals[monthKey] ??= {
|
monthlyTotals[monthKey] ??= {
|
||||||
month: monthKey,
|
month: monthKey,
|
||||||
totalInvoices: 0,
|
totalInvoices: 0,
|
||||||
@@ -126,7 +136,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
|||||||
.map(([month, revenue]) => ({
|
.map(([month, revenue]) => ({
|
||||||
month,
|
month,
|
||||||
revenue,
|
revenue,
|
||||||
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
|
monthLabel: formatCalendarDate(month + "-01", {
|
||||||
month: "short",
|
month: "short",
|
||||||
year: "2-digit",
|
year: "2-digit",
|
||||||
}),
|
}),
|
||||||
@@ -143,7 +153,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
|||||||
.slice(-6)
|
.slice(-6)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
|
monthLabel: formatCalendarDate(item.month + "-01", {
|
||||||
month: "short",
|
month: "short",
|
||||||
year: "2-digit",
|
year: "2-digit",
|
||||||
}),
|
}),
|
||||||
@@ -167,6 +177,12 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
columns: { timeZone: true },
|
||||||
|
});
|
||||||
|
const timeZone = user?.timeZone ?? "America/New_York";
|
||||||
|
const current = getZonedDateTimeParts(now, timeZone);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
userInvoices,
|
userInvoices,
|
||||||
@@ -203,8 +219,14 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
ctx.db.query.invoices.findMany({
|
ctx.db.query.invoices.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(invoices.createdById, userId),
|
eq(invoices.createdById, userId),
|
||||||
gte(invoices.issueDate, new Date(now.getFullYear(), now.getMonth(), 1)),
|
gte(
|
||||||
lt(invoices.issueDate, new Date(now.getFullYear(), now.getMonth() + 1, 1)),
|
invoices.issueDate,
|
||||||
|
new Date(Date.UTC(current.year, current.month - 1, 1)),
|
||||||
|
),
|
||||||
|
lt(
|
||||||
|
invoices.issueDate,
|
||||||
|
new Date(Date.UTC(current.year, current.month, 1)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
orderBy: [
|
orderBy: [
|
||||||
desc(invoices.issueDate),
|
desc(invoices.issueDate),
|
||||||
@@ -249,7 +271,7 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const metrics = aggregateDashboardMetrics(userInvoices, now);
|
const metrics = aggregateDashboardMetrics(userInvoices, now, timeZone);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...metrics,
|
...metrics,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
businesses,
|
businesses,
|
||||||
platformSettings,
|
platformSettings,
|
||||||
|
users,
|
||||||
|
backgroundJobs,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||||
@@ -22,6 +24,7 @@ import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
|||||||
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
|
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import { resolveEmailSender } from "~/server/services/email-sender";
|
import { resolveEmailSender } from "~/server/services/email-sender";
|
||||||
|
import { jobTypes } from "~/server/jobs/queue";
|
||||||
|
|
||||||
type InvoiceRouterContext = {
|
type InvoiceRouterContext = {
|
||||||
db: typeof db;
|
db: typeof db;
|
||||||
@@ -249,6 +252,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
return await ctx.db.query.invoices.findMany({
|
return await ctx.db.query.invoices.findMany({
|
||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
with: {
|
with: {
|
||||||
|
createdBy: { columns: { timeZone: true } },
|
||||||
business: true,
|
business: true,
|
||||||
client: true,
|
client: true,
|
||||||
items: {
|
items: {
|
||||||
@@ -347,6 +351,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
const currentInvoice = await ctx.db.query.invoices.findFirst({
|
const currentInvoice = await ctx.db.query.invoices.findFirst({
|
||||||
where: eq(invoices.createdById, ctx.session.user.id),
|
where: eq(invoices.createdById, ctx.session.user.id),
|
||||||
with: {
|
with: {
|
||||||
|
createdBy: { columns: { timeZone: true } },
|
||||||
business: true,
|
business: true,
|
||||||
client: true,
|
client: true,
|
||||||
items: {
|
items: {
|
||||||
@@ -386,6 +391,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
where: eq(invoices.id, input.id),
|
where: eq(invoices.id, input.id),
|
||||||
with: {
|
with: {
|
||||||
|
createdBy: { columns: { timeZone: true } },
|
||||||
business: true,
|
business: true,
|
||||||
client: true,
|
client: true,
|
||||||
items: {
|
items: {
|
||||||
@@ -452,10 +458,16 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return await ctx.db.transaction(async (tx) => {
|
return await ctx.db.transaction(async (tx) => {
|
||||||
|
const invoiceId = crypto.randomUUID();
|
||||||
|
const sendReminderJobId = cleanInvoiceData.sendReminderAt
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: null;
|
||||||
const [invoice] = await tx
|
const [invoice] = await tx
|
||||||
.insert(invoices)
|
.insert(invoices)
|
||||||
.values({
|
.values({
|
||||||
|
id: invoiceId,
|
||||||
...cleanInvoiceData,
|
...cleanInvoiceData,
|
||||||
|
sendReminderJobId,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
createdById: ctx.session.user.id,
|
createdById: ctx.session.user.id,
|
||||||
})
|
})
|
||||||
@@ -479,6 +491,17 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sendReminderJobId && cleanInvoiceData.sendReminderAt) {
|
||||||
|
await tx.insert(backgroundJobs).values({
|
||||||
|
id: sendReminderJobId,
|
||||||
|
type: jobTypes.sendInvoiceReminder,
|
||||||
|
payload: { invoiceId, userId: ctx.session.user.id },
|
||||||
|
idempotencyKey: `${jobTypes.sendInvoiceReminder}:${invoiceId}:${cleanInvoiceData.sendReminderAt.toISOString()}:${sendReminderJobId}`,
|
||||||
|
runAt: cleanInvoiceData.sendReminderAt,
|
||||||
|
maxAttempts: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return invoice;
|
return invoice;
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -561,6 +584,34 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.transaction(async (tx) => {
|
await ctx.db.transaction(async (tx) => {
|
||||||
|
let sendReminderJobId = existingInvoice.sendReminderJobId;
|
||||||
|
if (cleanInvoiceData.sendReminderAt !== undefined) {
|
||||||
|
if (existingInvoice.sendReminderJobId) {
|
||||||
|
await tx
|
||||||
|
.update(backgroundJobs)
|
||||||
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
eq(backgroundJobs.id, existingInvoice.sendReminderJobId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sendReminderJobId = cleanInvoiceData.sendReminderAt
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: null;
|
||||||
|
if (sendReminderJobId && cleanInvoiceData.sendReminderAt) {
|
||||||
|
await tx.insert(backgroundJobs).values({
|
||||||
|
id: sendReminderJobId,
|
||||||
|
type: jobTypes.sendInvoiceReminder,
|
||||||
|
payload: { invoiceId: id, userId: ctx.session.user.id },
|
||||||
|
idempotencyKey: `${jobTypes.sendInvoiceReminder}:${id}:${cleanInvoiceData.sendReminderAt.toISOString()}:${sendReminderJobId}`,
|
||||||
|
runAt: cleanInvoiceData.sendReminderAt,
|
||||||
|
maxAttempts: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reminderJobPatch =
|
||||||
|
cleanInvoiceData.sendReminderAt !== undefined
|
||||||
|
? { sendReminderJobId }
|
||||||
|
: {};
|
||||||
if (items) {
|
if (items) {
|
||||||
const totalAmount = calculateInvoiceTotal(
|
const totalAmount = calculateInvoiceTotal(
|
||||||
items,
|
items,
|
||||||
@@ -571,6 +622,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
...cleanInvoiceData,
|
...cleanInvoiceData,
|
||||||
|
...reminderJobPatch,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -601,6 +653,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
...cleanInvoiceData,
|
...cleanInvoiceData,
|
||||||
|
...reminderJobPatch,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, id))
|
.where(eq(invoices.id, id))
|
||||||
@@ -1050,6 +1103,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
where: eq(invoices.publicToken, input.token),
|
where: eq(invoices.publicToken, input.token),
|
||||||
with: {
|
with: {
|
||||||
client: true,
|
client: true,
|
||||||
|
createdBy: { columns: { timeZone: true } },
|
||||||
// Explicit allowlist: this is a publicProcedure — never let
|
// Explicit allowlist: this is a publicProcedure — never let
|
||||||
// secret fields (resendApiKey, resendDomain) reach an
|
// secret fields (resendApiKey, resendDomain) reach an
|
||||||
// unauthenticated caller via the business relation.
|
// unauthenticated caller via the business relation.
|
||||||
@@ -1120,6 +1174,10 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
ctx.session.user.name ??
|
ctx.session.user.name ??
|
||||||
"";
|
"";
|
||||||
const userEmail = invoice.business?.email ?? ctx.session.user.email ?? "";
|
const userEmail = invoice.business?.email ?? ctx.session.user.email ?? "";
|
||||||
|
const owner = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, ctx.session.user.id),
|
||||||
|
columns: { timeZone: true },
|
||||||
|
});
|
||||||
|
|
||||||
const { html, text, subject } = generateReminderEmailTemplate({
|
const { html, text, subject } = generateReminderEmailTemplate({
|
||||||
invoice: {
|
invoice: {
|
||||||
@@ -1134,6 +1192,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
customMessage: input.customMessage,
|
customMessage: input.customMessage,
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
|
timeZone: owner?.timeZone ?? "America/New_York",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
|
import { pushTokens } from "~/server/db/schema";
|
||||||
|
|
||||||
|
const expoPushToken = z
|
||||||
|
.string()
|
||||||
|
.regex(/^ExponentPushToken\[[^\]]+\]$|^ExpoPushToken\[[^\]]+\]$/);
|
||||||
|
|
||||||
|
export const notificationsRouter = createTRPCRouter({
|
||||||
|
registerPushToken: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
token: expoPushToken,
|
||||||
|
platform: z.enum(["ios", "android"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.db
|
||||||
|
.insert(pushTokens)
|
||||||
|
.values({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
token: input.token,
|
||||||
|
platform: input.platform,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: pushTokens.token,
|
||||||
|
set: {
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
platform: input.platform,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
unregisterPushToken: protectedProcedure
|
||||||
|
.input(z.object({ token: expoPushToken }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const owned = await ctx.db.query.pushTokens.findFirst({
|
||||||
|
where: eq(pushTokens.token, input.token),
|
||||||
|
});
|
||||||
|
if (owned?.userId === ctx.session.user.id) {
|
||||||
|
await ctx.db
|
||||||
|
.delete(pushTokens)
|
||||||
|
.where(eq(pushTokens.token, input.token));
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -8,12 +8,20 @@ import {
|
|||||||
businesses,
|
businesses,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { generateInvoiceFromRecurring } from "~/server/services/recurring-invoices";
|
||||||
import {
|
import {
|
||||||
generateInvoiceFromRecurring,
|
DEFAULT_TIME_ZONE,
|
||||||
nextDueDate,
|
isValidTimeZone,
|
||||||
} from "~/server/services/recurring-invoices";
|
zonedDateTimeToInstant,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
const scheduleEnum = z.enum(["weekly", "biweekly", "monthly", "quarterly", "yearly"]);
|
const scheduleEnum = z.enum([
|
||||||
|
"weekly",
|
||||||
|
"biweekly",
|
||||||
|
"monthly",
|
||||||
|
"quarterly",
|
||||||
|
"yearly",
|
||||||
|
]);
|
||||||
|
|
||||||
const recurringItemSchema = z.object({
|
const recurringItemSchema = z.object({
|
||||||
description: z.string().min(1),
|
description: z.string().min(1),
|
||||||
@@ -32,9 +40,27 @@ const recurringInvoiceSchema = z.object({
|
|||||||
currency: z.string().length(3).default("USD"),
|
currency: z.string().length(3).default("USD"),
|
||||||
notes: z.string().optional().or(z.literal("")),
|
notes: z.string().optional().or(z.literal("")),
|
||||||
emailMessage: z.string().optional().or(z.literal("")),
|
emailMessage: z.string().optional().or(z.literal("")),
|
||||||
|
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
|
||||||
|
nextRunLocal: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/),
|
||||||
|
disambiguation: z.enum(["earlier", "later", "reject"]).default("reject"),
|
||||||
items: z.array(recurringItemSchema).min(1),
|
items: z.array(recurringItemSchema).min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function parseNextRun(input: z.infer<typeof recurringInvoiceSchema>) {
|
||||||
|
try {
|
||||||
|
return zonedDateTimeToInstant(
|
||||||
|
input.nextRunLocal,
|
||||||
|
input.timeZone,
|
||||||
|
input.disambiguation,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: error instanceof Error ? error.message : "Invalid recurring run time",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const recurringInvoicesRouter = createTRPCRouter({
|
export const recurringInvoicesRouter = createTRPCRouter({
|
||||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||||
return ctx.db.query.recurringInvoices.findMany({
|
return ctx.db.query.recurringInvoices.findMany({
|
||||||
@@ -51,14 +77,20 @@ export const recurringInvoicesRouter = createTRPCRouter({
|
|||||||
where: eq(clients.id, input.clientId),
|
where: eq(clients.id, input.clientId),
|
||||||
});
|
});
|
||||||
if (client?.createdById !== ctx.session.user.id) {
|
if (client?.createdById !== ctx.session.user.id) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Client not found" });
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Client not found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (input.businessId) {
|
if (input.businessId) {
|
||||||
const biz = await ctx.db.query.businesses.findFirst({
|
const biz = await ctx.db.query.businesses.findFirst({
|
||||||
where: eq(businesses.id, input.businessId),
|
where: eq(businesses.id, input.businessId),
|
||||||
});
|
});
|
||||||
if (biz?.createdById !== ctx.session.user.id) {
|
if (biz?.createdById !== ctx.session.user.id) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Business not found" });
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Business not found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +107,8 @@ export const recurringInvoicesRouter = createTRPCRouter({
|
|||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
emailMessage: input.emailMessage ?? null,
|
emailMessage: input.emailMessage ?? null,
|
||||||
nextDueAt: nextDueDate(input.schedule),
|
nextDueAt: parseNextRun(input),
|
||||||
|
timeZone: input.timeZone,
|
||||||
createdById: ctx.session.user.id,
|
createdById: ctx.session.user.id,
|
||||||
})
|
})
|
||||||
.returning({ id: recurringInvoices.id });
|
.returning({ id: recurringInvoices.id });
|
||||||
@@ -117,6 +150,8 @@ export const recurringInvoicesRouter = createTRPCRouter({
|
|||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
emailMessage: input.emailMessage ?? null,
|
emailMessage: input.emailMessage ?? null,
|
||||||
|
nextDueAt: parseNextRun(input),
|
||||||
|
timeZone: input.timeZone,
|
||||||
})
|
})
|
||||||
.where(eq(recurringInvoices.id, input.id));
|
.where(eq(recurringInvoices.id, input.id));
|
||||||
|
|
||||||
@@ -195,11 +230,12 @@ export const recurringInvoicesRouter = createTRPCRouter({
|
|||||||
throw new TRPCError({ code: "NOT_FOUND" });
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const newInvoice = await generateInvoiceFromRecurring(ctx.db, rec);
|
const now = new Date();
|
||||||
|
const newInvoice = await generateInvoiceFromRecurring(ctx.db, rec, now);
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(recurringInvoices)
|
.update(recurringInvoices)
|
||||||
.set({ lastGeneratedAt: new Date(), nextDueAt: nextDueDate(rec.schedule) })
|
.set({ lastGeneratedAt: now })
|
||||||
.where(eq(recurringInvoices.id, input.id));
|
.where(eq(recurringInvoices.id, input.id));
|
||||||
|
|
||||||
return { invoiceId: newInvoice.id };
|
return { invoiceId: newInvoice.id };
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ import {
|
|||||||
type ColorMode,
|
type ColorMode,
|
||||||
} from "~/lib/branding";
|
} from "~/lib/branding";
|
||||||
import { revokeUserSessions } from "~/lib/session-security";
|
import { revokeUserSessions } from "~/lib/session-security";
|
||||||
|
import {
|
||||||
|
DEFAULT_TIME_ZONE,
|
||||||
|
isValidTimeZone,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
function resolveBusinessId(
|
function resolveBusinessId(
|
||||||
refs: { businessName?: string; businessNickname?: string },
|
refs: { businessName?: string; businessNickname?: string },
|
||||||
@@ -156,6 +160,7 @@ const RecurringInvoiceBackupSchema = z.object({
|
|||||||
currency: z.string().default("USD"),
|
currency: z.string().default("USD"),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
emailMessage: z.string().optional(),
|
emailMessage: z.string().optional(),
|
||||||
|
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
|
||||||
nextDueAt: z.coerce.date(),
|
nextDueAt: z.coerce.date(),
|
||||||
lastGeneratedAt: z.coerce.date().optional(),
|
lastGeneratedAt: z.coerce.date().optional(),
|
||||||
items: z.array(RecurringInvoiceItemBackupSchema),
|
items: z.array(RecurringInvoiceItemBackupSchema),
|
||||||
@@ -197,6 +202,7 @@ const BackupDataSchema = z.object({
|
|||||||
prefersReducedMotion: z.boolean().optional(),
|
prefersReducedMotion: z.boolean().optional(),
|
||||||
animationSpeedMultiplier: z.number().optional(),
|
animationSpeedMultiplier: z.number().optional(),
|
||||||
theme: z.string().optional(),
|
theme: z.string().optional(),
|
||||||
|
timeZone: z.string().refine(isValidTimeZone).optional(),
|
||||||
onboardingCompletedAt: z.coerce.date().nullable().optional(),
|
onboardingCompletedAt: z.coerce.date().nullable().optional(),
|
||||||
}),
|
}),
|
||||||
clients: z.array(ClientBackupSchema),
|
clients: z.array(ClientBackupSchema),
|
||||||
@@ -291,6 +297,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
email: true,
|
email: true,
|
||||||
image: true,
|
image: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
timeZone: true,
|
||||||
onboardingCompletedAt: true,
|
onboardingCompletedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -507,6 +514,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
|
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -514,6 +522,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
timeZone: input.timeZone,
|
||||||
})
|
})
|
||||||
.where(eq(users.id, ctx.session.user.id));
|
.where(eq(users.id, ctx.session.user.id));
|
||||||
|
|
||||||
@@ -621,6 +630,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
prefersReducedMotion: true,
|
prefersReducedMotion: true,
|
||||||
animationSpeedMultiplier: true,
|
animationSpeedMultiplier: true,
|
||||||
theme: true,
|
theme: true,
|
||||||
|
timeZone: true,
|
||||||
onboardingCompletedAt: true,
|
onboardingCompletedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -759,6 +769,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
prefersReducedMotion: user?.prefersReducedMotion ?? false,
|
prefersReducedMotion: user?.prefersReducedMotion ?? false,
|
||||||
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
|
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
|
||||||
theme: user?.theme ?? "system",
|
theme: user?.theme ?? "system",
|
||||||
|
timeZone: user?.timeZone ?? DEFAULT_TIME_ZONE,
|
||||||
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
|
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
|
||||||
},
|
},
|
||||||
clients: userClients.map((client) => ({
|
clients: userClients.map((client) => ({
|
||||||
@@ -835,6 +846,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
currency: recurring.currency,
|
currency: recurring.currency,
|
||||||
notes: recurring.notes ?? undefined,
|
notes: recurring.notes ?? undefined,
|
||||||
emailMessage: recurring.emailMessage ?? undefined,
|
emailMessage: recurring.emailMessage ?? undefined,
|
||||||
|
timeZone: recurring.timeZone,
|
||||||
nextDueAt: recurring.nextDueAt,
|
nextDueAt: recurring.nextDueAt,
|
||||||
lastGeneratedAt: recurring.lastGeneratedAt ?? undefined,
|
lastGeneratedAt: recurring.lastGeneratedAt ?? undefined,
|
||||||
items: recurring.items,
|
items: recurring.items,
|
||||||
@@ -1002,6 +1014,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
currency: recurringData.currency,
|
currency: recurringData.currency,
|
||||||
notes: recurringData.notes,
|
notes: recurringData.notes,
|
||||||
emailMessage: recurringData.emailMessage,
|
emailMessage: recurringData.emailMessage,
|
||||||
|
timeZone: recurringData.timeZone,
|
||||||
nextDueAt: recurringData.nextDueAt,
|
nextDueAt: recurringData.nextDueAt,
|
||||||
lastGeneratedAt: recurringData.lastGeneratedAt,
|
lastGeneratedAt: recurringData.lastGeneratedAt,
|
||||||
createdById: userId,
|
createdById: userId,
|
||||||
@@ -1110,6 +1123,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
...(input.user.animationSpeedMultiplier !== undefined && {
|
...(input.user.animationSpeedMultiplier !== undefined && {
|
||||||
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
|
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
|
||||||
}),
|
}),
|
||||||
|
...(input.user.timeZone !== undefined && {
|
||||||
|
timeZone: input.user.timeZone,
|
||||||
|
}),
|
||||||
...(input.user.theme !== undefined && {
|
...(input.user.theme !== undefined && {
|
||||||
theme: input.user.theme,
|
theme: input.user.theme,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { timeEntries, clients, invoices, businesses } from "~/server/db/schema";
|
import {
|
||||||
|
timeEntries,
|
||||||
|
clients,
|
||||||
|
invoices,
|
||||||
|
businesses,
|
||||||
|
users,
|
||||||
|
} from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +23,7 @@ import {
|
|||||||
removeLinkedInvoiceItem,
|
removeLinkedInvoiceItem,
|
||||||
syncLinkedInvoiceItem,
|
syncLinkedInvoiceItem,
|
||||||
} from "~/server/api/lib/time-entry-invoice-sync";
|
} from "~/server/api/lib/time-entry-invoice-sync";
|
||||||
|
import { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
type Db = typeof db;
|
type Db = typeof db;
|
||||||
|
|
||||||
@@ -55,20 +62,31 @@ function computeHours(startedAt: Date, endedAt: Date): number {
|
|||||||
|
|
||||||
async function addEntryToInvoice(
|
async function addEntryToInvoice(
|
||||||
database: Db,
|
database: Db,
|
||||||
invoice: { id: string; invoiceNumber: string; invoicePrefix: string | null; taxRate: number; items: { amount: number; position: number }[] },
|
userId: string,
|
||||||
|
invoice: {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
invoicePrefix: string | null;
|
||||||
|
taxRate: number;
|
||||||
|
items: { amount: number; position: number }[];
|
||||||
|
},
|
||||||
entryId: string,
|
entryId: string,
|
||||||
description: string,
|
description: string,
|
||||||
hours: number,
|
hours: number,
|
||||||
rate: number,
|
rate: number,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
|
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
|
||||||
|
const owner = await database.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
columns: { timeZone: true },
|
||||||
|
});
|
||||||
return insertInvoiceLineForTimeEntry(database, {
|
return insertInvoiceLineForTimeEntry(database, {
|
||||||
invoice,
|
invoice,
|
||||||
entryId,
|
entryId,
|
||||||
description,
|
description,
|
||||||
hours,
|
hours,
|
||||||
rate,
|
rate,
|
||||||
date,
|
date: calendarDateFromInstant(date, owner?.timeZone ?? "America/New_York"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,11 +118,21 @@ async function findOrCreateDraftInvoice(
|
|||||||
if (!client) return null;
|
if (!client) return null;
|
||||||
|
|
||||||
const defaultBusiness = await database.query.businesses.findFirst({
|
const defaultBusiness = await database.query.businesses.findFirst({
|
||||||
where: and(eq(businesses.createdById, userId), eq(businesses.isDefault, true)),
|
where: and(
|
||||||
|
eq(businesses.createdById, userId),
|
||||||
|
eq(businesses.isDefault, true),
|
||||||
|
),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const issueDate = new Date();
|
const owner = await database.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
columns: { timeZone: true },
|
||||||
|
});
|
||||||
|
const issueDate = calendarDateFromInstant(
|
||||||
|
new Date(),
|
||||||
|
owner?.timeZone ?? "America/New_York",
|
||||||
|
);
|
||||||
const [created] = await database
|
const [created] = await database
|
||||||
.insert(invoices)
|
.insert(invoices)
|
||||||
.values({
|
.values({
|
||||||
@@ -135,10 +163,23 @@ async function addEntryToLatestInvoice(
|
|||||||
hours: number,
|
hours: number,
|
||||||
rate: number,
|
rate: number,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
invoicePrefix: string;
|
||||||
|
} | null> {
|
||||||
const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
|
const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
|
||||||
if (!invoice) return null;
|
if (!invoice) return null;
|
||||||
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
|
return addEntryToInvoice(
|
||||||
|
database,
|
||||||
|
userId,
|
||||||
|
invoice,
|
||||||
|
entryId,
|
||||||
|
description,
|
||||||
|
hours,
|
||||||
|
rate,
|
||||||
|
date,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addEntryToSpecificInvoice(
|
async function addEntryToSpecificInvoice(
|
||||||
@@ -150,7 +191,11 @@ async function addEntryToSpecificInvoice(
|
|||||||
hours: number,
|
hours: number,
|
||||||
rate: number,
|
rate: number,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
invoicePrefix: string;
|
||||||
|
} | null> {
|
||||||
const invoice = await database.query.invoices.findFirst({
|
const invoice = await database.query.invoices.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(invoices.id, invoiceId),
|
eq(invoices.id, invoiceId),
|
||||||
@@ -161,7 +206,16 @@ async function addEntryToSpecificInvoice(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!invoice) return null;
|
if (!invoice) return null;
|
||||||
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
|
return addEntryToInvoice(
|
||||||
|
database,
|
||||||
|
userId,
|
||||||
|
invoice,
|
||||||
|
entryId,
|
||||||
|
description,
|
||||||
|
hours,
|
||||||
|
rate,
|
||||||
|
date,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const timeEntriesRouter = createTRPCRouter({
|
export const timeEntriesRouter = createTRPCRouter({
|
||||||
@@ -177,13 +231,19 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const conditions = [eq(timeEntries.createdById, ctx.session.user.id)];
|
const conditions = [eq(timeEntries.createdById, ctx.session.user.id)];
|
||||||
if (input?.clientId) conditions.push(eq(timeEntries.clientId, input.clientId));
|
if (input?.clientId)
|
||||||
|
conditions.push(eq(timeEntries.clientId, input.clientId));
|
||||||
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
|
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
|
||||||
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
|
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
|
||||||
|
|
||||||
return ctx.db.query.timeEntries.findMany({
|
return ctx.db.query.timeEntries.findMany({
|
||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
with: { client: true, invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } } },
|
with: {
|
||||||
|
client: true,
|
||||||
|
invoice: {
|
||||||
|
columns: { id: true, invoiceNumber: true, invoicePrefix: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: [desc(timeEntries.startedAt)],
|
orderBy: [desc(timeEntries.startedAt)],
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -198,7 +258,11 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
),
|
),
|
||||||
with: { client: true },
|
with: { client: true },
|
||||||
});
|
});
|
||||||
if (!entry) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
if (!entry)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Time entry not found",
|
||||||
|
});
|
||||||
return entry;
|
return entry;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -247,10 +311,17 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
let clientRecord: { defaultHourlyRate: number | null } | null = null;
|
let clientRecord: { defaultHourlyRate: number | null } | null = null;
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const found = await ctx.db.query.clients.findFirst({
|
const found = await ctx.db.query.clients.findFirst({
|
||||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
where: and(
|
||||||
|
eq(clients.id, clientId),
|
||||||
|
eq(clients.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
columns: { defaultHourlyRate: true },
|
columns: { defaultHourlyRate: true },
|
||||||
});
|
});
|
||||||
if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
if (!found)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Client not found",
|
||||||
|
});
|
||||||
clientRecord = found;
|
clientRecord = found;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +353,10 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const startedAt = input.startedAt ?? new Date();
|
const startedAt = input.startedAt ?? new Date();
|
||||||
if (startedAt > new Date()) {
|
if (startedAt > new Date()) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" });
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "startedAt cannot be in the future",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clientRecord && resolvedClientId) {
|
if (!clientRecord && resolvedClientId) {
|
||||||
@@ -337,7 +411,10 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No running timer found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates: {
|
const updates: {
|
||||||
@@ -369,9 +446,16 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
const clientId = input.clientId.trim() || null;
|
const clientId = input.clientId.trim() || null;
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const found = await ctx.db.query.clients.findFirst({
|
const found = await ctx.db.query.clients.findFirst({
|
||||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
where: and(
|
||||||
|
eq(clients.id, clientId),
|
||||||
|
eq(clients.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!found)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Client not found",
|
||||||
});
|
});
|
||||||
if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
|
||||||
}
|
}
|
||||||
resolvedClientId = clientId;
|
resolvedClientId = clientId;
|
||||||
updates.clientId = clientId;
|
updates.clientId = clientId;
|
||||||
@@ -427,7 +511,10 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Update failed",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
@@ -435,10 +522,12 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
clockOut: protectedProcedure
|
clockOut: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z
|
||||||
|
.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
}).optional(),
|
})
|
||||||
|
.optional(),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
@@ -452,24 +541,41 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No running timer found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const endedAt = new Date();
|
const endedAt = new Date();
|
||||||
const hours = computeHours(entry.startedAt, endedAt);
|
const hours = computeHours(entry.startedAt, endedAt);
|
||||||
const rawDescription = input?.description?.trim() ?? entry.description?.trim() ?? "";
|
const rawDescription =
|
||||||
|
input?.description?.trim() ?? entry.description?.trim() ?? "";
|
||||||
const billingDescription = resolveBillingDescription(rawDescription);
|
const billingDescription = resolveBillingDescription(rawDescription);
|
||||||
const rate = entry.rate ?? 0;
|
const rate = entry.rate ?? 0;
|
||||||
|
|
||||||
const [updated] = await ctx.db
|
const [updated] = await ctx.db
|
||||||
.update(timeEntries)
|
.update(timeEntries)
|
||||||
.set({ endedAt, hours, description: rawDescription, updatedAt: new Date() })
|
.set({
|
||||||
|
endedAt,
|
||||||
|
hours,
|
||||||
|
description: rawDescription,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
.where(eq(timeEntries.id, entry.id))
|
.where(eq(timeEntries.id, entry.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Clock out failed" });
|
if (!updated)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Clock out failed",
|
||||||
|
});
|
||||||
|
|
||||||
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
|
let linkedInvoice: {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
invoicePrefix: string;
|
||||||
|
} | null = null;
|
||||||
let outcome: ClockOutOutcome = "zero_hours";
|
let outcome: ClockOutOutcome = "zero_hours";
|
||||||
|
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
@@ -518,9 +624,16 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
const clientId = normalizeOptionalId(input.clientId);
|
const clientId = normalizeOptionalId(input.clientId);
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
where: and(
|
||||||
|
eq(clients.id, clientId),
|
||||||
|
eq(clients.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!client)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Client not found",
|
||||||
});
|
});
|
||||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let hours = input.hours ?? null;
|
let hours = input.hours ?? null;
|
||||||
@@ -542,9 +655,17 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!entry) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Create failed" });
|
if (!entry)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Create failed",
|
||||||
|
});
|
||||||
|
|
||||||
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
|
let linkedInvoice: {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
invoicePrefix: string;
|
||||||
|
} | null = null;
|
||||||
if (clientId && hours && input.endedAt) {
|
if (clientId && hours && input.endedAt) {
|
||||||
linkedInvoice = await addEntryToLatestInvoice(
|
linkedInvoice = await addEntryToLatestInvoice(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -576,7 +697,11 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
eq(timeEntries.createdById, ctx.session.user.id),
|
eq(timeEntries.createdById, ctx.session.user.id),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
if (!existing)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Time entry not found",
|
||||||
|
});
|
||||||
|
|
||||||
if (existing.endedAt == null) {
|
if (existing.endedAt == null) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -590,16 +715,28 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
where: and(
|
||||||
|
eq(clients.id, clientId),
|
||||||
|
eq(clients.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!client)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Client not found",
|
||||||
});
|
});
|
||||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let hours = data.hours;
|
let hours = data.hours;
|
||||||
const startedAt = data.startedAt ?? existing.startedAt;
|
const startedAt = data.startedAt ?? existing.startedAt;
|
||||||
const endedAt = data.endedAt ?? existing.endedAt;
|
const endedAt = data.endedAt ?? existing.endedAt;
|
||||||
|
|
||||||
if (endedAt && (data.startedAt !== undefined || data.endedAt !== undefined || data.hours === undefined)) {
|
if (
|
||||||
|
endedAt &&
|
||||||
|
(data.startedAt !== undefined ||
|
||||||
|
data.endedAt !== undefined ||
|
||||||
|
data.hours === undefined)
|
||||||
|
) {
|
||||||
hours = computeHours(startedAt, endedAt);
|
hours = computeHours(startedAt, endedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,11 +756,19 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Update failed",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextInvoiceId !== undefined) {
|
if (nextInvoiceId !== undefined) {
|
||||||
await relinkTimeEntryToInvoice(ctx.db, ctx.session.user.id, updated, nextInvoiceId.trim() || null);
|
await relinkTimeEntryToInvoice(
|
||||||
|
ctx.db,
|
||||||
|
ctx.session.user.id,
|
||||||
|
updated,
|
||||||
|
nextInvoiceId.trim() || null,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await syncLinkedInvoiceItem(ctx.db, updated);
|
await syncLinkedInvoiceItem(ctx.db, updated);
|
||||||
}
|
}
|
||||||
@@ -640,7 +785,11 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
eq(timeEntries.createdById, ctx.session.user.id),
|
eq(timeEntries.createdById, ctx.session.user.id),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
if (!existing)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Time entry not found",
|
||||||
|
});
|
||||||
|
|
||||||
await removeLinkedInvoiceItem(ctx.db, input.id);
|
await removeLinkedInvoiceItem(ctx.db, input.id);
|
||||||
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
|
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
|
||||||
@@ -649,10 +798,12 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
getSummary: protectedProcedure
|
getSummary: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z
|
||||||
|
.object({
|
||||||
from: z.date().optional(),
|
from: z.date().optional(),
|
||||||
to: z.date().optional(),
|
to: z.date().optional(),
|
||||||
}).optional(),
|
})
|
||||||
|
.optional(),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
|
|||||||
@@ -20,21 +20,22 @@ export const users = createTable("user", (d) => ({
|
|||||||
email: d.varchar({ length: 255 }).notNull().unique(),
|
email: d.varchar({ length: 255 }).notNull().unique(),
|
||||||
emailVerified: d.boolean().default(false).notNull(),
|
emailVerified: d.boolean().default(false).notNull(),
|
||||||
image: d.varchar({ length: 255 }),
|
image: d.varchar({ length: 255 }),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
timeZone: d.varchar({ length: 100 }).notNull().default("America/New_York"),
|
||||||
|
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: d
|
updatedAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => new Date()),
|
.$onUpdate(() => new Date()),
|
||||||
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
||||||
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
||||||
resetTokenExpiry: d.timestamp(),
|
resetTokenExpiry: d.timestamp({ withTimezone: true }),
|
||||||
// Custom fields
|
// Custom fields
|
||||||
prefersReducedMotion: d.boolean().default(false).notNull(),
|
prefersReducedMotion: d.boolean().default(false).notNull(),
|
||||||
animationSpeedMultiplier: d.real().default(1).notNull(),
|
animationSpeedMultiplier: d.real().default(1).notNull(),
|
||||||
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
||||||
role: d.varchar({ length: 20 }).default("user").notNull(),
|
role: d.varchar({ length: 20 }).default("user").notNull(),
|
||||||
onboardingCompletedAt: d.timestamp(),
|
onboardingCompletedAt: d.timestamp({ withTimezone: true }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const platformSettings = createTable("platform_setting", (d) => ({
|
export const platformSettings = createTable("platform_setting", (d) => ({
|
||||||
@@ -49,9 +50,9 @@ export const platformSettings = createTable("platform_setting", (d) => ({
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
pdfShowLogo: d.boolean().default(true).notNull(),
|
pdfShowLogo: d.boolean().default(true).notNull(),
|
||||||
pdfShowPageNumbers: d.boolean().default(true).notNull(),
|
pdfShowPageNumbers: d.boolean().default(true).notNull(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: d
|
updatedAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => new Date()),
|
.$onUpdate(() => new Date()),
|
||||||
@@ -68,6 +69,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
invoiceTemplates: many(invoiceTemplates),
|
invoiceTemplates: many(invoiceTemplates),
|
||||||
recurringInvoices: many(recurringInvoices),
|
recurringInvoices: many(recurringInvoices),
|
||||||
timeEntries: many(timeEntries),
|
timeEntries: many(timeEntries),
|
||||||
|
pushTokens: many(pushTokens),
|
||||||
auditLogsAsActor: many(auditLog),
|
auditLogsAsActor: many(auditLog),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ export const auditLog = createTable(
|
|||||||
targetType: d.varchar({ length: 50 }).notNull(),
|
targetType: d.varchar({ length: 50 }).notNull(),
|
||||||
targetId: d.varchar({ length: 255 }),
|
targetId: d.varchar({ length: 255 }),
|
||||||
metadata: d.jsonb().$type<Record<string, unknown>>(),
|
metadata: d.jsonb().$type<Record<string, unknown>>(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("audit_log_actor_user_id_idx").on(t.actorUserId),
|
index("audit_log_actor_user_id_idx").on(t.actorUserId),
|
||||||
@@ -119,14 +121,14 @@ export const accounts = createTable(
|
|||||||
providerId: d.varchar({ length: 255 }).notNull(),
|
providerId: d.varchar({ length: 255 }).notNull(),
|
||||||
accessToken: d.text(),
|
accessToken: d.text(),
|
||||||
refreshToken: d.text(),
|
refreshToken: d.text(),
|
||||||
accessTokenExpiresAt: d.timestamp(),
|
accessTokenExpiresAt: d.timestamp({ withTimezone: true }),
|
||||||
refreshTokenExpiresAt: d.timestamp(),
|
refreshTokenExpiresAt: d.timestamp({ withTimezone: true }),
|
||||||
scope: d.varchar({ length: 255 }),
|
scope: d.varchar({ length: 255 }),
|
||||||
idToken: d.text(),
|
idToken: d.text(),
|
||||||
password: d.text(), // Matched DB: text
|
password: d.text(), // Matched DB: text
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: d
|
updatedAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => new Date()),
|
.$onUpdate(() => new Date()),
|
||||||
@@ -151,12 +153,12 @@ export const sessions = createTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
token: d.varchar({ length: 255 }).notNull().unique(),
|
token: d.varchar({ length: 255 }).notNull().unique(),
|
||||||
expiresAt: d.timestamp().notNull(),
|
expiresAt: d.timestamp({ withTimezone: true }).notNull(),
|
||||||
ipAddress: d.text(), // Matched DB: text
|
ipAddress: d.text(), // Matched DB: text
|
||||||
userAgent: d.text(), // Matched DB: text
|
userAgent: d.text(), // Matched DB: text
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: d
|
updatedAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => new Date()),
|
.$onUpdate(() => new Date()),
|
||||||
@@ -183,12 +185,12 @@ export const apiKeys = createTable(
|
|||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
lastUsedAt: d.timestamp(),
|
lastUsedAt: d.timestamp({ withTimezone: true }),
|
||||||
expiresAt: d.timestamp(),
|
expiresAt: d.timestamp({ withTimezone: true }),
|
||||||
revokedAt: d.timestamp(),
|
revokedAt: d.timestamp({ withTimezone: true }),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: d
|
updatedAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => new Date()),
|
.$onUpdate(() => new Date()),
|
||||||
@@ -214,10 +216,10 @@ export const verificationTokens = createTable(
|
|||||||
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
||||||
identifier: d.varchar({ length: 255 }).notNull(),
|
identifier: d.varchar({ length: 255 }).notNull(),
|
||||||
value: d.text().notNull(),
|
value: d.text().notNull(),
|
||||||
expiresAt: d.timestamp().notNull(),
|
expiresAt: d.timestamp({ withTimezone: true }).notNull(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: d
|
updatedAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => new Date()),
|
.$onUpdate(() => new Date()),
|
||||||
@@ -241,9 +243,9 @@ export const ssoProviders = createTable(
|
|||||||
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
|
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
|
||||||
oidcConfig: d.text(),
|
oidcConfig: d.text(),
|
||||||
samlConfig: d.text(),
|
samlConfig: d.text(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: d
|
updatedAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => new Date()),
|
.$onUpdate(() => new Date()),
|
||||||
@@ -276,10 +278,10 @@ export const clients = createTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("client_created_by_idx").on(t.createdById),
|
index("client_created_by_idx").on(t.createdById),
|
||||||
@@ -331,10 +333,10 @@ export const businesses = createTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("business_created_by_idx").on(t.createdById),
|
index("business_created_by_idx").on(t.createdById),
|
||||||
@@ -368,8 +370,8 @@ export const invoices = createTable(
|
|||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id),
|
.references(() => clients.id),
|
||||||
issueDate: d.timestamp().notNull(),
|
issueDate: d.date({ mode: "date" }).notNull(),
|
||||||
dueDate: d.timestamp().notNull(),
|
dueDate: d.date({ mode: "date" }).notNull(),
|
||||||
status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed)
|
status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed)
|
||||||
totalAmount: d.real().notNull().default(0),
|
totalAmount: d.real().notNull().default(0),
|
||||||
taxRate: d.real().notNull().default(0.0),
|
taxRate: d.real().notNull().default(0.0),
|
||||||
@@ -381,19 +383,20 @@ export const invoices = createTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
publicToken: d.varchar({ length: 255 }).unique(),
|
publicToken: d.varchar({ length: 255 }).unique(),
|
||||||
publicTokenExpiresAt: d.timestamp(),
|
publicTokenExpiresAt: d.timestamp({ withTimezone: true }),
|
||||||
lastReminderSentAt: d.timestamp(),
|
lastReminderSentAt: d.timestamp({ withTimezone: true }),
|
||||||
sendReminderAt: d.timestamp(),
|
sendReminderAt: d.timestamp({ withTimezone: true }),
|
||||||
|
sendReminderJobId: d.varchar({ length: 255 }),
|
||||||
sentAt: d.timestamp({ withTimezone: true }),
|
sentAt: d.timestamp({ withTimezone: true }),
|
||||||
scheduledSendAt: d.timestamp({ withTimezone: true }),
|
scheduledSendAt: d.timestamp({ withTimezone: true }),
|
||||||
scheduledSendTimeZone: d.varchar({ length: 100 }),
|
scheduledSendTimeZone: d.varchar({ length: 100 }),
|
||||||
scheduledSendJobId: d.varchar({ length: 255 }),
|
scheduledSendJobId: d.varchar({ length: 255 }),
|
||||||
scheduledSendStatus: d.varchar({ length: 20 }), // pending | processing | completed | failed | cancelled
|
scheduledSendStatus: d.varchar({ length: 20 }), // pending | processing | completed | failed | cancelled
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("invoice_business_id_idx").on(t.businessId),
|
index("invoice_business_id_idx").on(t.businessId),
|
||||||
@@ -436,7 +439,7 @@ export const invoiceItems = createTable(
|
|||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => invoices.id, { onDelete: "cascade" }),
|
.references(() => invoices.id, { onDelete: "cascade" }),
|
||||||
date: d.timestamp().notNull(),
|
date: d.date({ mode: "date" }).notNull(),
|
||||||
description: d.varchar({ length: 500 }).notNull(),
|
description: d.varchar({ length: 500 }).notNull(),
|
||||||
hours: d.real().notNull(),
|
hours: d.real().notNull(),
|
||||||
rate: d.real().notNull(),
|
rate: d.real().notNull(),
|
||||||
@@ -446,7 +449,7 @@ export const invoiceItems = createTable(
|
|||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.references(() => timeEntries.id, { onDelete: "set null" }),
|
.references(() => timeEntries.id, { onDelete: "set null" }),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
}),
|
}),
|
||||||
@@ -481,7 +484,7 @@ export const expenses = createTable(
|
|||||||
invoiceId: d
|
invoiceId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.references(() => invoices.id, { onDelete: "set null" }),
|
.references(() => invoices.id, { onDelete: "set null" }),
|
||||||
date: d.timestamp().notNull(),
|
date: d.date({ mode: "date" }).notNull(),
|
||||||
description: d.varchar({ length: 500 }).notNull(),
|
description: d.varchar({ length: 500 }).notNull(),
|
||||||
amount: d.real().notNull(),
|
amount: d.real().notNull(),
|
||||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
@@ -495,10 +498,10 @@ export const expenses = createTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("expense_created_by_idx").on(t.createdById),
|
index("expense_created_by_idx").on(t.createdById),
|
||||||
@@ -527,7 +530,7 @@ export const expenseReceipts = createTable(
|
|||||||
mimeType: d.varchar({ length: 100 }).notNull(),
|
mimeType: d.varchar({ length: 100 }).notNull(),
|
||||||
sizeBytes: d.integer().notNull(),
|
sizeBytes: d.integer().notNull(),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
}),
|
}),
|
||||||
@@ -581,10 +584,10 @@ export const invoiceTemplates = createTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("invoice_template_created_by_idx").on(t.createdById),
|
index("invoice_template_created_by_idx").on(t.createdById),
|
||||||
@@ -618,7 +621,7 @@ export const invoicePayments = createTable(
|
|||||||
.references(() => invoices.id, { onDelete: "cascade" }),
|
.references(() => invoices.id, { onDelete: "cascade" }),
|
||||||
amount: d.real().notNull(),
|
amount: d.real().notNull(),
|
||||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
date: d.timestamp().notNull(),
|
date: d.date({ mode: "date" }).notNull(),
|
||||||
method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
|
method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
|
||||||
notes: d.varchar({ length: 500 }),
|
notes: d.varchar({ length: 500 }),
|
||||||
createdById: d
|
createdById: d
|
||||||
@@ -626,7 +629,7 @@ export const invoicePayments = createTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
}),
|
}),
|
||||||
@@ -673,17 +676,18 @@ export const recurringInvoices = createTable(
|
|||||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
notes: d.varchar({ length: 1000 }),
|
notes: d.varchar({ length: 1000 }),
|
||||||
emailMessage: d.varchar({ length: 2000 }),
|
emailMessage: d.varchar({ length: 2000 }),
|
||||||
nextDueAt: d.timestamp().notNull(),
|
nextDueAt: d.timestamp({ withTimezone: true }).notNull(),
|
||||||
lastGeneratedAt: d.timestamp(),
|
lastGeneratedAt: d.timestamp({ withTimezone: true }),
|
||||||
|
timeZone: d.varchar({ length: 100 }).notNull().default("America/New_York"),
|
||||||
createdById: d
|
createdById: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("recurring_invoice_created_by_idx").on(t.createdById),
|
index("recurring_invoice_created_by_idx").on(t.createdById),
|
||||||
@@ -729,7 +733,7 @@ export const recurringInvoiceItems = createTable(
|
|||||||
rate: d.real().notNull(),
|
rate: d.real().notNull(),
|
||||||
position: d.integer().notNull().default(0),
|
position: d.integer().notNull().default(0),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
}),
|
}),
|
||||||
@@ -748,6 +752,31 @@ export const recurringInvoiceItemsRelations = relations(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Mobile Push Tokens ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const pushTokens = createTable(
|
||||||
|
"push_token",
|
||||||
|
(d) => ({
|
||||||
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
token: d.varchar({ length: 255 }).notNull().unique(),
|
||||||
|
platform: d.varchar({ length: 20 }).notNull(),
|
||||||
|
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}),
|
||||||
|
(t) => [index("push_token_user_id_idx").on(t.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pushTokensRelations = relations(pushTokens, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [pushTokens.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
// ─── Background Jobs ─────────────────────────────────────────────────────────
|
// ─── Background Jobs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const backgroundJobs = createTable(
|
export const backgroundJobs = createTable(
|
||||||
@@ -795,8 +824,8 @@ export const timeEntries = createTable(
|
|||||||
invoiceId: d
|
invoiceId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.references(() => invoices.id, { onDelete: "set null" }),
|
.references(() => invoices.id, { onDelete: "set null" }),
|
||||||
startedAt: d.timestamp().notNull(),
|
startedAt: d.timestamp({ withTimezone: true }).notNull(),
|
||||||
endedAt: d.timestamp(), // null = currently running
|
endedAt: d.timestamp({ withTimezone: true }), // null = currently running
|
||||||
hours: d.real(), // stored when stopped
|
hours: d.real(), // stored when stopped
|
||||||
rate: d.real(),
|
rate: d.real(),
|
||||||
notes: d.varchar({ length: 500 }),
|
notes: d.varchar({ length: 500 }),
|
||||||
@@ -805,10 +834,10 @@ export const timeEntries = createTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("time_entry_created_by_idx").on(t.createdById),
|
index("time_entry_created_by_idx").on(t.createdById),
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { invoices, pushTokens } from "~/server/db/schema";
|
||||||
|
import type { BackgroundJob } from "~/server/jobs/queue";
|
||||||
|
|
||||||
|
type ExpoPushTicket = {
|
||||||
|
status: "ok" | "error";
|
||||||
|
message?: string;
|
||||||
|
details?: { error?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sendInvoiceReminder(job: BackgroundJob) {
|
||||||
|
const invoiceId = job.payload.invoiceId;
|
||||||
|
const userId = job.payload.userId;
|
||||||
|
if (typeof invoiceId !== "string" || typeof userId !== "string") {
|
||||||
|
throw new Error("Invalid invoice reminder payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = await db.query.invoices.findFirst({
|
||||||
|
where: and(eq(invoices.id, invoiceId), eq(invoices.createdById, userId)),
|
||||||
|
with: { client: { columns: { name: true } } },
|
||||||
|
});
|
||||||
|
if (invoice?.status !== "draft" || invoice.sendReminderJobId !== job.id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const tokens = await db.query.pushTokens.findMany({
|
||||||
|
where: eq(pushTokens.userId, userId),
|
||||||
|
});
|
||||||
|
if (!tokens.length) return;
|
||||||
|
|
||||||
|
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
||||||
|
const response = await fetch("https://exp.host/--/api/v2/push/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(
|
||||||
|
tokens.map(({ token }) => ({
|
||||||
|
to: token,
|
||||||
|
title: "Time to send invoice",
|
||||||
|
body: `${label} for ${invoice.client?.name ?? "your client"} is ready to send.`,
|
||||||
|
sound: "default",
|
||||||
|
data: { invoiceId, type: "invoice-send-reminder" },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Expo push request failed (${response.status})`);
|
||||||
|
|
||||||
|
const result = (await response.json()) as { data?: ExpoPushTicket[] };
|
||||||
|
const tickets = result.data ?? [];
|
||||||
|
const invalidTokens = tokens.filter(
|
||||||
|
(_, index) => tickets[index]?.details?.error === "DeviceNotRegistered",
|
||||||
|
);
|
||||||
|
for (const invalid of invalidTokens) {
|
||||||
|
await db.delete(pushTokens).where(eq(pushTokens.id, invalid.id));
|
||||||
|
}
|
||||||
|
const retryableFailure = tickets.find(
|
||||||
|
(ticket) =>
|
||||||
|
ticket.status === "error" &&
|
||||||
|
ticket.details?.error !== "DeviceNotRegistered",
|
||||||
|
);
|
||||||
|
if (retryableFailure) {
|
||||||
|
throw new Error(
|
||||||
|
retryableFailure.message ?? "Expo rejected the push notification",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,15 @@ import {
|
|||||||
export async function generateRecurringInvoice(job: BackgroundJob) {
|
export async function generateRecurringInvoice(job: BackgroundJob) {
|
||||||
const recurringInvoiceId = job.payload.recurringInvoiceId;
|
const recurringInvoiceId = job.payload.recurringInvoiceId;
|
||||||
const scheduledForValue = job.payload.scheduledFor;
|
const scheduledForValue = job.payload.scheduledFor;
|
||||||
if (typeof recurringInvoiceId !== "string" || typeof scheduledForValue !== "string") {
|
if (
|
||||||
|
typeof recurringInvoiceId !== "string" ||
|
||||||
|
typeof scheduledForValue !== "string"
|
||||||
|
) {
|
||||||
throw new Error("Invalid recurring invoice job payload");
|
throw new Error("Invalid recurring invoice job payload");
|
||||||
}
|
}
|
||||||
const scheduledFor = new Date(scheduledForValue);
|
const scheduledFor = new Date(scheduledForValue);
|
||||||
if (Number.isNaN(scheduledFor.getTime())) throw new Error("Invalid recurring invoice job payload");
|
if (Number.isNaN(scheduledFor.getTime()))
|
||||||
|
throw new Error("Invalid recurring invoice job payload");
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const recurring = await tx.query.recurringInvoices.findFirst({
|
const recurring = await tx.query.recurringInvoices.findFirst({
|
||||||
@@ -28,12 +32,16 @@ export async function generateRecurringInvoice(job: BackgroundJob) {
|
|||||||
});
|
});
|
||||||
if (!recurring) return;
|
if (!recurring) return;
|
||||||
|
|
||||||
await generateInvoiceFromRecurring(tx, recurring);
|
await generateInvoiceFromRecurring(tx, recurring, scheduledFor);
|
||||||
await tx
|
await tx
|
||||||
.update(recurringInvoices)
|
.update(recurringInvoices)
|
||||||
.set({
|
.set({
|
||||||
lastGeneratedAt: new Date(),
|
lastGeneratedAt: new Date(),
|
||||||
nextDueAt: nextDueDate(recurring.schedule, scheduledFor),
|
nextDueAt: nextDueDate(
|
||||||
|
recurring.schedule,
|
||||||
|
scheduledFor,
|
||||||
|
recurring.timeZone,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.where(eq(recurringInvoices.id, recurring.id));
|
.where(eq(recurringInvoices.id, recurring.id));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,27 +4,36 @@ import type {
|
|||||||
recurringInvoiceItems,
|
recurringInvoiceItems,
|
||||||
recurringInvoices,
|
recurringInvoices,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
|
import {
|
||||||
|
addZonedCalendarInterval,
|
||||||
|
getZonedDateTimeParts,
|
||||||
|
} from "@beenvoice/domain/time-zone";
|
||||||
|
|
||||||
export function nextDueDate(schedule: string, from = new Date()): Date {
|
export function nextDueDate(
|
||||||
const date = new Date(from);
|
schedule: string,
|
||||||
switch (schedule) {
|
from = new Date(),
|
||||||
case "weekly":
|
timeZone = "America/New_York",
|
||||||
date.setDate(date.getDate() + 7);
|
): Date {
|
||||||
break;
|
if (
|
||||||
case "biweekly":
|
!(
|
||||||
date.setDate(date.getDate() + 14);
|
["weekly", "biweekly", "monthly", "quarterly", "yearly"] as string[]
|
||||||
break;
|
).includes(schedule)
|
||||||
case "monthly":
|
) {
|
||||||
date.setMonth(date.getMonth() + 1);
|
throw new RangeError("Invalid recurring schedule");
|
||||||
break;
|
|
||||||
case "quarterly":
|
|
||||||
date.setMonth(date.getMonth() + 3);
|
|
||||||
break;
|
|
||||||
case "yearly":
|
|
||||||
date.setFullYear(date.getFullYear() + 1);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return date;
|
return addZonedCalendarInterval(
|
||||||
|
from,
|
||||||
|
schedule as "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly",
|
||||||
|
timeZone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarDateAt(value: Date, timeZone: string) {
|
||||||
|
const parts = getZonedDateTimeParts(value, timeZone);
|
||||||
|
const pad = (part: number) => String(part).padStart(2, "0");
|
||||||
|
return new Date(
|
||||||
|
`${parts.year}-${pad(parts.month)}-${pad(parts.day)}T00:00:00.000Z`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
|
type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
|
||||||
@@ -34,10 +43,14 @@ type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
|
|||||||
export async function generateInvoiceFromRecurring(
|
export async function generateInvoiceFromRecurring(
|
||||||
db: Pick<typeof DbType, "insert">,
|
db: Pick<typeof DbType, "insert">,
|
||||||
recurring: RecurringWithItems,
|
recurring: RecurringWithItems,
|
||||||
|
scheduledFor = new Date(),
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const now = new Date();
|
const issueDate = calendarDateAt(scheduledFor, recurring.timeZone);
|
||||||
const invoiceNumber = `REC-${Date.now()}`;
|
const invoiceNumber = `REC-${Date.now()}`;
|
||||||
const subtotal = recurring.items.reduce((sum, item) => sum + item.hours * item.rate, 0);
|
const subtotal = recurring.items.reduce(
|
||||||
|
(sum, item) => sum + item.hours * item.rate,
|
||||||
|
0,
|
||||||
|
);
|
||||||
const taxAmount = (subtotal * recurring.taxRate) / 100;
|
const taxAmount = (subtotal * recurring.taxRate) / 100;
|
||||||
|
|
||||||
const [newInvoice] = await db
|
const [newInvoice] = await db
|
||||||
@@ -47,8 +60,11 @@ export async function generateInvoiceFromRecurring(
|
|||||||
invoicePrefix: recurring.invoicePrefix ?? "#",
|
invoicePrefix: recurring.invoicePrefix ?? "#",
|
||||||
clientId: recurring.clientId,
|
clientId: recurring.clientId,
|
||||||
businessId: recurring.businessId ?? null,
|
businessId: recurring.businessId ?? null,
|
||||||
issueDate: now,
|
issueDate,
|
||||||
dueDate: nextDueDate("monthly", now),
|
dueDate: calendarDateAt(
|
||||||
|
nextDueDate("monthly", scheduledFor, recurring.timeZone),
|
||||||
|
recurring.timeZone,
|
||||||
|
),
|
||||||
status: "draft",
|
status: "draft",
|
||||||
totalAmount: subtotal + taxAmount,
|
totalAmount: subtotal + taxAmount,
|
||||||
taxRate: recurring.taxRate,
|
taxRate: recurring.taxRate,
|
||||||
@@ -65,7 +81,7 @@ export async function generateInvoiceFromRecurring(
|
|||||||
await db.insert(invoiceItems).values(
|
await db.insert(invoiceItems).values(
|
||||||
recurring.items.map((item, index) => ({
|
recurring.items.map((item, index) => ({
|
||||||
invoiceId: newInvoice.id,
|
invoiceId: newInvoice.id,
|
||||||
date: now,
|
date: issueDate,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) {
|
|||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
baseUrl: input.baseUrl,
|
baseUrl: input.baseUrl,
|
||||||
|
timeZone: invoice.createdBy.timeZone,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sender = resolveEmailSender(invoice.business, userName);
|
const sender = resolveEmailSender(invoice.business, userName);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type BackgroundJob,
|
type BackgroundJob,
|
||||||
} from "../../web/src/server/jobs/queue";
|
} from "../../web/src/server/jobs/queue";
|
||||||
import { sendScheduledInvoice } from "./send-invoice";
|
import { sendScheduledInvoice } from "./send-invoice";
|
||||||
|
import { sendInvoiceReminder } from "../../web/src/server/jobs/handlers/invoice-reminder";
|
||||||
|
|
||||||
const workerId = `beenvoice-worker:${randomUUID()}`;
|
const workerId = `beenvoice-worker:${randomUUID()}`;
|
||||||
const pollMs = Number(process.env.WORKER_POLL_MS ?? 2_000);
|
const pollMs = Number(process.env.WORKER_POLL_MS ?? 2_000);
|
||||||
@@ -69,6 +70,10 @@ async function handleJob(job: BackgroundJob) {
|
|||||||
await sendScheduledInvoice(job);
|
await sendScheduledInvoice(job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (job.type === jobTypes.sendInvoiceReminder) {
|
||||||
|
await sendInvoiceReminder(job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new Error(`No handler registered for ${job.type}`);
|
throw new Error(`No handler registered for ${job.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { nextDueDate } from "../../web/src/server/services/recurring-invoices";
|
|||||||
describe("recurring invoice scheduling", () => {
|
describe("recurring invoice scheduling", () => {
|
||||||
test("advances weekly schedules from their scheduled occurrence", () => {
|
test("advances weekly schedules from their scheduled occurrence", () => {
|
||||||
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
|
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
|
||||||
expect(nextDueDate("weekly", scheduledFor).toISOString()).toBe(
|
expect(
|
||||||
"2026-08-24T12:00:00.000Z",
|
nextDueDate("weekly", scheduledFor, "America/New_York").toISOString(),
|
||||||
);
|
).toBe("2026-08-24T12:00:00.000Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not mutate the source date", () => {
|
test("does not mutate the source date", () => {
|
||||||
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
|
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
|
||||||
nextDueDate("monthly", scheduledFor);
|
nextDueDate("monthly", scheduledFor, "America/New_York");
|
||||||
expect(scheduledFor.toISOString()).toBe("2026-08-17T12:00:00.000Z");
|
expect(scheduledFor.toISOString()).toBe("2026-08-17T12:00:00.000Z");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SERVICE_FQDN_APP:
|
SERVICE_FQDN_APP:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
TZ: UTC
|
||||||
PORT: ${APP_PORT:-3000}
|
PORT: ${APP_PORT:-3000}
|
||||||
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
|
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
|
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
|
||||||
@@ -67,6 +68,7 @@ services:
|
|||||||
image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:coolify}
|
image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:coolify}
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
TZ: UTC
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
|
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
|
||||||
DB_DISABLE_SSL: "true"
|
DB_DISABLE_SSL: "true"
|
||||||
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
|
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||||
|
TZ: UTC
|
||||||
volumes:
|
volumes:
|
||||||
- beenvoice_dev_pg_data:/var/lib/postgresql/data
|
- beenvoice_dev_pg_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
image: ${BEENVOICE_IMAGE:-beenvoice:local}
|
image: ${BEENVOICE_IMAGE:-beenvoice:local}
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
TZ: UTC
|
||||||
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
|
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
||||||
DB_DISABLE_SSL: "true"
|
DB_DISABLE_SSL: "true"
|
||||||
@@ -63,6 +64,7 @@ services:
|
|||||||
image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:local}
|
image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:local}
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
TZ: UTC
|
||||||
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
|
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
||||||
DB_DISABLE_SSL: "true"
|
DB_DISABLE_SSL: "true"
|
||||||
@@ -85,6 +87,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||||
|
TZ: UTC
|
||||||
volumes:
|
volumes:
|
||||||
- beenvoice_pg_data:/var/lib/postgresql/data
|
- beenvoice_pg_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -4,33 +4,48 @@ export type EffectiveInvoiceStatus = StoredInvoiceStatus | "overdue";
|
|||||||
export function getEffectiveInvoiceStatus(
|
export function getEffectiveInvoiceStatus(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
|
timeZone = getLocalTimeZone(),
|
||||||
|
now = new Date(),
|
||||||
): EffectiveInvoiceStatus {
|
): EffectiveInvoiceStatus {
|
||||||
if (storedStatus === "paid" || storedStatus === "draft") return storedStatus;
|
if (storedStatus === "paid" || storedStatus === "draft") return storedStatus;
|
||||||
|
return calendarDateKey(dueDate) < zonedTodayKey(now, timeZone)
|
||||||
const today = new Date();
|
? "overdue"
|
||||||
const due = new Date(dueDate);
|
: "sent";
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
due.setHours(0, 0, 0, 0);
|
|
||||||
return due < today ? "overdue" : "sent";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isInvoiceOverdue(
|
export function isInvoiceOverdue(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
|
timeZone = getLocalTimeZone(),
|
||||||
): boolean {
|
): boolean {
|
||||||
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
|
return (
|
||||||
|
getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone) === "overdue"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDaysPastDue(
|
export function getDaysPastDue(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
|
timeZone = getLocalTimeZone(),
|
||||||
|
now = new Date(),
|
||||||
): number {
|
): number {
|
||||||
if (!isInvoiceOverdue(storedStatus, dueDate)) return 0;
|
if (
|
||||||
const today = new Date();
|
getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone, now) !==
|
||||||
const due = new Date(dueDate);
|
"overdue"
|
||||||
today.setHours(0, 0, 0, 0);
|
)
|
||||||
due.setHours(0, 0, 0, 0);
|
return 0;
|
||||||
return Math.max(0, Math.ceil((today.getTime() - due.getTime()) / 86_400_000));
|
const dueKey = calendarDateKey(dueDate);
|
||||||
|
const todayKey = zonedTodayKey(now, timeZone);
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((Date.parse(todayKey) - Date.parse(dueKey)) / 86_400_000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zonedTodayKey(now: Date, timeZone: string) {
|
||||||
|
const parts = getZonedDateTimeParts(now, timeZone);
|
||||||
|
const pad = (value: number) => String(value).padStart(2, "0");
|
||||||
|
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getValidStatusTransitions(
|
export function getValidStatusTransitions(
|
||||||
@@ -52,3 +67,8 @@ export function isValidStatusTransition(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return getValidStatusTransitions(from).includes(to);
|
return getValidStatusTransitions(from).includes(to);
|
||||||
}
|
}
|
||||||
|
import {
|
||||||
|
calendarDateKey,
|
||||||
|
getLocalTimeZone,
|
||||||
|
getZonedDateTimeParts,
|
||||||
|
} from "./time-zone";
|
||||||
|
|||||||
@@ -1,4 +1,248 @@
|
|||||||
const FALLBACK_TIME_ZONE = "UTC";
|
export const DEFAULT_TIME_ZONE = "America/New_York";
|
||||||
|
const FALLBACK_TIME_ZONE = DEFAULT_TIME_ZONE;
|
||||||
|
|
||||||
|
export type ZonedDateTimeDisambiguation = "earlier" | "later" | "reject";
|
||||||
|
|
||||||
|
type DateTimeParts = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
second: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WALL_TIME_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
|
||||||
|
function wallTimeFormatter(timeZone: string) {
|
||||||
|
let formatter = WALL_TIME_FORMATTERS.get(timeZone);
|
||||||
|
if (!formatter) {
|
||||||
|
formatter = new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
WALL_TIME_FORMATTERS.set(timeZone, formatter);
|
||||||
|
}
|
||||||
|
return formatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZonedDateTimeParts(
|
||||||
|
value: Date | string | number,
|
||||||
|
timeZone: string,
|
||||||
|
): DateTimeParts {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) throw new RangeError("Invalid date");
|
||||||
|
if (!isValidTimeZone(timeZone)) throw new RangeError("Invalid time zone");
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
wallTimeFormatter(timeZone)
|
||||||
|
.formatToParts(date)
|
||||||
|
.filter((part) => part.type !== "literal")
|
||||||
|
.map((part) => [part.type, Number(part.value)]),
|
||||||
|
) as Record<string, number>;
|
||||||
|
return {
|
||||||
|
year: parts.year!,
|
||||||
|
month: parts.month!,
|
||||||
|
day: parts.day!,
|
||||||
|
hour: parts.hour!,
|
||||||
|
minute: parts.minute!,
|
||||||
|
second: parts.second!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameWallTime(a: DateTimeParts, b: DateTimeParts) {
|
||||||
|
return (
|
||||||
|
a.year === b.year &&
|
||||||
|
a.month === b.month &&
|
||||||
|
a.day === b.day &&
|
||||||
|
a.hour === b.hour &&
|
||||||
|
a.minute === b.minute &&
|
||||||
|
a.second === b.second
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDateTime(value: string): DateTimeParts {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
if (!match) throw new RangeError("Expected YYYY-MM-DDTHH:mm");
|
||||||
|
const parts = {
|
||||||
|
year: Number(match[1]),
|
||||||
|
month: Number(match[2]),
|
||||||
|
day: Number(match[3]),
|
||||||
|
hour: Number(match[4]),
|
||||||
|
minute: Number(match[5]),
|
||||||
|
second: Number(match[6] ?? 0),
|
||||||
|
};
|
||||||
|
const check = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
parts.year,
|
||||||
|
parts.month - 1,
|
||||||
|
parts.day,
|
||||||
|
parts.hour,
|
||||||
|
parts.minute,
|
||||||
|
parts.second,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
check.getUTCFullYear() !== parts.year ||
|
||||||
|
check.getUTCMonth() + 1 !== parts.month ||
|
||||||
|
check.getUTCDate() !== parts.day ||
|
||||||
|
parts.hour > 23 ||
|
||||||
|
parts.minute > 59 ||
|
||||||
|
parts.second > 59
|
||||||
|
) {
|
||||||
|
throw new RangeError("Invalid local date and time");
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zonedDateTimeToInstant(
|
||||||
|
localDateTime: string,
|
||||||
|
timeZone: string,
|
||||||
|
disambiguation: ZonedDateTimeDisambiguation = "reject",
|
||||||
|
): Date {
|
||||||
|
if (!isValidTimeZone(timeZone)) throw new RangeError("Invalid time zone");
|
||||||
|
const desired = parseLocalDateTime(localDateTime);
|
||||||
|
const wallAsUtc = Date.UTC(
|
||||||
|
desired.year,
|
||||||
|
desired.month - 1,
|
||||||
|
desired.day,
|
||||||
|
desired.hour,
|
||||||
|
desired.minute,
|
||||||
|
desired.second,
|
||||||
|
);
|
||||||
|
|
||||||
|
let candidateMs = wallAsUtc;
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const observed = getZonedDateTimeParts(candidateMs, timeZone);
|
||||||
|
const observedAsUtc = Date.UTC(
|
||||||
|
observed.year,
|
||||||
|
observed.month - 1,
|
||||||
|
observed.day,
|
||||||
|
observed.hour,
|
||||||
|
observed.minute,
|
||||||
|
observed.second,
|
||||||
|
);
|
||||||
|
candidateMs += wallAsUtc - observedAsUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = Array.from(
|
||||||
|
{ length: 25 },
|
||||||
|
(_, index) => candidateMs + (index - 12) * 15 * 60_000,
|
||||||
|
)
|
||||||
|
.filter((value, index, all) => all.indexOf(value) === index)
|
||||||
|
.filter((value) =>
|
||||||
|
sameWallTime(getZonedDateTimeParts(value, timeZone), desired),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
if (candidates.length === 0)
|
||||||
|
throw new RangeError("That local time does not exist");
|
||||||
|
if (candidates.length > 1 && disambiguation === "reject") {
|
||||||
|
throw new RangeError(
|
||||||
|
"That local time occurs twice; choose earlier or later",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Date(
|
||||||
|
disambiguation === "later" ? candidates.at(-1)! : candidates[0]!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toZonedDateTimeInputValue(
|
||||||
|
value: Date | string | number,
|
||||||
|
timeZone: string,
|
||||||
|
): string {
|
||||||
|
const parts = getZonedDateTimeParts(value, timeZone);
|
||||||
|
const pad = (part: number) => String(part).padStart(2, "0");
|
||||||
|
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addZonedCalendarInterval(
|
||||||
|
value: Date | string | number,
|
||||||
|
schedule: "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly",
|
||||||
|
timeZone: string,
|
||||||
|
): Date {
|
||||||
|
const source = getZonedDateTimeParts(value, timeZone);
|
||||||
|
const calendar = new Date(
|
||||||
|
Date.UTC(source.year, source.month - 1, source.day),
|
||||||
|
);
|
||||||
|
if (schedule === "weekly" || schedule === "biweekly") {
|
||||||
|
calendar.setUTCDate(
|
||||||
|
calendar.getUTCDate() + (schedule === "weekly" ? 7 : 14),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const months =
|
||||||
|
schedule === "monthly" ? 1 : schedule === "quarterly" ? 3 : 12;
|
||||||
|
const originalDay = calendar.getUTCDate();
|
||||||
|
calendar.setUTCDate(1);
|
||||||
|
calendar.setUTCMonth(calendar.getUTCMonth() + months);
|
||||||
|
const lastDay = new Date(
|
||||||
|
Date.UTC(calendar.getUTCFullYear(), calendar.getUTCMonth() + 1, 0),
|
||||||
|
).getUTCDate();
|
||||||
|
calendar.setUTCDate(Math.min(originalDay, lastDay));
|
||||||
|
}
|
||||||
|
const pad = (part: number) => String(part).padStart(2, "0");
|
||||||
|
return zonedDateTimeToInstant(
|
||||||
|
`${calendar.getUTCFullYear()}-${pad(calendar.getUTCMonth() + 1)}-${pad(calendar.getUTCDate())}T${pad(source.hour)}:${pad(source.minute)}:${pad(source.second)}`,
|
||||||
|
timeZone,
|
||||||
|
"earlier",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCalendarDate(
|
||||||
|
value: Date | string,
|
||||||
|
options: Intl.DateTimeFormatOptions = {},
|
||||||
|
): string {
|
||||||
|
const date =
|
||||||
|
value instanceof Date
|
||||||
|
? value
|
||||||
|
: new Date(`${value.slice(0, 10)}T12:00:00.000Z`);
|
||||||
|
if (Number.isNaN(date.getTime())) return "Invalid date";
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
...options,
|
||||||
|
timeZone: "UTC",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calendarDateKey(value: Date | string): string {
|
||||||
|
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value))
|
||||||
|
return value.slice(0, 10);
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) throw new RangeError("Invalid date");
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calendarDateFromLocalDate(value: Date): Date {
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(value.getFullYear(), value.getMonth(), value.getDate(), 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calendarDateToLocalDate(value: Date | string): Date {
|
||||||
|
const [year, month, day] = calendarDateKey(value).split("-").map(Number);
|
||||||
|
return new Date(year!, month! - 1, day!, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calendarDateFromInstant(
|
||||||
|
value: Date | string | number,
|
||||||
|
timeZone: string,
|
||||||
|
): Date {
|
||||||
|
const parts = getZonedDateTimeParts(value, timeZone);
|
||||||
|
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCalendarDays(value: Date | string, days: number): Date {
|
||||||
|
const [year, month, day] = calendarDateKey(value).split("-").map(Number);
|
||||||
|
return new Date(Date.UTC(year!, month! - 1, day! + days, 12));
|
||||||
|
}
|
||||||
|
|
||||||
export function isValidTimeZone(value: string): boolean {
|
export function isValidTimeZone(value: string): boolean {
|
||||||
if (!value.trim()) return false;
|
if (!value.trim()) return false;
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
formatZonedDateTime,
|
formatZonedDateTime,
|
||||||
|
addZonedCalendarInterval,
|
||||||
getDefaultScheduledSendAt,
|
getDefaultScheduledSendAt,
|
||||||
isValidTimeZone,
|
isValidTimeZone,
|
||||||
toLocalDateTimeInputValue,
|
toLocalDateTimeInputValue,
|
||||||
|
zonedDateTimeToInstant,
|
||||||
} from "../src/time-zone";
|
} from "../src/time-zone";
|
||||||
import {
|
import {
|
||||||
EXPENSE_CATEGORIES,
|
EXPENSE_CATEGORIES,
|
||||||
formatElapsedSeconds,
|
formatElapsedSeconds,
|
||||||
getEffectiveInvoiceStatus,
|
getEffectiveInvoiceStatus,
|
||||||
|
getDaysPastDue,
|
||||||
parseReceiptText,
|
parseReceiptText,
|
||||||
} from "../src";
|
} from "../src";
|
||||||
|
|
||||||
@@ -26,6 +29,17 @@ describe("shared domain behavior", () => {
|
|||||||
expect(getEffectiveInvoiceStatus("paid", yesterday)).toBe("paid");
|
expect(getEffectiveInvoiceStatus("paid", yesterday)).toBe("paid");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("counts calendar days rather than 24-hour blocks across fall DST", () => {
|
||||||
|
expect(
|
||||||
|
getDaysPastDue(
|
||||||
|
"sent",
|
||||||
|
"2026-11-01",
|
||||||
|
"America/New_York",
|
||||||
|
new Date("2026-11-02T17:00:00.000Z"),
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("formats elapsed time", () => {
|
test("formats elapsed time", () => {
|
||||||
expect(formatElapsedSeconds(3_661)).toBe("01:01:01");
|
expect(formatElapsedSeconds(3_661)).toBe("01:01:01");
|
||||||
});
|
});
|
||||||
@@ -56,6 +70,69 @@ describe("time-zone helpers", () => {
|
|||||||
expect(isValidTimeZone("not/a-zone")).toBe(false);
|
expect(isValidTimeZone("not/a-zone")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("converts Eastern wall time to the correct absolute instant", () => {
|
||||||
|
expect(
|
||||||
|
zonedDateTimeToInstant(
|
||||||
|
"2026-08-17T09:00",
|
||||||
|
"America/New_York",
|
||||||
|
).toISOString(),
|
||||||
|
).toBe("2026-08-17T13:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects nonexistent spring-forward wall times", () => {
|
||||||
|
expect(() =>
|
||||||
|
zonedDateTimeToInstant("2026-03-08T02:30", "America/New_York"),
|
||||||
|
).toThrow("does not exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disambiguates both occurrences of a fall-back wall time", () => {
|
||||||
|
expect(
|
||||||
|
zonedDateTimeToInstant(
|
||||||
|
"2026-11-01T01:30",
|
||||||
|
"America/New_York",
|
||||||
|
"earlier",
|
||||||
|
).toISOString(),
|
||||||
|
).toBe("2026-11-01T05:30:00.000Z");
|
||||||
|
expect(
|
||||||
|
zonedDateTimeToInstant(
|
||||||
|
"2026-11-01T01:30",
|
||||||
|
"America/New_York",
|
||||||
|
"later",
|
||||||
|
).toISOString(),
|
||||||
|
).toBe("2026-11-01T06:30:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports half-hour DST transitions", () => {
|
||||||
|
const earlier = zonedDateTimeToInstant(
|
||||||
|
"2026-04-05T01:45",
|
||||||
|
"Australia/Lord_Howe",
|
||||||
|
"earlier",
|
||||||
|
);
|
||||||
|
const later = zonedDateTimeToInstant(
|
||||||
|
"2026-04-05T01:45",
|
||||||
|
"Australia/Lord_Howe",
|
||||||
|
"later",
|
||||||
|
);
|
||||||
|
expect(later.getTime() - earlier.getTime()).toBe(30 * 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves Eastern wall time across DST and clamps month end", () => {
|
||||||
|
expect(
|
||||||
|
addZonedCalendarInterval(
|
||||||
|
new Date("2026-03-01T14:00:00.000Z"),
|
||||||
|
"weekly",
|
||||||
|
"America/New_York",
|
||||||
|
).toISOString(),
|
||||||
|
).toBe("2026-03-08T13:00:00.000Z");
|
||||||
|
expect(
|
||||||
|
addZonedCalendarInterval(
|
||||||
|
new Date("2026-01-31T14:00:00.000Z"),
|
||||||
|
"monthly",
|
||||||
|
"America/New_York",
|
||||||
|
).toISOString(),
|
||||||
|
).toBe("2026-02-28T14:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
test("rounds the default schedule to the next local hour", () => {
|
test("rounds the default schedule to the next local hour", () => {
|
||||||
const result = getDefaultScheduledSendAt(new Date(2026, 7, 17, 10, 42, 19));
|
const result = getDefaultScheduledSendAt(new Date(2026, 7, 17, 10, 42, 19));
|
||||||
expect(toLocalDateTimeInputValue(result)).toBe("2026-08-17T11:00");
|
expect(toLocalDateTimeInputValue(result)).toBe("2026-08-17T11:00");
|
||||||
|
|||||||
Reference in New Issue
Block a user