Compare commits
10
Commits
9929d7321d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0b1a7355e | ||
|
|
57a985bcd5 | ||
|
|
7be4bb2abe | ||
|
|
5c9fbe6dc2 | ||
|
|
70c08054fb | ||
|
|
1853eaa963 | ||
|
|
67ab6b78bd | ||
|
|
29589c1f32 | ||
|
|
dafa62b9bb | ||
|
|
24a1307a96 |
+44
-1
@@ -6,9 +6,30 @@ WORKDIR /app
|
||||
FROM base AS install
|
||||
COPY package.json bun.lock ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY apps/worker/package.json apps/worker/package.json
|
||||
COPY apps/mobile/package.json apps/mobile/package.json
|
||||
COPY packages/domain/package.json packages/domain/package.json
|
||||
RUN bun install --frozen-lockfile --filter @beenvoice/web
|
||||
COPY packages/email/package.json packages/email/package.json
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \
|
||||
for attempt in 1 2 3; do \
|
||||
bun install --frozen-lockfile --filter @beenvoice/web && exit 0; \
|
||||
echo "Bun install failed (attempt ${attempt}/3); retrying" >&2; \
|
||||
done; \
|
||||
exit 1
|
||||
|
||||
FROM base AS worker-install
|
||||
COPY package.json bun.lock ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY apps/worker/package.json apps/worker/package.json
|
||||
COPY apps/mobile/package.json apps/mobile/package.json
|
||||
COPY packages/domain/package.json packages/domain/package.json
|
||||
COPY packages/email/package.json packages/email/package.json
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \
|
||||
for attempt in 1 2 3; do \
|
||||
bun install --frozen-lockfile --filter @beenvoice/worker && exit 0; \
|
||||
echo "Bun install failed (attempt ${attempt}/3); retrying" >&2; \
|
||||
done; \
|
||||
exit 1
|
||||
|
||||
# Next's production build runs under Node because Bun can fail during the
|
||||
# page-data worker phase on Linux arm64. Dependencies still come from Bun.
|
||||
@@ -43,6 +64,7 @@ COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/apps/web/package.json ./apps/web/package.json
|
||||
COPY --from=build /app/packages/domain ./packages/domain
|
||||
COPY --from=build /app/packages/email ./packages/email
|
||||
COPY --from=build /app/apps/web/.next ./apps/web/.next
|
||||
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||
COPY --from=build /app/apps/web/drizzle.config.ts ./apps/web/drizzle.config.ts
|
||||
@@ -54,4 +76,25 @@ RUN chmod -R a+rX apps/web/drizzle apps/web/public apps/web/src/server/db/migrat
|
||||
USER bun
|
||||
EXPOSE 3000
|
||||
WORKDIR /app/apps/web
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||
CMD bun -e 'const port = process.env.PORT || "3000"; const response = await fetch("http://127.0.0.1:" + port + "/api/health"); if (!response.ok) process.exit(1)'
|
||||
CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"]
|
||||
|
||||
FROM base AS worker
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=worker-install /app ./
|
||||
COPY apps/web/src ./apps/web/src
|
||||
COPY apps/web/tsconfig.json ./apps/web/tsconfig.json
|
||||
COPY apps/worker ./apps/worker
|
||||
COPY packages/domain ./packages/domain
|
||||
COPY packages/email ./packages/email
|
||||
RUN ln -s ../worker/node_modules apps/web/node_modules
|
||||
|
||||
USER bun
|
||||
WORKDIR /app/apps/worker
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
# Keep the web application as the default Dockerfile target.
|
||||
FROM release AS final
|
||||
|
||||
@@ -8,9 +8,11 @@ Beenvoice is a freelancer and small-business invoicing platform with a Next.js w
|
||||
beenvoice/
|
||||
├── apps/
|
||||
│ ├── web/ # Next.js dashboard, tRPC API, PostgreSQL/Drizzle
|
||||
│ └── mobile/ # Expo Router mobile app and iOS widgets
|
||||
│ ├── mobile/ # Expo Router mobile app and iOS widgets
|
||||
│ └── worker/ # PostgreSQL-backed scheduler and background jobs
|
||||
├── packages/
|
||||
│ └── domain/ # Platform-neutral shared rules and parsing
|
||||
│ ├── domain/ # Platform-neutral shared rules and parsing
|
||||
│ └── email/ # Resend and SMTP/Mailpit delivery adapter
|
||||
├── Dockerfile
|
||||
├── docker-compose*.yml
|
||||
├── package.json
|
||||
@@ -32,7 +34,7 @@ bun run --filter @beenvoice/web db:push
|
||||
bun run dev
|
||||
```
|
||||
|
||||
`bun run dev` starts Next.js on port 3000 and Expo Metro on port 8082. For a physical iPhone, set `EXPO_PUBLIC_API_URL` in `apps/mobile/.env` to a host the device can reach and configure the web app's canonical/auth URLs consistently.
|
||||
`bun run dev` starts Next.js on port 3000, Expo Metro on port 8082, and the background worker. For a physical iPhone, set `EXPO_PUBLIC_API_URL` in `apps/mobile/.env` to a host the device can reach and configure the web app's canonical/auth URLs consistently.
|
||||
|
||||
Useful workspace commands:
|
||||
|
||||
@@ -42,6 +44,7 @@ bun run lint
|
||||
bun run test
|
||||
bun run build
|
||||
bun run check
|
||||
bun run email:preview # sends a PDF-bearing message to local Mailpit
|
||||
```
|
||||
|
||||
Run an app-specific command with a workspace filter:
|
||||
@@ -61,7 +64,7 @@ git pull
|
||||
./scripts/docker-deploy.sh
|
||||
```
|
||||
|
||||
The root Dockerfile installs the frozen Bun workspace lockfile, builds the Next.js app under Node, and runs migrations plus the web server under Bun.
|
||||
The root Dockerfile installs the frozen Bun workspace lockfile and exposes separate `final` (web) and `worker` targets. The Compose stack starts both; the web container runs migrations before serving requests and the worker tolerates that short startup race.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -69,7 +72,9 @@ The root Dockerfile installs the frozen Bun workspace lockfile, builds the Next.
|
||||
- [Web architecture](./apps/web/docs/ARCHITECTURE.md)
|
||||
- [Mobile setup](./apps/mobile/README.md)
|
||||
- [Mobile architecture](./apps/mobile/docs/ARCHITECTURE.md)
|
||||
- [Worker architecture](./apps/worker/README.md)
|
||||
- [Shared domain package](./packages/domain/README.md)
|
||||
- [Email delivery package](./packages/email/README.md)
|
||||
|
||||
## Product concepts
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.beenvoice.app",
|
||||
"buildNumber": "28",
|
||||
"buildNumber": "29",
|
||||
"icon": "./assets/beenvoice.icon",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
@@ -24,6 +18,10 @@ import { formatCurrency } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
import {
|
||||
BusinessBrandImage,
|
||||
hasMobileBusinessBrandAsset,
|
||||
} from "@/components/businesses/BusinessBrandImage";
|
||||
|
||||
type EntityTab = "clients" | "businesses";
|
||||
|
||||
@@ -48,7 +46,8 @@ export default function EntitiesScreen() {
|
||||
|
||||
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
||||
const isLoading =
|
||||
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
|
||||
clientsQuery.isLoading ||
|
||||
(tab === "businesses" && businessesQuery.isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen message="Loading…" />;
|
||||
@@ -71,11 +70,16 @@ export default function EntitiesScreen() {
|
||||
const businesses = businessesQuery.data ?? [];
|
||||
|
||||
function refresh() {
|
||||
return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch();
|
||||
return tab === "clients"
|
||||
? clientsQuery.refetch()
|
||||
: businessesQuery.refetch();
|
||||
}
|
||||
|
||||
function confirmDelete(id: string, name: string) {
|
||||
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
|
||||
Alert.alert(
|
||||
`Delete ${tab === "clients" ? "client" : "business"}?`,
|
||||
`Remove ${name}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
@@ -85,7 +89,8 @@ export default function EntitiesScreen() {
|
||||
else deleteBusiness.mutate({ id });
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -99,10 +104,7 @@ export default function EntitiesScreen() {
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<PullToRefresh
|
||||
onRefresh={refresh}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
<PullToRefresh onRefresh={refresh} tintColor={colors.primary} />
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
@@ -141,7 +143,10 @@ export default function EntitiesScreen() {
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
|
||||
onPress: () =>
|
||||
router.push(
|
||||
`/(app)/entities/clients/edit/${client.id}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
@@ -152,7 +157,9 @@ export default function EntitiesScreen() {
|
||||
onPress: () => confirmDelete(client.id, client.name),
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
||||
onPress={() =>
|
||||
router.push(`/(app)/entities/clients/${client.id}`)
|
||||
}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
@@ -162,7 +169,10 @@ export default function EntitiesScreen() {
|
||||
) : null}
|
||||
{client.defaultHourlyRate != null ? (
|
||||
<Text style={styles.meta}>
|
||||
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
||||
{formatCurrency(
|
||||
client.defaultHourlyRate,
|
||||
client.currency ?? "USD",
|
||||
)}
|
||||
/hr
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -190,7 +200,10 @@ export default function EntitiesScreen() {
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
|
||||
onPress: () =>
|
||||
router.push(
|
||||
`/(app)/entities/businesses/edit/${business.id}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
@@ -201,10 +214,21 @@ export default function EntitiesScreen() {
|
||||
onPress: () => confirmDelete(business.id, business.name),
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
||||
onPress={() =>
|
||||
router.push(`/(app)/entities/businesses/${business.id}`)
|
||||
}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.businessRow}>
|
||||
{hasMobileBusinessBrandAsset(business) ? (
|
||||
<BusinessBrandImage
|
||||
business={business}
|
||||
kind="icon"
|
||||
style={styles.businessIcon}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.businessCopy}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? (
|
||||
@@ -214,7 +238,11 @@ export default function EntitiesScreen() {
|
||||
{business.nickname ? (
|
||||
<Text style={styles.meta}>{business.nickname}</Text>
|
||||
) : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
{business.email ? (
|
||||
<Text style={styles.meta}>{business.email}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</SwipeableRow>
|
||||
@@ -257,6 +285,21 @@ const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
businessRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
},
|
||||
businessCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
},
|
||||
businessIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
|
||||
import {
|
||||
InvoiceViewChips,
|
||||
type InvoiceViewSection,
|
||||
} from "@/components/invoices/InvoiceViewChips";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
|
||||
@@ -21,6 +31,7 @@ import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||||
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { formatZonedDateTime } from "@beenvoice/domain/time-zone";
|
||||
|
||||
export default function InvoiceDetailScreen() {
|
||||
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||||
@@ -53,7 +64,10 @@ export default function InvoiceDetailScreen() {
|
||||
});
|
||||
|
||||
const previewInput = useMemo(
|
||||
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
|
||||
() =>
|
||||
invoiceQuery.data
|
||||
? buildPreviewPdfInputFromInvoice(invoiceQuery.data)
|
||||
: null,
|
||||
[invoiceQuery.data],
|
||||
);
|
||||
|
||||
@@ -73,7 +87,11 @@ export default function InvoiceDetailScreen() {
|
||||
<Text style={styles.errorText}>
|
||||
{invoiceQuery.error?.message ?? "Invoice not found"}
|
||||
</Text>
|
||||
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
|
||||
<Button
|
||||
title="Go back"
|
||||
variant="secondary"
|
||||
onPress={() => router.back()}
|
||||
/>
|
||||
</View>
|
||||
</AppBackground>
|
||||
);
|
||||
@@ -126,18 +144,22 @@ export default function InvoiceDetailScreen() {
|
||||
}
|
||||
|
||||
function promptStatusChange(current: InvoiceStatus) {
|
||||
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> =
|
||||
[];
|
||||
if (current !== "draft")
|
||||
options.push({ label: "Mark as draft", status: "draft" });
|
||||
if (current !== "sent" && current !== "overdue") {
|
||||
options.push({ label: "Mark as sent", status: "sent" });
|
||||
}
|
||||
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||||
if (current !== "paid")
|
||||
options.push({ label: "Mark as paid", status: "paid" });
|
||||
if (options.length === 0) return;
|
||||
|
||||
Alert.alert("Update status", "Choose a new status", [
|
||||
...options.map((option) => ({
|
||||
text: option.label,
|
||||
onPress: () => updateStatus.mutate({ id: invoice.id, status: option.status }),
|
||||
onPress: () =>
|
||||
updateStatus.mutate({ id: invoice.id, status: option.status }),
|
||||
})),
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]);
|
||||
@@ -148,8 +170,13 @@ export default function InvoiceDetailScreen() {
|
||||
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={
|
||||
Platform.OS === "ios" ? "never" : undefined
|
||||
}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
@@ -160,7 +187,9 @@ export default function InvoiceDetailScreen() {
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
||||
<Text style={styles.clientName}>
|
||||
{invoice.client?.name ?? "Client"}
|
||||
</Text>
|
||||
</View>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
@@ -184,8 +213,14 @@ export default function InvoiceDetailScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Card title="Details">
|
||||
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
|
||||
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
|
||||
<DetailRow
|
||||
label="Business"
|
||||
value={invoice.business?.name ?? "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Client"
|
||||
value={invoice.client?.name ?? "Client"}
|
||||
/>
|
||||
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
|
||||
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
|
||||
<DetailRow label="Currency" value={invoice.currency} />
|
||||
@@ -202,20 +237,32 @@ export default function InvoiceDetailScreen() {
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{invoice.scheduledSendStatus === "pending" &&
|
||||
invoice.scheduledSendAt ? (
|
||||
<DetailRow
|
||||
label="Scheduled send"
|
||||
value={formatZonedDateTime(
|
||||
invoice.scheduledSendAt,
|
||||
invoice.scheduledSendTimeZone ?? "UTC",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Line items">
|
||||
{invoice.items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Clock time to this invoice from the Timer tab, or edit to
|
||||
add lines manually.
|
||||
No line items yet. Clock time to this invoice from the Timer
|
||||
tab, or edit to add lines manually.
|
||||
</Text>
|
||||
) : (
|
||||
invoice.items.map((item) => {
|
||||
const line = (
|
||||
<View style={styles.lineItem}>
|
||||
<View style={styles.lineMeta}>
|
||||
<Text style={styles.lineDescription}>{item.description}</Text>
|
||||
<Text style={styles.lineDescription}>
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text style={styles.lineSub}>
|
||||
{formatDate(item.date)} · {item.hours}h ×{" "}
|
||||
{formatCurrency(item.rate, invoice.currency)}
|
||||
@@ -242,7 +289,8 @@ export default function InvoiceDetailScreen() {
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||
onPress: () =>
|
||||
router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -253,9 +301,13 @@ export default function InvoiceDetailScreen() {
|
||||
)}
|
||||
<InvoiceTotals
|
||||
subtotal={formatCurrency(subtotal, invoice.currency)}
|
||||
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
|
||||
taxLabel={
|
||||
invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined
|
||||
}
|
||||
taxAmount={
|
||||
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined
|
||||
invoice.taxRate > 0
|
||||
? formatCurrency(taxAmount, invoice.currency)
|
||||
: undefined
|
||||
}
|
||||
total={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
/>
|
||||
@@ -271,13 +323,17 @@ export default function InvoiceDetailScreen() {
|
||||
status={status}
|
||||
clientEmail={clientEmail}
|
||||
onPaymentReminder={
|
||||
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
|
||||
status === "sent" || status === "overdue"
|
||||
? promptPaymentReminder
|
||||
: undefined
|
||||
}
|
||||
paymentReminderLoading={sendPaymentReminder.isPending}
|
||||
onUpdateStatus={() => promptStatusChange(status)}
|
||||
updateStatusLoading={updateStatus.isPending}
|
||||
onTrackTime={() =>
|
||||
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
|
||||
router.push(
|
||||
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
@@ -291,8 +347,12 @@ function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={detailStyles.row}>
|
||||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[detailStyles.value, { color: colors.foreground }]}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||
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 { Card } from "@/components/ui/Card";
|
||||
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 { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
export default function InvoiceEditScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
@@ -53,7 +57,9 @@ export default function InvoiceEditScreen() {
|
||||
const [businessId, setBusinessId] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [dueDate, setDueDate] = useState(() => new Date());
|
||||
const [dueDate, setDueDate] = useState(() =>
|
||||
calendarDateFromLocalDate(new Date()),
|
||||
);
|
||||
const [taxRate, setTaxRate] = useState("0");
|
||||
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
||||
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||
@@ -68,7 +74,9 @@ export default function InvoiceEditScreen() {
|
||||
setNotes(invoice.notes ?? "");
|
||||
setDueDate(new Date(invoice.dueDate));
|
||||
setTaxRate(String(invoice.taxRate));
|
||||
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
|
||||
setSendReminderAt(
|
||||
invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null,
|
||||
);
|
||||
setItems(
|
||||
invoice.items.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -119,9 +127,14 @@ export default function InvoiceEditScreen() {
|
||||
[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 resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||
const resolvedBusinessId = resolveInvoiceBusinessId(
|
||||
businessId,
|
||||
businessesQuery.data,
|
||||
);
|
||||
|
||||
const subtotal = useMemo(
|
||||
() =>
|
||||
@@ -137,8 +150,12 @@ export default function InvoiceEditScreen() {
|
||||
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
const lineItemsError = isDraft ? validateLineItems(items) : null;
|
||||
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
|
||||
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
|
||||
const taxError =
|
||||
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 canSave = isDraft
|
||||
? !lineItemsError && !taxError && !businessError && !clientError
|
||||
@@ -159,13 +176,26 @@ export default function InvoiceEditScreen() {
|
||||
currency,
|
||||
items,
|
||||
});
|
||||
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
|
||||
}, [
|
||||
invoice,
|
||||
resolvedBusinessId,
|
||||
clientId,
|
||||
dueDate,
|
||||
notes,
|
||||
parsedTaxRate,
|
||||
currency,
|
||||
items,
|
||||
]);
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
|
||||
if (
|
||||
invoiceQuery.isLoading ||
|
||||
businessesQuery.isLoading ||
|
||||
clientsQuery.isLoading
|
||||
) {
|
||||
return <LoadingScreen message="Loading invoice…" />;
|
||||
}
|
||||
|
||||
@@ -177,14 +207,16 @@ export default function InvoiceEditScreen() {
|
||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||
|
||||
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() {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||
@@ -260,8 +292,13 @@ export default function InvoiceEditScreen() {
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={
|
||||
Platform.OS === "ios" ? "automatic" : undefined
|
||||
}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
@@ -316,13 +353,13 @@ export default function InvoiceEditScreen() {
|
||||
<Card title="Line items">
|
||||
{!isDraft ? (
|
||||
<Text style={styles.lockedHint}>
|
||||
Line items are locked after an invoice is sent. Mark as draft on the invoice
|
||||
screen to edit entries.
|
||||
Line items are locked after an invoice is sent. Mark as
|
||||
draft on the invoice screen to edit entries.
|
||||
</Text>
|
||||
) : items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Add lines here or clock time to this invoice from the
|
||||
Timer tab.
|
||||
No line items yet. Add lines here or clock time to this
|
||||
invoice from the Timer tab.
|
||||
</Text>
|
||||
) : null}
|
||||
{items.map((item, index) => (
|
||||
@@ -334,26 +371,40 @@ export default function InvoiceEditScreen() {
|
||||
isLast={index === items.length - 1}
|
||||
onChange={(patch) => updateItem(index, patch)}
|
||||
onRemove={() => removeItem(index)}
|
||||
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
|
||||
onDuplicate={
|
||||
isDraft ? () => duplicateItem(index) : undefined
|
||||
}
|
||||
readOnly={!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>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<InvoiceTotals
|
||||
subtotal={formatCurrency(subtotal, currency)}
|
||||
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
||||
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
|
||||
taxLabel={
|
||||
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
|
||||
}
|
||||
taxAmount={
|
||||
parsedTaxRate > 0
|
||||
? formatCurrency(taxAmount, currency)
|
||||
: undefined
|
||||
}
|
||||
total={formatCurrency(total, currency)}
|
||||
/>
|
||||
</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={
|
||||
status !== "paid"
|
||||
? {
|
||||
title: status === "draft" ? "Send invoice" : "Resend invoice",
|
||||
title:
|
||||
status === "draft" ? "Send invoice" : "Resend invoice",
|
||||
subtitle: clientEmail
|
||||
? items.length === 0
|
||||
? "Add line items before sending"
|
||||
|
||||
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||
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 { Button } from "@/components/ui/Button";
|
||||
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 { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
export default function NewInvoiceScreen() {
|
||||
const styles = useThemedStyles(createNewInvoiceStyles);
|
||||
@@ -53,8 +57,12 @@ export default function NewInvoiceScreen() {
|
||||
const [businessId, setBusinessId] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
|
||||
const [issueDate, setIssueDate] = useState(() => new Date());
|
||||
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
|
||||
const [issueDate, setIssueDate] = useState(() =>
|
||||
calendarDateFromLocalDate(new Date()),
|
||||
);
|
||||
const [dueDate, setDueDate] = useState(() =>
|
||||
defaultDueDate(calendarDateFromLocalDate(new Date())),
|
||||
);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [taxRate, setTaxRate] = useState("0");
|
||||
const [items, setItems] = useState<EditableLineItem[]>(() =>
|
||||
@@ -62,7 +70,7 @@ export default function NewInvoiceScreen() {
|
||||
? []
|
||||
: [
|
||||
{
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: "0",
|
||||
@@ -96,9 +104,14 @@ export default function NewInvoiceScreen() {
|
||||
[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 resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||
const resolvedBusinessId = resolveInvoiceBusinessId(
|
||||
businessId,
|
||||
businessesQuery.data,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedClient?.defaultHourlyRate) return;
|
||||
@@ -170,7 +183,9 @@ export default function NewInvoiceScreen() {
|
||||
const invoiceNumberError = isRequiredString(invoiceNumber)
|
||||
? undefined
|
||||
: "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 canCreate =
|
||||
businessOptions.length > 0 &&
|
||||
@@ -187,7 +202,9 @@ export default function NewInvoiceScreen() {
|
||||
|
||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||
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() {
|
||||
@@ -195,7 +212,7 @@ export default function NewInvoiceScreen() {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||
@@ -266,8 +283,13 @@ export default function NewInvoiceScreen() {
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={
|
||||
Platform.OS === "ios" ? "automatic" : undefined
|
||||
}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
@@ -287,7 +309,11 @@ export default function NewInvoiceScreen() {
|
||||
: "Add a client before creating an invoice."}
|
||||
</Text>
|
||||
<Button
|
||||
title={businessOptions.length === 0 ? "Add business" : "Add client"}
|
||||
title={
|
||||
businessOptions.length === 0
|
||||
? "Add business"
|
||||
: "Add client"
|
||||
}
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
router.push(
|
||||
@@ -303,7 +329,9 @@ export default function NewInvoiceScreen() {
|
||||
businessId={businessId}
|
||||
onBusinessIdChange={setBusinessId}
|
||||
businessOptions={businessOptions}
|
||||
businessError={visible("business") ? businessError : undefined}
|
||||
businessError={
|
||||
visible("business") ? businessError : undefined
|
||||
}
|
||||
onBusinessBlur={() => touch("business")}
|
||||
clientId={clientId}
|
||||
onClientIdChange={setClientId}
|
||||
@@ -334,8 +362,8 @@ export default function NewInvoiceScreen() {
|
||||
<Card title="Line items">
|
||||
{isBlank && items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Save this draft and clock time to it from the Timer tab,
|
||||
or add lines here.
|
||||
No line items yet. Save this draft and clock time to it from
|
||||
the Timer tab, or add lines here.
|
||||
</Text>
|
||||
) : null}
|
||||
{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>
|
||||
</Pressable>
|
||||
|
||||
<InvoiceTotals
|
||||
subtotal={formatCurrency(subtotal, currency)}
|
||||
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
||||
taxLabel={
|
||||
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
|
||||
}
|
||||
taxAmount={
|
||||
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
|
||||
parsedTaxRate > 0
|
||||
? formatCurrency(taxAmount, currency)
|
||||
: undefined
|
||||
}
|
||||
total={formatCurrency(total, currency)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{visible("lineItems") && lineItemsError ? (
|
||||
<Text selectable style={styles.error}>{lineItemsError}</Text>
|
||||
<Text selectable style={styles.error}>
|
||||
{lineItemsError}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
|
||||
{error ? (
|
||||
<Text selectable style={styles.error}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<InvoiceEditorFooter
|
||||
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
||||
|
||||
@@ -16,6 +16,14 @@ import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import {
|
||||
formatZonedDateTime,
|
||||
getDefaultScheduledSendAt,
|
||||
DEFAULT_TIME_ZONE,
|
||||
toLocalDateTimeInputValue,
|
||||
zonedDateTimeToInstant,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
@@ -33,6 +41,11 @@ export default function InvoiceSendScreen() {
|
||||
const utils = api.useUtils();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
const [scheduledAt, setScheduledAt] = useState(() =>
|
||||
getDefaultScheduledSendAt(),
|
||||
);
|
||||
const profileQuery = api.settings.getProfile.useQuery();
|
||||
const timeZone = profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE;
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
@@ -51,9 +64,39 @@ export default function InvoiceSendScreen() {
|
||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||
});
|
||||
|
||||
const scheduleInvoice = api.email.scheduleInvoice.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
await utils.invoices.getAll.invalidate();
|
||||
Alert.alert(
|
||||
"Invoice scheduled",
|
||||
`It will send ${formatZonedDateTime(data.scheduledAt, data.timeZone)}.`,
|
||||
[
|
||||
{
|
||||
text: "OK",
|
||||
onPress: () => router.replace(`/(app)/invoices/${id}`),
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not schedule invoice", err.message),
|
||||
});
|
||||
|
||||
const cancelScheduledInvoice = api.email.cancelScheduledInvoice.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
await utils.invoices.getAll.invalidate();
|
||||
Alert.alert("Scheduled send cancelled");
|
||||
},
|
||||
onError: (err) =>
|
||||
Alert.alert("Could not cancel scheduled send", err.message),
|
||||
});
|
||||
|
||||
const previewInput = useMemo(
|
||||
() =>
|
||||
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
|
||||
invoiceQuery.data
|
||||
? buildPreviewPdfInputFromInvoice(invoiceQuery.data)
|
||||
: null,
|
||||
[invoiceQuery.data],
|
||||
);
|
||||
|
||||
@@ -97,22 +140,63 @@ export default function InvoiceSendScreen() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSchedule() {
|
||||
if (!clientEmail || invoice.items.length === 0) return;
|
||||
let instant: Date;
|
||||
try {
|
||||
instant = zonedDateTimeToInstant(
|
||||
toLocalDateTimeInputValue(scheduledAt),
|
||||
timeZone,
|
||||
"earlier",
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Choose another time",
|
||||
error instanceof Error ? error.message : "Invalid local time",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (instant.getTime() < Date.now() + 60_000) {
|
||||
Alert.alert(
|
||||
"Choose a future time",
|
||||
"The scheduled time must be at least one minute from now.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
scheduleInvoice.mutate({
|
||||
invoiceId: invoice.id,
|
||||
scheduledAt: instant,
|
||||
timeZone,
|
||||
customMessage: customMessage.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ title: sendLabel, headerBackTitle: "Invoice" }} />
|
||||
<Stack.Screen
|
||||
options={{ title: sendLabel, headerBackTitle: "Invoice" }}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={
|
||||
Platform.OS === "ios" ? "automatic" : undefined
|
||||
}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card title="Email summary">
|
||||
<SummaryRow label="From" value={businessName} />
|
||||
<SummaryRow label="To" value={clientEmail || "No client email on file"} />
|
||||
<SummaryRow
|
||||
label="To"
|
||||
value={clientEmail || "No client email on file"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Invoice"
|
||||
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
|
||||
@@ -130,7 +214,9 @@ export default function InvoiceSendScreen() {
|
||||
</Card>
|
||||
|
||||
<Card title="Message">
|
||||
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}>
|
||||
<Text
|
||||
style={[styles.messageHint, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Optional note included in the email body.
|
||||
</Text>
|
||||
<Input
|
||||
@@ -143,6 +229,52 @@ export default function InvoiceSendScreen() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{invoice.scheduledSendStatus === "pending" &&
|
||||
invoice.scheduledSendAt ? (
|
||||
<Card title="Scheduled send">
|
||||
<Text
|
||||
style={[styles.messageHint, { color: colors.mutedForeground }]}
|
||||
>
|
||||
{formatZonedDateTime(
|
||||
invoice.scheduledSendAt,
|
||||
invoice.scheduledSendTimeZone ?? timeZone,
|
||||
)}{" "}
|
||||
({invoice.scheduledSendTimeZone ?? timeZone})
|
||||
</Text>
|
||||
<Button
|
||||
title="Cancel scheduled send"
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
cancelScheduledInvoice.mutate({ invoiceId: invoice.id })
|
||||
}
|
||||
loading={cancelScheduledInvoice.isPending}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Send later">
|
||||
<DateTimeField
|
||||
label="Send date and time"
|
||||
value={scheduledAt}
|
||||
minimumDate={new Date(Date.now() + 60_000)}
|
||||
maximumDate={new Date(2100, 0, 1)}
|
||||
onChange={setScheduledAt}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.messageHint, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Time zone: {timeZone}. The exact instant is preserved across
|
||||
devices and daylight saving changes.
|
||||
</Text>
|
||||
<Button
|
||||
title="Schedule invoice"
|
||||
variant="secondary"
|
||||
onPress={handleSchedule}
|
||||
loading={scheduleInvoice.isPending}
|
||||
disabled={!clientEmail || invoice.items.length === 0}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title={sendLabel}
|
||||
onPress={handleSend}
|
||||
@@ -176,7 +308,9 @@ function SummaryRow({
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={summaryStyles.row}>
|
||||
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
summaryStyles.value,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
type ReceiptSplitDraft = Pick<
|
||||
ReceiptScanResult,
|
||||
@@ -37,7 +38,7 @@ export default function ExpenseDetailScreen() {
|
||||
const [form, setForm] = useState<ExpenseFormState>({
|
||||
description: "",
|
||||
amountText: "",
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
category: "",
|
||||
businessId: "",
|
||||
clientId: "",
|
||||
@@ -165,7 +166,9 @@ export default function ExpenseDetailScreen() {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
|
||||
<TabScrollView
|
||||
header={<PageHeader title="Expense" subtitle="Expense details" />}
|
||||
>
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
Expense not found
|
||||
</Text>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import type { AppRouter } from "beenvoice/server/api/root";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
@@ -26,6 +21,7 @@ import { api } from "@/lib/trpc";
|
||||
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
type ExpenseFilter = "all" | "billable" | "receipts";
|
||||
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
|
||||
@@ -120,17 +116,27 @@ export default function ExpensesScreen() {
|
||||
<View
|
||||
style={[
|
||||
styles.emptyCard,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
|
||||
<View
|
||||
style={[styles.emptyIcon, { backgroundColor: colors.muted }]}
|
||||
>
|
||||
<Ionicons
|
||||
name="receipt-outline"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
||||
No expenses yet
|
||||
</Text>
|
||||
<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>
|
||||
<Button
|
||||
title="Add expense"
|
||||
@@ -140,9 +146,18 @@ export default function ExpensesScreen() {
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.summaryGrid}>
|
||||
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
|
||||
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
|
||||
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
|
||||
<SummaryTile
|
||||
label="Visible total"
|
||||
value={formatCurrency(summary.total)}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Billable"
|
||||
value={formatCurrency(summary.billable)}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Receipts"
|
||||
value={String(summary.receiptCount)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@@ -174,14 +189,21 @@ export default function ExpensesScreen() {
|
||||
) : (
|
||||
groupedExpenses.map(([monthLabel, group]) => (
|
||||
<View key={monthLabel} style={styles.monthGroup}>
|
||||
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.monthLabel,
|
||||
{ color: colors.mutedForeground },
|
||||
]}
|
||||
>
|
||||
{monthLabel}
|
||||
</Text>
|
||||
{group.map((expense) => (
|
||||
<ExpenseRow
|
||||
key={expense.id}
|
||||
expense={expense}
|
||||
onDelete={() => deleteExpense.mutate({ id: expense.id })}
|
||||
onDelete={() =>
|
||||
deleteExpense.mutate({ id: expense.id })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -209,14 +231,23 @@ function SummaryTile({ label, value }: { label: string; value: string }) {
|
||||
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
|
||||
<Text
|
||||
style={[styles.summaryValue, { color: colors.foreground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
|
||||
function ExpenseRow({
|
||||
expense,
|
||||
onDelete,
|
||||
}: {
|
||||
expense: Expense;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createStyles);
|
||||
|
||||
@@ -236,7 +267,8 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
icon: "open-outline",
|
||||
color: "#fff",
|
||||
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",
|
||||
@@ -249,45 +281,77 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
]}
|
||||
>
|
||||
<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 style={styles.meta}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
|
||||
<Text
|
||||
style={[styles.title, { color: colors.foreground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{expense.description}
|
||||
</Text>
|
||||
{expense.receiptCount ? (
|
||||
<View
|
||||
style={[
|
||||
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 }]}>
|
||||
{expense.receiptCount}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
||||
<Text
|
||||
style={[styles.sub, { color: colors.mutedForeground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{formatDate(expense.date)}
|
||||
{expense.category ? ` · ${expense.category}` : ""}
|
||||
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||
</Text>
|
||||
<View style={styles.tagRow}>
|
||||
{expense.billable ? (
|
||||
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tag,
|
||||
{ color: colors.primary, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Billable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.reimbursable ? (
|
||||
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tag,
|
||||
{ color: colors.foreground, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Reimbursable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.taxDeductible ? (
|
||||
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tag,
|
||||
{ color: colors.success, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Tax
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -297,7 +361,11 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={colors.mutedForeground}
|
||||
/>
|
||||
</View>
|
||||
</SwipeableRow>
|
||||
);
|
||||
@@ -306,8 +374,7 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
function groupExpensesByMonth(expenses: Expense[]) {
|
||||
const groups = new Map<string, Expense[]>();
|
||||
for (const expense of expenses) {
|
||||
const date = new Date(expense.date);
|
||||
const key = date.toLocaleDateString(undefined, {
|
||||
const key = formatCalendarDate(expense.date, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
@@ -320,11 +387,16 @@ function groupExpensesByMonth(expenses: Expense[]) {
|
||||
|
||||
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
|
||||
const normalized = category?.toLowerCase() ?? "";
|
||||
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
|
||||
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-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";
|
||||
if (normalized.includes("travel") || normalized.includes("mileage"))
|
||||
return "airplane-outline";
|
||||
if (normalized.includes("meal") || normalized.includes("food"))
|
||||
return "restaurant-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";
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +303,9 @@ export default function SettingsScreen() {
|
||||
Role: {profile.role}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Time zone: {profile?.timeZone ?? "America/New_York"}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card title="Accounts">
|
||||
|
||||
@@ -17,36 +17,53 @@ import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import { api } from "@/lib/trpc";
|
||||
import type { AppRouter } from "beenvoice/server/api/root";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import {
|
||||
DEFAULT_TIME_ZONE,
|
||||
getZonedDateTimeParts,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
|
||||
|
||||
function groupByDate(entries: TimeEntry[]) {
|
||||
function groupByDate(entries: TimeEntry[], timeZone: string) {
|
||||
const groups = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
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",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(entry);
|
||||
groups.set(key, list);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
timeZone,
|
||||
}),
|
||||
groupedEntries,
|
||||
] as const,
|
||||
);
|
||||
}
|
||||
|
||||
export default function TimeEntriesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
const entriesQuery = api.timeEntries.getAll.useQuery();
|
||||
const profileQuery = api.settings.getProfile.useQuery();
|
||||
|
||||
const completed = useMemo(
|
||||
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
|
||||
[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) {
|
||||
return <LoadingScreen message="Loading time entries…" />;
|
||||
@@ -57,7 +74,10 @@ export default function TimeEntriesScreen() {
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<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 }}>
|
||||
{formatTrpcErrorMessage(entriesQuery.error)}
|
||||
</Text>
|
||||
@@ -72,7 +92,10 @@ export default function TimeEntriesScreen() {
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
|
||||
<PageHeader
|
||||
title="Time entries"
|
||||
subtitle={`${completed.length} completed entries`}
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<PullToRefresh
|
||||
@@ -82,7 +105,9 @@ export default function TimeEntriesScreen() {
|
||||
}
|
||||
>
|
||||
{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.
|
||||
</Text>
|
||||
) : (
|
||||
@@ -104,17 +129,26 @@ export default function TimeEntriesScreen() {
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||
<Text
|
||||
style={[styles.title, { color: colors.foreground }]}
|
||||
>
|
||||
{formatRunningTimerLabel(entry.description)}
|
||||
</Text>
|
||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
}}
|
||||
>
|
||||
{entry.client?.name ?? "No client"}
|
||||
{entry.invoice
|
||||
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||
: " · not billed"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||
<Text
|
||||
style={[styles.title, { color: colors.foreground }]}
|
||||
>
|
||||
{entry.hours ?? "—"}h
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import * as Notifications from "expo-notifications";
|
||||
import Constants from "expo-constants";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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";
|
||||
|
||||
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) {
|
||||
function openInvoiceFromNotification(
|
||||
data: Record<string, unknown> | undefined,
|
||||
) {
|
||||
if (data?.type !== "invoice-send-reminder") return;
|
||||
const invoiceId = data.invoiceId;
|
||||
if (typeof invoiceId !== "string" || !invoiceId) return;
|
||||
@@ -21,14 +27,37 @@ export function InvoiceReminderSync() {
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
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(() => {
|
||||
if (!invoicesQuery.data) return;
|
||||
void syncInvoiceSendReminders(invoicesQuery.data);
|
||||
}, [invoicesQuery.data]);
|
||||
void syncInvoiceSendReminders(remotePushReady ? [] : invoicesQuery.data);
|
||||
}, [invoicesQuery.data, remotePushReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
(nextState: AppStateStatus) => {
|
||||
if (nextState === "background" || nextState === "inactive") {
|
||||
wasBackgrounded.current = true;
|
||||
return;
|
||||
@@ -37,19 +66,19 @@ export function InvoiceReminderSync() {
|
||||
if (nextState !== "active" || !wasBackgrounded.current) return;
|
||||
wasBackgrounded.current = false;
|
||||
void utils.invoices.getAll.invalidate({ status: "draft" });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [utils.invoices.getAll]);
|
||||
|
||||
useEffect(() => {
|
||||
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
const responseSubscription =
|
||||
Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
openInvoiceFromNotification(
|
||||
response.notification.request.content.data as Record<string, unknown>,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (!response) return;
|
||||
|
||||
@@ -8,6 +8,11 @@ import { Logo } from "@/components/Logo";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
|
||||
import {
|
||||
BusinessBrandImage,
|
||||
hasMobileBusinessBrandAsset,
|
||||
} from "@/components/businesses/BusinessBrandImage";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type TopChromeProps = {
|
||||
showMoreBack?: boolean;
|
||||
@@ -16,6 +21,7 @@ type TopChromeProps = {
|
||||
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
|
||||
export function TopChrome({ showMoreBack = false }: TopChromeProps) {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const defaultBusiness = api.businesses.getDefault.useQuery();
|
||||
|
||||
function handleBack() {
|
||||
if (router.canGoBack()) {
|
||||
@@ -34,13 +40,25 @@ export function TopChrome({ showMoreBack = false }: TopChromeProps) {
|
||||
onPress={handleBack}
|
||||
style={({ pressed }) => [
|
||||
styles.backButton,
|
||||
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
|
||||
{
|
||||
borderColor: colors.borderGlass,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={18} color={colors.foreground} />
|
||||
<Text style={[styles.backLabel, { color: colors.foreground }]}>More</Text>
|
||||
<Text style={[styles.backLabel, { color: colors.foreground }]}>
|
||||
More
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : defaultBusiness.data &&
|
||||
hasMobileBusinessBrandAsset(defaultBusiness.data) ? (
|
||||
<BusinessBrandImage
|
||||
business={defaultBusiness.data}
|
||||
kind="wordmark"
|
||||
style={styles.businessWordmark}
|
||||
/>
|
||||
) : (
|
||||
<Logo size="xs" onDark={isDark} />
|
||||
)}
|
||||
@@ -66,6 +84,10 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.pill,
|
||||
},
|
||||
businessWordmark: {
|
||||
width: 132,
|
||||
height: 32,
|
||||
},
|
||||
backLabel: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Image, type ImageStyle, type StyleProp } from "react-native";
|
||||
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { hasBusinessBrandAsset } from "@beenvoice/domain/brand-assets";
|
||||
|
||||
type BrandAssetKind = "logo" | "wordmark" | "icon";
|
||||
type BrandAssetTheme = "light" | "dark";
|
||||
|
||||
type BusinessBrandImageProps = {
|
||||
business: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoDarkStorageKey?: string | null;
|
||||
wordmarkLightStorageKey?: string | null;
|
||||
wordmarkDarkStorageKey?: string | null;
|
||||
iconLightStorageKey?: string | null;
|
||||
iconDarkStorageKey?: string | null;
|
||||
};
|
||||
kind?: BrandAssetKind;
|
||||
theme?: BrandAssetTheme;
|
||||
style?: StyleProp<ImageStyle>;
|
||||
};
|
||||
|
||||
export function hasMobileBusinessBrandAsset(
|
||||
business: BusinessBrandImageProps["business"],
|
||||
) {
|
||||
return hasBusinessBrandAsset(business);
|
||||
}
|
||||
|
||||
export function BusinessBrandImage({
|
||||
business,
|
||||
kind = "icon",
|
||||
theme: themeOverride,
|
||||
style,
|
||||
}: BusinessBrandImageProps) {
|
||||
const { apiUrl } = useAccounts();
|
||||
const { isDark } = useAppTheme();
|
||||
if (!hasMobileBusinessBrandAsset(business)) return null;
|
||||
const theme = themeOverride ?? (isDark ? "dark" : "light");
|
||||
const base = apiUrl.replace(/\/$/, "");
|
||||
return (
|
||||
<Image
|
||||
source={{
|
||||
uri: `${base}/api/business-logo/${business.id}?kind=${kind}&theme=${theme}&format=png`,
|
||||
}}
|
||||
accessibilityLabel={`${business.name ?? "Business"} ${kind}`}
|
||||
resizeMode="contain"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
@@ -19,6 +20,12 @@ import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { BusinessBrandImage } from "@/components/businesses/BusinessBrandImage";
|
||||
import {
|
||||
getBrandAssetFieldNames,
|
||||
type BrandAssetKind,
|
||||
type BrandAssetTheme,
|
||||
} from "@beenvoice/domain/brand-assets";
|
||||
|
||||
type BusinessFormValues = {
|
||||
name: string;
|
||||
@@ -33,6 +40,7 @@ type BusinessFormValues = {
|
||||
country: string;
|
||||
website: string;
|
||||
taxId: string;
|
||||
hideNameWithLogo: boolean;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
@@ -49,9 +57,24 @@ const emptyValues: BusinessFormValues = {
|
||||
country: "United States",
|
||||
website: "",
|
||||
taxId: "",
|
||||
hideNameWithLogo: false,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
const MAX_BRAND_ASSET_BYTES = 5 * 1024 * 1024;
|
||||
const BRAND_ASSET_SLOTS: Array<{
|
||||
kind: BrandAssetKind;
|
||||
theme: BrandAssetTheme;
|
||||
label: string;
|
||||
}> = [
|
||||
{ kind: "logo", theme: "light", label: "Logo · light" },
|
||||
{ kind: "logo", theme: "dark", label: "Logo · dark" },
|
||||
{ kind: "wordmark", theme: "light", label: "Wordmark · light" },
|
||||
{ kind: "wordmark", theme: "dark", label: "Wordmark · dark" },
|
||||
{ kind: "icon", theme: "light", label: "Icon · light" },
|
||||
{ kind: "icon", theme: "dark", label: "Icon · dark" },
|
||||
];
|
||||
|
||||
type BusinessFormProps = {
|
||||
mode: "create" | "edit";
|
||||
businessId?: string;
|
||||
@@ -78,6 +101,7 @@ export function BusinessForm({
|
||||
|
||||
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
|
||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||
|
||||
const switchProps = {
|
||||
@@ -102,6 +126,7 @@ export function BusinessForm({
|
||||
country: business.country ?? "United States",
|
||||
website: business.website ?? "",
|
||||
taxId: business.taxId ?? "",
|
||||
hideNameWithLogo: business.hideNameWithLogo ?? false,
|
||||
isDefault: business.isDefault ?? false,
|
||||
});
|
||||
}, [businessQuery.data]);
|
||||
@@ -117,7 +142,8 @@ export function BusinessForm({
|
||||
const updateBusiness = api.businesses.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.businesses.getAll.invalidate();
|
||||
if (businessId) void utils.businesses.getById.invalidate({ id: businessId });
|
||||
if (businessId)
|
||||
void utils.businesses.getById.invalidate({ id: businessId });
|
||||
onSaved();
|
||||
},
|
||||
onError: (err) => setFieldError(err.message),
|
||||
@@ -130,8 +156,68 @@ export function BusinessForm({
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not delete business", err.message),
|
||||
});
|
||||
const uploadLogo = api.businesses.uploadLogo.useMutation({
|
||||
onSuccess: () => {
|
||||
if (businessId)
|
||||
void utils.businesses.getById.invalidate({ id: businessId });
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not upload brand asset", err.message),
|
||||
onSettled: () => setUploadingAsset(null),
|
||||
});
|
||||
const removeLogo = api.businesses.removeLogo.useMutation({
|
||||
onSuccess: () => {
|
||||
if (businessId)
|
||||
void utils.businesses.getById.invalidate({ id: businessId });
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not remove brand asset", err.message),
|
||||
});
|
||||
|
||||
function patch<K extends keyof BusinessFormValues>(field: K, value: BusinessFormValues[K]) {
|
||||
async function pickBrandAsset(kind: BrandAssetKind, theme: BrandAssetTheme) {
|
||||
if (!businessId) return;
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert(
|
||||
"Photos access needed",
|
||||
"Allow photo access to upload branding.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
quality: 1,
|
||||
base64: true,
|
||||
});
|
||||
if (result.canceled) return;
|
||||
const asset = result.assets[0];
|
||||
if (!asset?.base64) return;
|
||||
const mimeType = asset.mimeType ?? "image/jpeg";
|
||||
if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
|
||||
Alert.alert(
|
||||
"Unsupported image",
|
||||
"Mobile supports PNG, JPEG, and WebP. Upload SVG assets from the web app.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (asset.fileSize && asset.fileSize > MAX_BRAND_ASSET_BYTES) {
|
||||
Alert.alert("Image too large", "Brand assets must be 5MB or less.");
|
||||
return;
|
||||
}
|
||||
const key = `${kind}-${theme}`;
|
||||
setUploadingAsset(key);
|
||||
uploadLogo.mutate({
|
||||
id: businessId,
|
||||
kind,
|
||||
theme,
|
||||
filename: asset.fileName ?? `${key}.jpg`,
|
||||
mimeType,
|
||||
data: asset.base64,
|
||||
});
|
||||
}
|
||||
|
||||
function patch<K extends keyof BusinessFormValues>(
|
||||
field: K,
|
||||
value: BusinessFormValues[K],
|
||||
) {
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
setFieldError(null);
|
||||
}
|
||||
@@ -150,6 +236,7 @@ export function BusinessForm({
|
||||
country: values.country.trim() || "United States",
|
||||
website: values.website.trim(),
|
||||
taxId: values.taxId.trim(),
|
||||
hideNameWithLogo: values.hideNameWithLogo,
|
||||
isDefault: values.isDefault,
|
||||
};
|
||||
}
|
||||
@@ -186,7 +273,9 @@ export function BusinessForm({
|
||||
}
|
||||
|
||||
const saving = createBusiness.isPending || updateBusiness.isPending;
|
||||
const nameError = values.name.trim() ? undefined : "Business name is required";
|
||||
const nameError = values.name.trim()
|
||||
? undefined
|
||||
: "Business name is required";
|
||||
const canSave = isRequiredString(values.name);
|
||||
|
||||
return (
|
||||
@@ -195,8 +284,13 @@ export function BusinessForm({
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={
|
||||
Platform.OS === "ios" ? "automatic" : undefined
|
||||
}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
@@ -247,7 +341,9 @@ export function BusinessForm({
|
||||
<Text style={[styles.switchLabel, { color: colors.foreground }]}>
|
||||
Default business
|
||||
</Text>
|
||||
<Text style={[styles.switchHint, { color: colors.mutedForeground }]}>
|
||||
<Text
|
||||
style={[styles.switchHint, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Used for new invoices when none is selected
|
||||
</Text>
|
||||
</View>
|
||||
@@ -259,6 +355,108 @@ export function BusinessForm({
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{mode === "edit" && businessQuery.data ? (
|
||||
<Card title="Brand assets">
|
||||
<Text style={[styles.brandHint, { color: colors.mutedForeground }]}>
|
||||
Upload combined logos, wordmarks, and compact icons for light and
|
||||
dark backgrounds. Missing variants fall back automatically.
|
||||
</Text>
|
||||
{BRAND_ASSET_SLOTS.map((slot) => {
|
||||
const [storageField] = getBrandAssetFieldNames(
|
||||
slot.kind,
|
||||
slot.theme,
|
||||
);
|
||||
const hasAsset = Boolean(businessQuery.data[storageField]);
|
||||
const key = `${slot.kind}-${slot.theme}`;
|
||||
return (
|
||||
<View key={key} style={styles.brandSlot}>
|
||||
<View
|
||||
style={[
|
||||
styles.brandPreview,
|
||||
{
|
||||
backgroundColor:
|
||||
slot.theme === "dark" ? "#09090b" : "#ffffff",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{hasAsset ? (
|
||||
<BusinessBrandImage
|
||||
business={businessQuery.data}
|
||||
kind={slot.kind}
|
||||
theme={slot.theme}
|
||||
style={styles.brandImage}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
color:
|
||||
slot.theme === "dark"
|
||||
? "rgba(255,255,255,0.45)"
|
||||
: "rgba(0,0,0,0.35)",
|
||||
}}
|
||||
>
|
||||
No asset
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.brandLabel, { color: colors.foreground }]}
|
||||
>
|
||||
{slot.label}
|
||||
</Text>
|
||||
<View style={styles.brandActions}>
|
||||
<Button
|
||||
title={hasAsset ? "Replace" : "Upload"}
|
||||
variant="secondary"
|
||||
leftIcon="cloud-upload-outline"
|
||||
loading={uploadingAsset === key}
|
||||
disabled={Boolean(uploadingAsset)}
|
||||
style={styles.brandAction}
|
||||
onPress={() => void pickBrandAsset(slot.kind, slot.theme)}
|
||||
/>
|
||||
{hasAsset ? (
|
||||
<Button
|
||||
title="Remove"
|
||||
variant="ghost"
|
||||
leftIcon="trash-outline"
|
||||
loading={removeLogo.isPending}
|
||||
style={styles.brandAction}
|
||||
onPress={() =>
|
||||
removeLogo.mutate({
|
||||
id: businessId!,
|
||||
kind: slot.kind,
|
||||
theme: slot.theme,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchCopy}>
|
||||
<Text
|
||||
style={[styles.switchLabel, { color: colors.foreground }]}
|
||||
>
|
||||
Hide business name on invoices
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.switchHint, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Useful when the combined logo already includes the name
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={values.hideNameWithLogo}
|
||||
onValueChange={(value) => patch("hideNameWithLogo", value)}
|
||||
{...switchProps}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Address">
|
||||
<Input
|
||||
label="Address line 1"
|
||||
@@ -270,8 +468,16 @@ export function BusinessForm({
|
||||
value={values.addressLine2}
|
||||
onChangeText={(v) => patch("addressLine2", v)}
|
||||
/>
|
||||
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
|
||||
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
|
||||
<Input
|
||||
label="City"
|
||||
value={values.city}
|
||||
onChangeText={(v) => patch("city", v)}
|
||||
/>
|
||||
<Input
|
||||
label="State"
|
||||
value={values.state}
|
||||
onChangeText={(v) => patch("state", v)}
|
||||
/>
|
||||
<Input
|
||||
label="Postal code"
|
||||
value={values.postalCode}
|
||||
@@ -284,7 +490,11 @@ export function BusinessForm({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
|
||||
{fieldError ? (
|
||||
<Text selectable style={styles.error}>
|
||||
{fieldError}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
@@ -337,6 +547,38 @@ const createBusinessFormStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
brandHint: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
brandSlot: {
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
brandPreview: {
|
||||
height: 92,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: spacing.md,
|
||||
},
|
||||
brandImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
brandLabel: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
brandActions: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
brandAction: {
|
||||
flex: 1,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SelectField, type SelectOption } from "@/components/ui/SelectField";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
|
||||
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
@@ -37,7 +38,7 @@ export function defaultExpenseFormState(
|
||||
return {
|
||||
description: "",
|
||||
amountText: "",
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
category: "",
|
||||
businessId: defaultBusinessId,
|
||||
clientId: "",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SelectField } from "@/components/ui/SelectField";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { defaultDueDate } from "@/lib/invoice-number";
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
type SelectOption = { label: string; value: string };
|
||||
|
||||
@@ -120,7 +121,9 @@ export function InvoiceSetupForm({
|
||||
|
||||
{invoiceNumberReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
<Text
|
||||
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Invoice number
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
@@ -141,11 +144,13 @@ export function InvoiceSetupForm({
|
||||
|
||||
{issueDateReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
<Text
|
||||
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Issue date
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{issueDate.toLocaleDateString()}
|
||||
{formatCalendarDate(issueDate)}
|
||||
</Text>
|
||||
</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 ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
<Text
|
||||
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Tax rate
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
@@ -186,7 +198,7 @@ export function InvoiceSetupForm({
|
||||
<>
|
||||
<DateTimeField
|
||||
label="Remind me to send"
|
||||
mode="date"
|
||||
mode="datetime"
|
||||
value={sendReminderAt ?? dueDate}
|
||||
minimumDate={new Date()}
|
||||
maximumDate={new Date(2100, 0, 1)}
|
||||
|
||||
@@ -3,11 +3,22 @@ import DateTimePicker, {
|
||||
type DateTimePickerEvent,
|
||||
} from "@react-native-community/datetimepicker";
|
||||
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 { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import {
|
||||
calendarDateFromLocalDate,
|
||||
calendarDateToLocalDate,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
type DateTimeFieldProps = {
|
||||
label: string;
|
||||
@@ -31,17 +42,18 @@ export function DateTimeField({
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
function openPicker() {
|
||||
setDraft(value);
|
||||
setDraft(mode === "date" ? calendarDateToLocalDate(value) : value);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function applyDate(next: Date) {
|
||||
const normalized = mode === "date" ? calendarDateFromLocalDate(next) : next;
|
||||
const clamped =
|
||||
next.getTime() > maximumDate.getTime()
|
||||
normalized.getTime() > maximumDate.getTime()
|
||||
? maximumDate
|
||||
: minimumDate && next.getTime() < minimumDate.getTime()
|
||||
: minimumDate && normalized.getTime() < minimumDate.getTime()
|
||||
? minimumDate
|
||||
: next;
|
||||
: normalized;
|
||||
onChange(clamped);
|
||||
}
|
||||
|
||||
@@ -60,7 +72,9 @@ export function DateTimeField({
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text style={[styles.label, { color: colors.mutedForeground }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel={`${label}, ${
|
||||
@@ -81,28 +95,53 @@ export function DateTimeField({
|
||||
<Text style={[styles.value, { color: colors.foreground }]}>
|
||||
{mode === "date" ? formatDate(value) : formatDateTime(value)}
|
||||
</Text>
|
||||
<Ionicons name="calendar-outline" size={18} color={colors.mutedForeground} />
|
||||
<Ionicons
|
||||
name="calendar-outline"
|
||||
size={18}
|
||||
color={colors.mutedForeground}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{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.sheet, { backgroundColor: colors.card }]}
|
||||
onPress={(event) => event.stopPropagation()}
|
||||
>
|
||||
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.sheetHeader,
|
||||
{ borderBottomColor: colors.border },
|
||||
]}
|
||||
>
|
||||
<Pressable onPress={() => setOpen(false)}>
|
||||
<Text style={[styles.sheetAction, { color: colors.mutedForeground }]}>Cancel</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.sheetAction,
|
||||
{ color: colors.mutedForeground },
|
||||
]}
|
||||
>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
|
||||
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
applyDate(draft);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.sheetAction, { color: colors.primary }]}>Done</Text>
|
||||
<Text style={[styles.sheetAction, { color: colors.primary }]}>
|
||||
Done
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
export function formatCurrency(amount: number, currency = "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
@@ -7,7 +9,7 @@ export function formatCurrency(amount: number, currency = "USD") {
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string) {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
return formatCalendarDate(date, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
@@ -15,7 +17,7 @@ export function formatDate(date: Date | string) {
|
||||
}
|
||||
|
||||
export function formatShortDate(date: Date | string) {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
return formatCalendarDate(date, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { addCalendarDays } from "@beenvoice/domain/time-zone";
|
||||
|
||||
/** Matches web invoice-form default numbering. */
|
||||
export function generateInvoiceNumber(now = new Date()): string {
|
||||
const date = [
|
||||
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
|
||||
}
|
||||
|
||||
export function defaultDueDate(issueDate: Date): Date {
|
||||
const due = new Date(issueDate);
|
||||
due.setDate(due.getDate() + 30);
|
||||
return due;
|
||||
return addCalendarDays(issueDate, 30);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,16 @@ export type InvoiceStatus = EffectiveInvoiceStatus;
|
||||
export function getInvoiceStatus(invoice: {
|
||||
status: string;
|
||||
dueDate: Date | string;
|
||||
createdBy?: { timeZone: string } | null;
|
||||
}): InvoiceStatus {
|
||||
if (invoice.status === "paid" || invoice.status === "draft") {
|
||||
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> = {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
EXPENSE_CATEGORIES as domainExpenseCategories,
|
||||
addCalendarDays,
|
||||
calendarDateFromInstant,
|
||||
formatElapsedSeconds as formatDomainElapsedSeconds,
|
||||
getEffectiveInvoiceStatus,
|
||||
} from "@beenvoice/domain";
|
||||
@@ -14,9 +16,7 @@ import { generateInvoiceNumber as generateMobileInvoiceNumber } from "../lib/inv
|
||||
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
|
||||
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
|
||||
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
|
||||
import {
|
||||
normalizeOptionalId,
|
||||
} from "../../web/src/lib/time-clock";
|
||||
import { normalizeOptionalId } from "../../web/src/lib/time-clock";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -52,15 +52,18 @@ describe("invoice parity", () => {
|
||||
test("web and mobile use the device-local date in invoice numbers", () => {
|
||||
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
|
||||
|
||||
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
|
||||
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
|
||||
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith(
|
||||
"INV-20260816-",
|
||||
);
|
||||
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith(
|
||||
"INV-20260816-",
|
||||
);
|
||||
});
|
||||
|
||||
test("web and mobile agree on draft, paid, sent, and overdue states", () => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const timeZone = "America/New_York";
|
||||
const today = calendarDateFromInstant(new Date(), timeZone);
|
||||
const yesterday = addCalendarDays(today, -1);
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
@@ -82,11 +85,15 @@ describe("invoice parity", () => {
|
||||
];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
expect(getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate)).toBe(
|
||||
fixture.expected,
|
||||
);
|
||||
expect(
|
||||
getInvoiceStatus({ status: fixture.stored, dueDate: fixture.dueDate }),
|
||||
getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate, timeZone),
|
||||
).toBe(fixture.expected);
|
||||
expect(
|
||||
getInvoiceStatus({
|
||||
status: fixture.stored,
|
||||
dueDate: fixture.dueDate,
|
||||
createdBy: { timeZone },
|
||||
}),
|
||||
).toBe(fixture.expected);
|
||||
}
|
||||
});
|
||||
|
||||
+27
-5
@@ -68,6 +68,20 @@ DB_DISABLE_SSL=true
|
||||
POSTGRES_PORT=5432
|
||||
GARAGE_API_PORT=3900
|
||||
|
||||
# Coolify Compose container listener ports. APP_PORT is Coolify-only;
|
||||
# POSTGRES_PORT and GARAGE_API_PORT are reused from above. The regular/dev
|
||||
# compose files keep the database and Garage container ports at 5432/3900 and
|
||||
# use those variables only for host-side mappings.
|
||||
APP_PORT=3000
|
||||
GARAGE_RPC_PORT=3901
|
||||
GARAGE_WEB_PORT=3902
|
||||
GARAGE_ADMIN_PORT=3903
|
||||
# Garage requires a 64-character hexadecimal RPC secret. Generate with:
|
||||
# openssl rand -hex 32
|
||||
# GARAGE_RPC_SECRET=
|
||||
# GARAGE_ADMIN_TOKEN=
|
||||
# GARAGE_METRICS_TOKEN=
|
||||
|
||||
# Optional: if Next dev picks another port, you do not need to change URLs for
|
||||
# sign-in — the auth client uses window.location.origin in the browser.
|
||||
|
||||
@@ -100,12 +114,19 @@ NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
|
||||
NEXT_PUBLIC_BRAND_ICON=$
|
||||
|
||||
# =============================================================================
|
||||
# Email — Resend (optional)
|
||||
# Email — Mailpit locally, Resend in production
|
||||
# =============================================================================
|
||||
# Leave blank to disable invoice and password-reset email delivery.
|
||||
# Start local dependencies, then inspect messages at http://localhost:8028.
|
||||
# Production must use EMAIL_PROVIDER=resend (Mailpit is rejected in production).
|
||||
|
||||
EMAIL_PROVIDER=mailpit
|
||||
EMAIL_FROM=beenvoice <noreply@beenvoice.test>
|
||||
SMTP_HOST=127.0.0.1
|
||||
SMTP_PORT=1028
|
||||
SMTP_SECURE=false
|
||||
RESEND_API_KEY=
|
||||
RESEND_DOMAIN=
|
||||
RESEND_FROM=
|
||||
|
||||
# =============================================================================
|
||||
# Analytics — Umami (optional)
|
||||
@@ -138,7 +159,8 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
||||
# • Coolify — see docs/COOLIFY.md. Summary:
|
||||
# - Best: one Compose resource with docker-compose.coolify.yml (app+db+garage).
|
||||
# - Application + separate Garage: ENOTFOUND garage → set S3_ENDPOINT to
|
||||
# SERVICE_URL_GARAGE_3900 (public domain) OR http://garage-<resource-uuid>:3900
|
||||
# SERVICE_URL_GARAGE (public domain) OR
|
||||
# http://garage-<resource-uuid>:<GARAGE_API_PORT>
|
||||
# with Connect to Predefined Network on both resources. Never bare "garage".
|
||||
# - NEVER use localhost in production — inside the app container that is the app, not Garage.
|
||||
#
|
||||
@@ -150,8 +172,8 @@ S3_SECRET_KEY=7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
|
||||
S3_REGION=garage
|
||||
# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for Garage/HTTPS proxy
|
||||
#
|
||||
# docker-compose.yml sets S3_ENDPOINT=http://garage:3900 inside the app container
|
||||
# automatically. S3_ACCESS_KEY / S3_SECRET_KEY must match the garage service env.
|
||||
# docker-compose.yml uses http://garage:3900 internally. The Coolify compose
|
||||
# derives the internal URL from GARAGE_API_PORT. Credentials must match Garage.
|
||||
|
||||
# =============================================================================
|
||||
# SSO — Authentik OIDC (optional)
|
||||
|
||||
+33
-18
@@ -10,13 +10,13 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| ----------- | ------------------------------------------------------------------ |
|
||||
| App | Next.js 16 App Router, React 19 |
|
||||
| API | tRPC 11 + SuperJSON |
|
||||
| Database | PostgreSQL 17, Drizzle ORM |
|
||||
| Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) |
|
||||
| UI | shadcn/ui, Tailwind CSS v4 |
|
||||
| Email / PDF | Resend, `@react-pdf/renderer` |
|
||||
| Email / PDF | Resend or SMTP/Mailpit, `@react-pdf/renderer` |
|
||||
| Runtime | Bun |
|
||||
|
||||
## Features
|
||||
@@ -24,7 +24,7 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
|
||||
- Clients, businesses, invoices (line items, tax, status workflow)
|
||||
- Time clock with one running timer per user; clock-out can append invoice lines
|
||||
- Expenses, payments, recurring invoices, invoice templates
|
||||
- PDF export and email delivery (Resend)
|
||||
- PDF export and email delivery (Resend in production, Mailpit locally)
|
||||
- Public invoice links (`/i/[token]`)
|
||||
- CSV import, reports, platform branding / admin settings
|
||||
- MCP API (`/api/mcp`) for automation via API keys (`bv_…`)
|
||||
@@ -62,7 +62,13 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
Email and SSO are optional for local work — leave `RESEND_*` and `AUTHENTIK_*` blank unless you need them.
|
||||
SSO is optional for local work. Email defaults to Mailpit: start the development
|
||||
Compose services and open `http://localhost:8028` to inspect messages.
|
||||
|
||||
```bash
|
||||
bun run --filter @beenvoice/web docker:up
|
||||
bun run email:preview
|
||||
```
|
||||
|
||||
### 3. Database
|
||||
|
||||
@@ -145,15 +151,22 @@ App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is alway
|
||||
|
||||
### Scheduled recurring invoices
|
||||
|
||||
The app container does not run a cron daemon. It starts the web server with
|
||||
`bun migrate.ts && bun run start`, and recurring invoice generation only happens
|
||||
when something calls `POST /api/cron/generate-recurring` with
|
||||
`Authorization: Bearer $CRON_SECRET`.
|
||||
The Compose stack includes a dedicated PostgreSQL-backed worker. It discovers due
|
||||
recurring invoices every minute, enqueues idempotent jobs, and processes them with
|
||||
bounded retries and stale-lock recovery. No Coolify scheduled task or Redis
|
||||
service is required.
|
||||
|
||||
- **Coolify deploys:** use a Coolify scheduled task to call the endpoint.
|
||||
- **Full Docker deploys:** use host cron, a small scheduler sidecar, or an
|
||||
external scheduler to call
|
||||
`http://localhost:${WEB_PORT:-${PORT:-3000}}/api/cron/generate-recurring`.
|
||||
`POST /api/cron/generate-recurring` remains available as an optional authenticated
|
||||
"schedule now" hook. It only enqueues due work; invoice generation stays in the
|
||||
worker.
|
||||
|
||||
### Scheduled invoice delivery
|
||||
|
||||
The web and mobile send screens can enqueue invoice email for a future date and
|
||||
time. Each job stores an absolute instant and the originating IANA timezone,
|
||||
supports rescheduling or cancellation before the worker claims it, and uses a
|
||||
Resend idempotency key during bounded retries. The worker reaches the web app at
|
||||
`APP_INTERNAL_URL` using `CRON_SECRET`; Compose configures the internal URL.
|
||||
|
||||
### 3. Updating an existing deploy
|
||||
|
||||
@@ -164,13 +177,13 @@ git pull
|
||||
```
|
||||
|
||||
| Command | New code? | Migrations run? |
|
||||
|---------|-----------|-----------------|
|
||||
| ----------------------------------------------------------------------- | ----------------------------- | ----------------------------------------------- |
|
||||
| `git pull` only | No | No |
|
||||
| `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
|
||||
| `../../scripts/docker-deploy.sh` or root `docker compose up -d --build` | Yes | Yes — on app container start |
|
||||
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
|
||||
|
||||
Prune old app images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` tags).
|
||||
Prune old app and worker images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` / `beenvoice-worker:*` tags).
|
||||
|
||||
To verify migration files match the journal before deploy: `bun run db:verify-journal`.
|
||||
|
||||
@@ -191,8 +204,10 @@ Use the literal strings `true` or `false` (or omit the variable). Do not rely on
|
||||
### 5. Optional services
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `RESEND_API_KEY`, `RESEND_DOMAIN` | Invoice and password-reset email |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `EMAIL_PROVIDER`, `EMAIL_FROM` | Select `mailpit`, `smtp`, or `resend` and configure the sender |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | Local Mailpit or another SMTP-compatible transport |
|
||||
| `RESEND_API_KEY`, `RESEND_DOMAIN`, `RESEND_FROM` | Production Resend delivery |
|
||||
| `AUTHENTIK_ISSUER`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | OIDC SSO (also set `NEXT_PUBLIC_AUTHENTIK_ENABLED=true` and rebuild) |
|
||||
| `CRON_SECRET` | Protects `/api/cron/generate-recurring` |
|
||||
| `DISABLE_SIGNUPS=true` | Block new registrations |
|
||||
@@ -246,7 +261,7 @@ Full-stack deploy uses `bun run docker:deploy` or `../../scripts/docker-deploy.s
|
||||
## API surface
|
||||
|
||||
| Endpoint | Auth | Purpose |
|
||||
|----------|------|---------|
|
||||
| -------------------- | ------------------------- | ---------------------------------------- |
|
||||
| `/api/trpc` | Session cookie or API key | Primary API (web + mobile) |
|
||||
| `/api/auth/*` | Varies | better-auth + custom register/reset REST |
|
||||
| `/api/mcp` | API key only | JSON-RPC automation tools |
|
||||
@@ -265,7 +280,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
|
||||
## Documentation
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| ---------------------------------------------- | ------------------------------------------ |
|
||||
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
|
||||
| [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking |
|
||||
| [docs/README.md](./docs/README.md) | Index of UI and product guides |
|
||||
|
||||
@@ -7,13 +7,13 @@ This application is the server and browser workspace in the Beenvoice monorepo.
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| --------- | ----------------------------------------------------------------------------- |
|
||||
| Framework | Next.js 16 App Router (`src/app/`) |
|
||||
| API | tRPC 11 (`/api/trpc`), SuperJSON transformer |
|
||||
| ORM | Drizzle + `pg` pool |
|
||||
| Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) |
|
||||
| UI | shadcn/ui, Tailwind CSS v4, Radix primitives |
|
||||
| Email | Resend |
|
||||
| Email | Shared Resend/SMTP transport; Mailpit for local capture |
|
||||
| PDF | `@react-pdf/renderer` |
|
||||
|
||||
## Request flow
|
||||
@@ -70,14 +70,14 @@ drizzle/ # SQL migrations (0000–0014+)
|
||||
Root: `src/server/api/root.ts`. All routers use Zod input validation.
|
||||
|
||||
| Namespace | File | Key procedures |
|
||||
|-----------|------|----------------|
|
||||
| ------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `clients` | `routers/clients.ts` | getAll, getById, create, update, delete |
|
||||
| `businesses` | `routers/businesses.ts` | getAll, getById, getDefault, create, update, delete, setDefault, getEmailConfig, updateEmailConfig |
|
||||
| `invoices` | `routers/invoices.ts` | getAll, getBillable, getById, create, update, delete, updateStatus, bulk*, previewPdf, public token, **getByPublicToken** (public), sendReminder |
|
||||
| `invoices` | `routers/invoices.ts` | getAll, getBillable, getById, create, update, delete, updateStatus, bulk\*, previewPdf, public token, **getByPublicToken** (public), sendReminder |
|
||||
| `payments` | `routers/payments.ts` | getByInvoice, create, delete |
|
||||
| `expenses` | `routers/expenses.ts` | getAll, getById, create, update, delete |
|
||||
| `invoiceTemplates` | `routers/invoiceTemplates.ts` | CRUD by template type |
|
||||
| `recurringInvoices` | `routers/recurring-invoices.ts` | CRUD, pause/resume, generateNow; cron helper `generateDueRecurringInvoices` |
|
||||
| `recurringInvoices` | `routers/recurring-invoices.ts` | CRUD, pause/resume, generateNow; due generation runs through the worker |
|
||||
| `timeEntries` | `routers/time-entries.ts` | getAll, getRunning, clockIn, updateRunning, clockOut, create, update, delete, getSummary |
|
||||
| `dashboard` | `routers/dashboard.ts` | getStats |
|
||||
| `email` | `routers/email.ts` | sendInvoice |
|
||||
@@ -98,7 +98,7 @@ Single file: `src/server/db/schema.ts`. Table names use `pgTableCreator` → pre
|
||||
### Auth & platform
|
||||
|
||||
| Table | Notes |
|
||||
|-------|-------|
|
||||
| ------------------------------ | ------------------------------------------------- |
|
||||
| `beenvoice_user` | Core user; role for admin features |
|
||||
| `beenvoice_account` | OAuth/credential accounts (better-auth) |
|
||||
| `beenvoice_session` | Sessions; unique token |
|
||||
@@ -110,7 +110,7 @@ Single file: `src/server/db/schema.ts`. Table names use `pgTableCreator` → pre
|
||||
### Domain
|
||||
|
||||
| Table | FKs | Notes |
|
||||
|-------|-----|-------|
|
||||
| ---------------------------------- | ---------------------------- | ------------------------------------- |
|
||||
| `beenvoice_client` | `createdById` → user | defaultHourlyRate, currency |
|
||||
| `beenvoice_business` | `createdById` | Resend config, `isDefault` |
|
||||
| `beenvoice_invoice` | client, business?, user | status draft/sent/paid; `publicToken` |
|
||||
@@ -167,26 +167,28 @@ API keys: format `bv_<base64url>`; stored as SHA-256 hash (`src/server/api/api-k
|
||||
Validated in `src/env.js`. See `.env.example`.
|
||||
|
||||
| Variable | Required | Notes |
|
||||
|----------|----------|-------|
|
||||
| ------------------------------------------------ | -------------------- | ------------------------------------------------------------------------------ |
|
||||
| `DATABASE_URL` | yes | PostgreSQL connection string |
|
||||
| `AUTH_SECRET` | prod | `openssl rand -base64 32` |
|
||||
| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) |
|
||||
| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL |
|
||||
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
|
||||
| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send |
|
||||
| `EMAIL_PROVIDER`, `EMAIL_FROM` | optional | `mailpit`, `smtp`, or `resend`; sender identity |
|
||||
| `SMTP_HOST`, `SMTP_PORT` | SMTP/Mailpit | SMTP endpoint (`127.0.0.1:1028` in local development) |
|
||||
| `RESEND_API_KEY`, `RESEND_DOMAIN`, `RESEND_FROM` | Resend | Production delivery credentials and verified sender |
|
||||
| `AUTHENTIK_*` | optional | OIDC SSO |
|
||||
| `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) |
|
||||
| `CRON_SECRET` | cron route | Protects `/api/cron/generate-recurring` |
|
||||
| `CRON_SECRET` | worker / cron routes | Protects worker delivery and `/api/cron/generate-recurring` |
|
||||
| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults |
|
||||
|
||||
## Docker
|
||||
|
||||
| File | Use |
|
||||
|------|-----|
|
||||
| Root `docker-compose.yml` | Deploy: `app` + `db` + Garage; use `apps/web/.env` |
|
||||
| ----------------------------- | ------------------------------------------------------------- |
|
||||
| Root `docker-compose.yml` | Deploy: `app` + `worker` + `db` + Garage; use `apps/web/.env` |
|
||||
| Root `docker-compose.dev.yml` | Local dev: Postgres + Garage |
|
||||
|
||||
The app image is built from the root `Dockerfile`. Container startup runs the web migration script and then `next start` on port 3000. Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun.
|
||||
The root `Dockerfile` has separate web and worker targets. The web container runs migrations and then `next start` on port 3000. The Bun worker uses PostgreSQL as its durable queue, polls with row locking, generates recurring invoices, and triggers idempotent scheduled invoice delivery without Redis or an external cron. User-selected wall times are converted by the client to absolute instants and stored with their IANA timezone for consistent cross-device display. Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun.
|
||||
|
||||
Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars.
|
||||
|
||||
|
||||
+19
-18
@@ -6,16 +6,16 @@ beenvoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_AC
|
||||
|
||||
Docker DNS resolves service names **only inside the same Docker network**.
|
||||
|
||||
| Setup | Does `http://garage:3900` work? |
|
||||
| Setup | Does `http://garage:<GARAGE_API_PORT>` work? |
|
||||
|-------|--------------------------------|
|
||||
| Single Compose stack (app + garage together) | Yes — Compose service name `garage` |
|
||||
| beenvoice **Application** + Garage **separate Compose** | **No** — each resource has its own network by default |
|
||||
| Application + Garage with shared destination network + correct hostname | Yes — hostname is usually **`garage-<resource-uuid>`**, not bare `garage` |
|
||||
| Application + Garage via **public domain** (`SERVICE_URL_GARAGE_3900`) | Yes — no Docker DNS needed |
|
||||
| Application + Garage via **public domain** (`SERVICE_URL_GARAGE`) | Yes — no Docker DNS needed |
|
||||
|
||||
Setting `S3_ENDPOINT=http://garage:3900` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`.
|
||||
Setting `S3_ENDPOINT=http://garage:<GARAGE_API_PORT>` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`.
|
||||
|
||||
Also avoid `http://localhost:3900` inside the app container — that points at the app itself, not Garage.
|
||||
Also avoid `http://localhost:<GARAGE_API_PORT>` inside the app container — that points at the app itself, not Garage.
|
||||
|
||||
---
|
||||
|
||||
@@ -27,9 +27,9 @@ Use this if you are **not** migrating to a single Compose stack today.
|
||||
|
||||
This is the most reliable fix when beenvoice is a Coolify **Application** (Dockerfile) and Garage is a separate Compose resource.
|
||||
|
||||
1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE_3900`) and **redeploy** the Garage resource.
|
||||
2. In the **Garage Compose resource** → assign a domain for **port 3900** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
|
||||
3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE_3900`** (e.g. `https://s3.yourdomain.com`).
|
||||
1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE`) and **redeploy** the Garage resource.
|
||||
2. In the **Garage Compose resource** → assign a domain to `GARAGE_API_PORT` (default **3900**, e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
|
||||
3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE`** (e.g. `https://s3.yourdomain.com`).
|
||||
4. On the **beenvoice Application** → Environment:
|
||||
|
||||
```env
|
||||
@@ -55,12 +55,12 @@ Use when you want S3 API traffic to stay on the Docker network.
|
||||
5. Set on beenvoice Application:
|
||||
|
||||
```env
|
||||
S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
|
||||
S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:<GARAGE_API_PORT>
|
||||
```
|
||||
|
||||
Example: resource UUID `k8w2o0g4s0g8` → `S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`.
|
||||
Example with the default port and resource UUID `k8w2o0g4s0g8`: `S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`.
|
||||
|
||||
**Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:3900` fails, use the `garage-<uuid>` form or Path A).
|
||||
**Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:<GARAGE_API_PORT>` fails, use the `garage-<uuid>` form or Path A).
|
||||
|
||||
6. Match credentials and bucket:
|
||||
|
||||
@@ -79,10 +79,12 @@ Deploy the root **[`docker-compose.coolify.yml`](../../../docker-compose.coolify
|
||||
|
||||
1. Coolify → **New Resource** → **Docker Compose**
|
||||
2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
|
||||
3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, etc.
|
||||
3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, and a 64-character hexadecimal `GARAGE_RPC_SECRET` (`openssl rand -hex 32`). Garage has a valid development fallback, but production should override it.
|
||||
4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically).
|
||||
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network.
|
||||
6. Redeploy.
|
||||
5. Optionally set `APP_PORT`, `POSTGRES_PORT`, `GARAGE_API_PORT`, `GARAGE_RPC_PORT`, `GARAGE_WEB_PORT`, or `GARAGE_ADMIN_PORT`. Defaults are `3000`, `5432`, and `3900`–`3903` respectively.
|
||||
6. Only the app port is exposed to Coolify's proxy. PostgreSQL and every Garage listener remain reachable only through the stack's internal Docker network.
|
||||
7. **Do not** override `S3_ENDPOINT` — the compose file derives it from `GARAGE_API_PORT` on the shared network.
|
||||
8. Redeploy.
|
||||
|
||||
Alternative: [`docker-compose.yml`](../../../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage.
|
||||
|
||||
@@ -95,7 +97,7 @@ Alternative: [`docker-compose.yml`](../../../docker-compose.yml) works the same
|
||||
| Garage / MinIO compose | Remove after data migrated (rclone) or re-point receipts (new bucket) |
|
||||
| Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env |
|
||||
|
||||
**Migrating from MinIO:** Garage uses port **3900** (not 9000) and Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated.
|
||||
**Migrating from MinIO:** Garage defaults to port **3900** (not 9000) and uses Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated.
|
||||
|
||||
---
|
||||
|
||||
@@ -114,9 +116,8 @@ Do **not** add `networks: coolify: external: true` unless you know the exact ext
|
||||
## Checklist (Application + separate Garage)
|
||||
|
||||
- [ ] Garage stack redeployed with current `docker-compose.coolify-garage.yml`
|
||||
- [ ] **Path A:** domain on port 3900 + `S3_ENDPOINT` = `SERVICE_URL_GARAGE_3900`
|
||||
**or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-<uuid>:3900`
|
||||
- [ ] `S3_ENDPOINT` is **not** `http://garage:3900`, **not** `localhost`
|
||||
- [ ] **Path A:** domain targets `GARAGE_API_PORT` + `S3_ENDPOINT` = `SERVICE_URL_GARAGE`; **or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-<uuid>:<GARAGE_API_PORT>`
|
||||
- [ ] `S3_ENDPOINT` is not a bare `http://garage:<port>` across separate resources and is **not** `localhost`
|
||||
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match the Garage stack env
|
||||
- [ ] `S3_BUCKET` exists (Garage `--default-bucket` creates `beenvoice-receipts` on first start)
|
||||
- [ ] Redeployed beenvoice after env or network changes
|
||||
@@ -131,7 +132,7 @@ docker exec -it <beenvoice-container> sh
|
||||
wget -qO- "https://s3.yourdomain.com" || curl -sf "https://s3.yourdomain.com"
|
||||
|
||||
# Path B — internal host from S3_ENDPOINT
|
||||
wget -qO- "http://garage-<uuid>:3900" || curl -sf "http://garage-<uuid>:3900"
|
||||
wget -qO- "http://garage-<uuid>:<GARAGE_API_PORT>" || curl -sf "http://garage-<uuid>:<GARAGE_API_PORT>"
|
||||
```
|
||||
|
||||
If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `garage` in production.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS "beenvoice_background_job" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"type" varchar(100) NOT NULL,
|
||||
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"status" varchar(20) DEFAULT 'pending' NOT NULL,
|
||||
"idempotencyKey" varchar(500) NOT NULL,
|
||||
"runAt" timestamp DEFAULT now() NOT NULL,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"maxAttempts" integer DEFAULT 5 NOT NULL,
|
||||
"lockedAt" timestamp,
|
||||
"lockedBy" varchar(255),
|
||||
"lastError" text,
|
||||
"completedAt" timestamp,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "beenvoice_background_job_idempotencyKey_unique" UNIQUE("idempotencyKey")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "background_job_status_run_at_idx" ON "beenvoice_background_job" USING btree ("status", "runAt");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "background_job_type_status_idx" ON "beenvoice_background_job" USING btree ("type", "status");
|
||||
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "sentAt" timestamp with time zone;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "scheduledSendAt" timestamp with time zone;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "scheduledSendTimeZone" varchar(100);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "scheduledSendJobId" varchar(255);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "scheduledSendStatus" varchar(20);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "runAt" TYPE timestamp with time zone USING "runAt" AT TIME ZONE 'UTC';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "lockedAt" TYPE timestamp with time zone USING "lockedAt" AT TIME ZONE 'UTC';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "completedAt" TYPE timestamp with time zone USING "completedAt" AT TIME ZONE 'UTC';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "invoice_scheduled_send_at_idx" ON "beenvoice_invoice" USING btree ("scheduledSendAt");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "invoice_scheduled_send_job_idx" ON "beenvoice_invoice" USING btree ("scheduledSendJobId");
|
||||
@@ -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';
|
||||
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoDarkStorageKey" varchar(500);
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoDarkMimeType" varchar(100);
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkLightStorageKey" varchar(500);
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkLightMimeType" varchar(100);
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkDarkStorageKey" varchar(500);
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkDarkMimeType" varchar(100);
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconLightStorageKey" varchar(500);
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconLightMimeType" varchar(100);
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconDarkStorageKey" varchar(500);
|
||||
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconDarkMimeType" varchar(100);
|
||||
@@ -204,6 +204,34 @@
|
||||
"when": 1786766968000,
|
||||
"tag": "0028_enable_public_demo_password",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1786941568000,
|
||||
"tag": "0029_background_jobs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1786946793000,
|
||||
"tag": "0030_scheduled_invoice_sends",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1786950000000,
|
||||
"tag": "0031_timezone_safety",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1786975200000,
|
||||
"tag": "0032_business_brand_assets",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@beenvoice/domain": "workspace:*",
|
||||
"@beenvoice/email": "workspace:*",
|
||||
"@aws-sdk/client-s3": "3.1075.0",
|
||||
"@better-auth/expo": "1.6.19",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@fontsource-variable/playfair-display": "5.2.8",
|
||||
"@radix-ui/react-alert-dialog": "1.1.16",
|
||||
"@radix-ui/react-avatar": "1.1.12",
|
||||
"@radix-ui/react-checkbox": "1.3.4",
|
||||
|
||||
@@ -3,6 +3,13 @@ import { eq } from "drizzle-orm";
|
||||
import { getObject } from "~/lib/object-storage";
|
||||
import { db } from "~/server/db";
|
||||
import { businesses } from "~/server/db/schema";
|
||||
import {
|
||||
brandAssetKinds,
|
||||
brandAssetThemes,
|
||||
resolveBusinessBrandAsset,
|
||||
type BrandAssetKind,
|
||||
type BrandAssetTheme,
|
||||
} from "~/lib/business-branding";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -16,27 +23,57 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ businessId: string }> },
|
||||
) {
|
||||
const { businessId } = await params;
|
||||
const url = new URL(req.url);
|
||||
const requestedKind = url.searchParams.get("kind");
|
||||
const requestedTheme = url.searchParams.get("theme");
|
||||
const kind: BrandAssetKind = brandAssetKinds.includes(
|
||||
requestedKind as BrandAssetKind,
|
||||
)
|
||||
? (requestedKind as BrandAssetKind)
|
||||
: "logo";
|
||||
const theme: BrandAssetTheme = brandAssetThemes.includes(
|
||||
requestedTheme as BrandAssetTheme,
|
||||
)
|
||||
? (requestedTheme as BrandAssetTheme)
|
||||
: "light";
|
||||
|
||||
const business = await db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, businessId),
|
||||
columns: { logoStorageKey: true, logoMimeType: true },
|
||||
columns: {
|
||||
logoStorageKey: true,
|
||||
logoMimeType: true,
|
||||
logoDarkStorageKey: true,
|
||||
logoDarkMimeType: true,
|
||||
wordmarkLightStorageKey: true,
|
||||
wordmarkLightMimeType: true,
|
||||
wordmarkDarkStorageKey: true,
|
||||
wordmarkDarkMimeType: true,
|
||||
iconLightStorageKey: true,
|
||||
iconLightMimeType: true,
|
||||
iconDarkStorageKey: true,
|
||||
iconDarkMimeType: true,
|
||||
},
|
||||
});
|
||||
const asset = business
|
||||
? resolveBusinessBrandAsset(business, kind, theme)
|
||||
: null;
|
||||
|
||||
if (!business?.logoStorageKey || !business.logoMimeType) {
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
|
||||
// generation requests a rasterized copy of SVG/WebP logos via this param.
|
||||
const wantsPng =
|
||||
new URL(req.url).searchParams.get("format") === "png" &&
|
||||
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
|
||||
url.searchParams.get("format") === "png" &&
|
||||
RASTERIZABLE_MIME_TYPES.has(asset.mimeType);
|
||||
|
||||
try {
|
||||
const body = await getObject(business.logoStorageKey);
|
||||
const body = await getObject(asset.storageKey);
|
||||
|
||||
if (wantsPng) {
|
||||
const { default: sharp } = await import("sharp");
|
||||
const isSvg = business.logoMimeType === "image/svg+xml";
|
||||
const isSvg = asset.mimeType === "image/svg+xml";
|
||||
// SVG is vector: rasterize at a high density so the PNG stays crisp at
|
||||
// the size it's actually displayed (PDF header, up to ~2.2in wide).
|
||||
// withoutEnlargement only makes sense for the WebP (already-raster)
|
||||
@@ -62,7 +99,7 @@ export async function GET(
|
||||
|
||||
return new NextResponse(new Uint8Array(body), {
|
||||
headers: {
|
||||
"Content-Type": business.logoMimeType,
|
||||
"Content-Type": asset.mimeType,
|
||||
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
@@ -71,6 +108,8 @@ export async function GET(
|
||||
console.error("[business-logo] Failed to serve logo", {
|
||||
backendError: error,
|
||||
businessId,
|
||||
kind,
|
||||
theme,
|
||||
wantsPng,
|
||||
});
|
||||
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { env } from "~/env";
|
||||
import { db } from "~/server/db";
|
||||
import { generateDueRecurringInvoices } from "~/server/api/routers/recurring-invoices";
|
||||
import { scheduleDueRecurringInvoiceJobs } from "~/server/jobs/queue";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
@@ -18,6 +17,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const generated = await generateDueRecurringInvoices(db);
|
||||
return NextResponse.json({ generated });
|
||||
const result = await scheduleDueRecurringInvoiceJobs();
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await Promise.race([
|
||||
db.execute(sql`select 1`),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Database readiness timed out")),
|
||||
2_000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
return NextResponse.json(
|
||||
{ status: "ok", database: "ready" },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ status: "unavailable", database: "unavailable" },
|
||||
{ status: 503, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,16 @@ export async function GET(
|
||||
taxId: true,
|
||||
logoStorageKey: true,
|
||||
logoMimeType: true,
|
||||
logoDarkStorageKey: true,
|
||||
logoDarkMimeType: true,
|
||||
wordmarkLightStorageKey: true,
|
||||
wordmarkLightMimeType: true,
|
||||
wordmarkDarkStorageKey: true,
|
||||
wordmarkDarkMimeType: true,
|
||||
iconLightStorageKey: true,
|
||||
iconLightMimeType: true,
|
||||
iconDarkStorageKey: true,
|
||||
iconDarkMimeType: true,
|
||||
hideNameWithLogo: true,
|
||||
},
|
||||
},
|
||||
@@ -52,8 +62,14 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
|
||||
return NextResponse.json({ error: "This link has expired" }, { status: 410 });
|
||||
if (
|
||||
invoice.publicTokenExpiresAt &&
|
||||
new Date(invoice.publicTokenExpiresAt) < new Date()
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "This link has expired" },
|
||||
{ status: 410 },
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await db.query.platformSettings.findFirst({
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { db } from "~/server/db";
|
||||
import { invoices } from "~/server/db/schema";
|
||||
import { deliverInvoiceEmail } from "~/server/services/send-invoice-email";
|
||||
|
||||
const requestSchema = z.object({
|
||||
jobId: z.string().min(1),
|
||||
idempotencyKey: z.string().min(1),
|
||||
invoiceId: z.string().min(1),
|
||||
actorUserId: z.string().min(1),
|
||||
customSubject: z.string().optional(),
|
||||
customContent: z.string().optional(),
|
||||
customMessage: z.string().optional(),
|
||||
useHtml: z.boolean().optional(),
|
||||
ccEmails: z.string().optional(),
|
||||
bccEmails: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!env.CRON_SECRET) {
|
||||
return NextResponse.json(
|
||||
{ error: "Worker secret is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
if (request.headers.get("authorization") !== `Bearer ${env.CRON_SECRET}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid scheduled send payload" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
const [claimed] = await db
|
||||
.update(invoices)
|
||||
.set({ scheduledSendStatus: "processing", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.id, input.invoiceId),
|
||||
eq(invoices.scheduledSendJobId, input.jobId),
|
||||
eq(invoices.scheduledSendStatus, "pending"),
|
||||
),
|
||||
)
|
||||
.returning({ id: invoices.id });
|
||||
|
||||
if (!claimed) {
|
||||
const existing = await db.query.invoices.findFirst({
|
||||
where: and(
|
||||
eq(invoices.id, input.invoiceId),
|
||||
eq(invoices.scheduledSendJobId, input.jobId),
|
||||
),
|
||||
columns: { scheduledSendStatus: true },
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
!["processing", "completed"].includes(existing.scheduledSendStatus ?? "")
|
||||
) {
|
||||
return NextResponse.json({ skipped: true });
|
||||
}
|
||||
if (existing.scheduledSendStatus === "completed") {
|
||||
return NextResponse.json({ success: true, alreadyCompleted: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deliverInvoiceEmail({
|
||||
...input,
|
||||
scheduledJobId: input.jobId,
|
||||
baseUrl: getAppUrl(),
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Scheduled invoice send failed";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type ToolResult = {
|
||||
type McpCaller = ReturnType<typeof createCaller>;
|
||||
|
||||
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 invoiceStatus = z.enum(["draft", "sent", "paid"]);
|
||||
const paymentMethod = z.enum([
|
||||
@@ -26,7 +27,7 @@ const paymentMethod = z.enum([
|
||||
]);
|
||||
|
||||
const invoiceItemSchema = z.object({
|
||||
date: dateString,
|
||||
date: calendarDateString,
|
||||
description: z.string().min(1),
|
||||
hours: z.number().min(0),
|
||||
rate: z.number().min(0),
|
||||
@@ -68,8 +69,8 @@ const invoiceCreateSchema = z.object({
|
||||
invoicePrefix: z.string().optional(),
|
||||
businessId: emptyableString,
|
||||
clientId: z.string().min(1),
|
||||
issueDate: dateString,
|
||||
dueDate: dateString,
|
||||
issueDate: calendarDateString,
|
||||
dueDate: calendarDateString,
|
||||
status: invoiceStatus.default("draft"),
|
||||
notes: emptyableString,
|
||||
emailMessage: emptyableString,
|
||||
@@ -83,7 +84,7 @@ const invoiceUpdateSchema = invoiceCreateSchema.partial().extend({
|
||||
});
|
||||
|
||||
const expenseCreateSchema = z.object({
|
||||
date: dateString,
|
||||
date: calendarDateString,
|
||||
description: z.string().min(1),
|
||||
amount: z.number().min(0),
|
||||
currency: z.string().length(3).default("USD"),
|
||||
@@ -97,7 +98,9 @@ const expenseCreateSchema = z.object({
|
||||
invoiceId: z.string().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const expenseUpdateSchema = expenseCreateSchema.partial().extend({ id: z.string() });
|
||||
const expenseUpdateSchema = expenseCreateSchema
|
||||
.partial()
|
||||
.extend({ id: z.string() });
|
||||
|
||||
const recurringItemSchema = z.object({
|
||||
description: z.string().min(1),
|
||||
@@ -116,6 +119,9 @@ const recurringCreateSchema = z.object({
|
||||
currency: z.string().length(3).default("USD"),
|
||||
notes: 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),
|
||||
});
|
||||
|
||||
@@ -149,10 +155,17 @@ const jsonSchemas = {
|
||||
properties: {
|
||||
invoiceId: { type: "string" },
|
||||
amount: { type: "number", exclusiveMinimum: 0 },
|
||||
date: { type: "string", format: "date-time" },
|
||||
date: { type: "string", format: "date" },
|
||||
method: {
|
||||
type: "string",
|
||||
enum: ["cash", "check", "bank_transfer", "credit_card", "paypal", "other"],
|
||||
enum: [
|
||||
"cash",
|
||||
"check",
|
||||
"bank_transfer",
|
||||
"credit_card",
|
||||
"paypal",
|
||||
"other",
|
||||
],
|
||||
},
|
||||
notes: { type: "string", maxLength: 500 },
|
||||
},
|
||||
@@ -184,11 +197,17 @@ const jsonSchemas = {
|
||||
invoicePrefix: { type: "string" },
|
||||
businessId: { type: "string" },
|
||||
clientId: { type: "string", minLength: 1 },
|
||||
issueDate: { type: "string", format: "date-time" },
|
||||
dueDate: { type: "string", format: "date-time" },
|
||||
issueDate: { type: "string", format: "date" },
|
||||
dueDate: { type: "string", format: "date" },
|
||||
status: { type: "string", enum: ["draft", "sent", "paid"] },
|
||||
notes: { 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 },
|
||||
currency: { type: "string", minLength: 3, maxLength: 3 },
|
||||
items: {
|
||||
@@ -197,7 +216,7 @@ const jsonSchemas = {
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
date: { type: "string", format: "date-time" },
|
||||
date: { type: "string", format: "date" },
|
||||
description: { type: "string", minLength: 1 },
|
||||
hours: { type: "number", minimum: 0 },
|
||||
rate: { type: "number", minimum: 0 },
|
||||
@@ -234,11 +253,24 @@ const jsonSchemas = {
|
||||
expenseCreate: {
|
||||
type: "object",
|
||||
properties: {
|
||||
date: { type: "string", format: "date-time" },
|
||||
date: { type: "string", format: "date" },
|
||||
description: { type: "string", minLength: 1 },
|
||||
amount: { type: "number", minimum: 0 },
|
||||
currency: { type: "string", minLength: 3, maxLength: 3 },
|
||||
category: { type: "string", enum: ["Travel", "Meals & Entertainment", "Software & Subscriptions", "Hardware & Equipment", "Office Supplies", "Marketing", "Professional Services", "Utilities", "Other"] },
|
||||
category: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"Travel",
|
||||
"Meals & Entertainment",
|
||||
"Software & Subscriptions",
|
||||
"Hardware & Equipment",
|
||||
"Office Supplies",
|
||||
"Marketing",
|
||||
"Professional Services",
|
||||
"Utilities",
|
||||
"Other",
|
||||
],
|
||||
},
|
||||
billable: { type: "boolean" },
|
||||
reimbursable: { type: "boolean" },
|
||||
taxDeductible: { type: "boolean" },
|
||||
@@ -267,7 +299,10 @@ const jsonSchemas = {
|
||||
name: { type: "string", minLength: 1, maxLength: 255 },
|
||||
clientId: { type: "string", minLength: 1 },
|
||||
businessId: { type: "string" },
|
||||
schedule: { type: "string", enum: ["weekly", "biweekly", "monthly", "quarterly", "yearly"] },
|
||||
schedule: {
|
||||
type: "string",
|
||||
enum: ["weekly", "biweekly", "monthly", "quarterly", "yearly"],
|
||||
},
|
||||
invoicePrefix: { type: "string" },
|
||||
taxRate: { type: "number", minimum: 0, maximum: 100 },
|
||||
currency: { type: "string", minLength: 3, maxLength: 3 },
|
||||
@@ -289,7 +324,7 @@ const jsonSchemas = {
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["name", "clientId", "schedule", "items"],
|
||||
required: ["name", "clientId", "schedule", "nextRunLocal", "items"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
invoiceSend: {
|
||||
@@ -298,15 +333,50 @@ const jsonSchemas = {
|
||||
invoiceId: { type: "string" },
|
||||
customSubject: { type: "string" },
|
||||
customMessage: { type: "string" },
|
||||
ccEmails: { type: "string", description: "Comma-separated CC email addresses" },
|
||||
bccEmails: { type: "string", description: "Comma-separated BCC email addresses" },
|
||||
ccEmails: {
|
||||
type: "string",
|
||||
description: "Comma-separated CC email addresses",
|
||||
},
|
||||
bccEmails: {
|
||||
type: "string",
|
||||
description: "Comma-separated BCC email addresses",
|
||||
},
|
||||
},
|
||||
required: ["invoiceId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
invoiceScheduleSend: {
|
||||
type: "object",
|
||||
properties: {
|
||||
invoiceId: { type: "string" },
|
||||
scheduledAt: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
description: "Absolute ISO 8601 send time, including its UTC offset",
|
||||
},
|
||||
timeZone: {
|
||||
type: "string",
|
||||
description: "IANA timezone used to choose and display the send time",
|
||||
},
|
||||
customSubject: { type: "string" },
|
||||
customMessage: { type: "string" },
|
||||
ccEmails: {
|
||||
type: "string",
|
||||
description: "Comma-separated CC email addresses",
|
||||
},
|
||||
bccEmails: {
|
||||
type: "string",
|
||||
description: "Comma-separated BCC email addresses",
|
||||
},
|
||||
},
|
||||
required: ["invoiceId", "scheduledAt", "timeZone"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
bulkIds: {
|
||||
type: "object",
|
||||
properties: { ids: { type: "array", items: { type: "string" }, minItems: 1 } },
|
||||
properties: {
|
||||
ids: { type: "array", items: { type: "string" }, minItems: 1 },
|
||||
},
|
||||
required: ["ids"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -353,10 +423,30 @@ function parseDate(value: string, fieldName: string) {
|
||||
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>[]) {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
date: parseDate(item.date, "item.date"),
|
||||
date: parseCalendarDate(item.date, "item.date"),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -368,19 +458,27 @@ function textResult(data: unknown): ToolResult {
|
||||
|
||||
const tools = {
|
||||
invoices_list: defineTool({
|
||||
description: "List invoices for the authenticated user. Optionally filter by status ('draft', 'sent', or 'paid') and/or clientId.",
|
||||
description:
|
||||
"List invoices for the authenticated user. Optionally filter by status ('draft', 'sent', or 'paid') and/or clientId.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["draft", "sent", "paid"], description: "Filter by invoice status" },
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["draft", "sent", "paid"],
|
||||
description: "Filter by invoice status",
|
||||
},
|
||||
clientId: { type: "string", description: "Filter by client ID" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
schema: z.object({
|
||||
schema: z
|
||||
.object({
|
||||
status: z.enum(["draft", "sent", "paid"]).optional(),
|
||||
clientId: z.string().optional(),
|
||||
}).optional().default({}),
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
handler: async (input, caller) => caller.invoices.getAll(input ?? {}),
|
||||
}),
|
||||
invoices_get: defineTool({
|
||||
@@ -396,8 +494,8 @@ const tools = {
|
||||
handler: async (input, caller) =>
|
||||
caller.invoices.create({
|
||||
...input,
|
||||
issueDate: parseDate(input.issueDate, "issueDate"),
|
||||
dueDate: parseDate(input.dueDate, "dueDate"),
|
||||
issueDate: parseCalendarDate(input.issueDate, "issueDate"),
|
||||
dueDate: parseCalendarDate(input.dueDate, "dueDate"),
|
||||
items: parseInvoiceItems(input.items),
|
||||
}),
|
||||
}),
|
||||
@@ -416,9 +514,11 @@ const tools = {
|
||||
caller.invoices.update({
|
||||
...input,
|
||||
issueDate: input.issueDate
|
||||
? parseDate(input.issueDate, "issueDate")
|
||||
? parseCalendarDate(input.issueDate, "issueDate")
|
||||
: undefined,
|
||||
dueDate: input.dueDate
|
||||
? parseCalendarDate(input.dueDate, "dueDate")
|
||||
: undefined,
|
||||
dueDate: input.dueDate ? parseDate(input.dueDate, "dueDate") : undefined,
|
||||
items: input.items ? parseInvoiceItems(input.items) : undefined,
|
||||
}),
|
||||
}),
|
||||
@@ -446,14 +546,14 @@ const tools = {
|
||||
schema: z.object({
|
||||
invoiceId: z.string(),
|
||||
amount: z.number().positive(),
|
||||
date: dateString,
|
||||
date: calendarDateString,
|
||||
method: paymentMethod.default("other"),
|
||||
notes: z.string().max(500).optional(),
|
||||
}),
|
||||
handler: async (input, caller) =>
|
||||
caller.payments.create({
|
||||
...input,
|
||||
date: parseDate(input.date, "date"),
|
||||
date: parseCalendarDate(input.date, "date"),
|
||||
}),
|
||||
}),
|
||||
payments_delete: defineTool({
|
||||
@@ -485,7 +585,10 @@ const tools = {
|
||||
inputSchema: {
|
||||
...jsonSchemas.clientCreate,
|
||||
required: ["id"],
|
||||
properties: { id: { type: "string" }, ...jsonSchemas.clientCreate.properties },
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
...jsonSchemas.clientCreate.properties,
|
||||
},
|
||||
},
|
||||
schema: clientCreateSchema.partial().extend({ id: z.string() }),
|
||||
handler: async (input, caller) => caller.clients.update(input),
|
||||
@@ -521,7 +624,8 @@ const tools = {
|
||||
handler: async (input, caller) => caller.businesses.create(input),
|
||||
}),
|
||||
businesses_update: defineTool({
|
||||
description: "Update a business profile. All business fields should be provided.",
|
||||
description:
|
||||
"Update a business profile. All business fields should be provided.",
|
||||
inputSchema: {
|
||||
...jsonSchemas.businessCreate,
|
||||
required: ["id", "name"],
|
||||
@@ -553,9 +657,18 @@ const tools = {
|
||||
properties: {
|
||||
description: { type: "string", maxLength: 500 },
|
||||
clientId: { type: "string" },
|
||||
invoiceId: { type: "string", description: "Link this timer to a specific invoice. On clock-out, time is added directly to this invoice." },
|
||||
invoiceId: {
|
||||
type: "string",
|
||||
description:
|
||||
"Link this timer to a specific invoice. On clock-out, time is added directly to this invoice.",
|
||||
},
|
||||
rate: { type: "number", minimum: 0 },
|
||||
startedAt: { type: "string", format: "date-time", description: "Optional backdated start time (ISO 8601). Defaults to now." },
|
||||
startedAt: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
description:
|
||||
"Optional backdated start time (ISO 8601). Defaults to now.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -569,7 +682,9 @@ const tools = {
|
||||
handler: async (input, caller) =>
|
||||
caller.timeEntries.clockIn({
|
||||
...input,
|
||||
startedAt: input.startedAt ? parseDate(input.startedAt, "startedAt") : undefined,
|
||||
startedAt: input.startedAt
|
||||
? parseDate(input.startedAt, "startedAt")
|
||||
: undefined,
|
||||
}),
|
||||
}),
|
||||
time_clock_out: defineTool({
|
||||
@@ -588,8 +703,13 @@ const tools = {
|
||||
handler: async (input, caller) => caller.timeEntries.clockOut(input),
|
||||
}),
|
||||
time_get_running: defineTool({
|
||||
description: "Get the currently running timer, if any. Returns null if no timer is running.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
description:
|
||||
"Get the currently running timer, if any. Returns null if no timer is running.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
schema: z.object({}).optional().default({}),
|
||||
handler: async (_input, caller) => caller.timeEntries.getRunning(),
|
||||
}),
|
||||
@@ -646,11 +766,14 @@ const tools = {
|
||||
caller.timeEntries.create({
|
||||
...input,
|
||||
startedAt: parseDate(input.startedAt, "startedAt"),
|
||||
endedAt: input.endedAt ? parseDate(input.endedAt, "endedAt") : undefined,
|
||||
endedAt: input.endedAt
|
||||
? parseDate(input.endedAt, "endedAt")
|
||||
: undefined,
|
||||
}),
|
||||
}),
|
||||
time_entries_update: defineTool({
|
||||
description: "Update an existing time entry by ID. All fields are optional except id.",
|
||||
description:
|
||||
"Update an existing time entry by ID. All fields are optional except id.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -679,8 +802,12 @@ const tools = {
|
||||
handler: async (input, caller) =>
|
||||
caller.timeEntries.update({
|
||||
...input,
|
||||
startedAt: input.startedAt ? parseDate(input.startedAt, "startedAt") : undefined,
|
||||
endedAt: input.endedAt ? parseDate(input.endedAt, "endedAt") : undefined,
|
||||
startedAt: input.startedAt
|
||||
? parseDate(input.startedAt, "startedAt")
|
||||
: undefined,
|
||||
endedAt: input.endedAt
|
||||
? parseDate(input.endedAt, "endedAt")
|
||||
: undefined,
|
||||
}),
|
||||
}),
|
||||
time_entries_delete: defineTool({
|
||||
@@ -712,7 +839,8 @@ const tools = {
|
||||
}),
|
||||
// ── Expenses ────────────────────────────────────────────────────────────────
|
||||
expenses_list: defineTool({
|
||||
description: "List all expenses for the authenticated user, ordered by date descending.",
|
||||
description:
|
||||
"List all expenses for the authenticated user, ordered by date descending.",
|
||||
inputSchema: jsonSchemas.empty,
|
||||
schema: z.object({}).optional().default({}),
|
||||
handler: async (_input, caller) => caller.expenses.getAll(),
|
||||
@@ -724,27 +852,32 @@ const tools = {
|
||||
handler: async (input, caller) => caller.expenses.getById(input),
|
||||
}),
|
||||
expenses_create: defineTool({
|
||||
description: "Create an expense. Category must be one of the allowed values. Set billable=true if this will be charged to a client, taxDeductible=true for tax purposes.",
|
||||
description:
|
||||
"Create an expense. Category must be one of the allowed values. Set billable=true if this will be charged to a client, taxDeductible=true for tax purposes.",
|
||||
inputSchema: jsonSchemas.expenseCreate,
|
||||
schema: expenseCreateSchema,
|
||||
handler: async (input, caller) =>
|
||||
caller.expenses.create({
|
||||
...input,
|
||||
date: parseDate(input.date, "date"),
|
||||
date: parseCalendarDate(input.date, "date"),
|
||||
}),
|
||||
}),
|
||||
expenses_update: defineTool({
|
||||
description: "Update an existing expense by ID. All fields are optional except id.",
|
||||
description:
|
||||
"Update an existing expense by ID. All fields are optional except id.",
|
||||
inputSchema: {
|
||||
...jsonSchemas.expenseCreate,
|
||||
required: ["id"],
|
||||
properties: { id: { type: "string" }, ...jsonSchemas.expenseCreate.properties },
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
...jsonSchemas.expenseCreate.properties,
|
||||
},
|
||||
},
|
||||
schema: expenseUpdateSchema,
|
||||
handler: async (input, caller) =>
|
||||
caller.expenses.update({
|
||||
...input,
|
||||
date: input.date ? parseDate(input.date, "date") : undefined,
|
||||
date: input.date ? parseCalendarDate(input.date, "date") : undefined,
|
||||
}),
|
||||
}),
|
||||
expenses_delete: defineTool({
|
||||
@@ -756,13 +889,15 @@ const tools = {
|
||||
|
||||
// ── Recurring Invoices ───────────────────────────────────────────────────────
|
||||
recurring_list: defineTool({
|
||||
description: "List all recurring invoice templates for the authenticated user, ordered by next due date.",
|
||||
description:
|
||||
"List all recurring invoice templates for the authenticated user, ordered by next due date.",
|
||||
inputSchema: jsonSchemas.empty,
|
||||
schema: z.object({}).optional().default({}),
|
||||
handler: async (_input, caller) => caller.recurringInvoices.getAll(),
|
||||
}),
|
||||
recurring_create: defineTool({
|
||||
description: "Create a recurring invoice template. Invoices will be auto-generated on the given schedule. Items are line item templates (description, hours, rate).",
|
||||
description:
|
||||
"Create a recurring invoice template. Invoices will be auto-generated on the given schedule. Items are line item templates (description, hours, rate).",
|
||||
inputSchema: jsonSchemas.recurringCreate,
|
||||
schema: recurringCreateSchema,
|
||||
handler: async (input, caller) => caller.recurringInvoices.create(input),
|
||||
@@ -771,14 +906,18 @@ const tools = {
|
||||
description: "Update a recurring invoice template. Replaces all items.",
|
||||
inputSchema: {
|
||||
...jsonSchemas.recurringCreate,
|
||||
required: ["id", "name", "clientId", "schedule", "items"],
|
||||
properties: { id: { type: "string" }, ...jsonSchemas.recurringCreate.properties },
|
||||
required: ["id", "name", "clientId", "schedule", "nextRunLocal", "items"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
...jsonSchemas.recurringCreate.properties,
|
||||
},
|
||||
},
|
||||
schema: recurringUpdateSchema,
|
||||
handler: async (input, caller) => caller.recurringInvoices.update(input),
|
||||
}),
|
||||
recurring_pause: defineTool({
|
||||
description: "Pause a recurring invoice template. No invoices will be generated until resumed.",
|
||||
description:
|
||||
"Pause a recurring invoice template. No invoices will be generated until resumed.",
|
||||
inputSchema: jsonSchemas.id,
|
||||
schema: z.object({ id: z.string() }),
|
||||
handler: async (input, caller) => caller.recurringInvoices.pause(input),
|
||||
@@ -790,10 +929,12 @@ const tools = {
|
||||
handler: async (input, caller) => caller.recurringInvoices.resume(input),
|
||||
}),
|
||||
recurring_generate_now: defineTool({
|
||||
description: "Immediately generate a draft invoice from a recurring template, regardless of schedule. Returns the new invoice ID.",
|
||||
description:
|
||||
"Immediately generate a draft invoice from a recurring template, regardless of schedule. Returns the new invoice ID.",
|
||||
inputSchema: jsonSchemas.id,
|
||||
schema: z.object({ id: z.string() }),
|
||||
handler: async (input, caller) => caller.recurringInvoices.generateNow(input),
|
||||
handler: async (input, caller) =>
|
||||
caller.recurringInvoices.generateNow(input),
|
||||
}),
|
||||
recurring_delete: defineTool({
|
||||
description: "Delete a recurring invoice template by ID.",
|
||||
@@ -804,7 +945,8 @@ const tools = {
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────────────────────
|
||||
dashboard_get_stats: defineTool({
|
||||
description: "Get a business overview: total revenue (paid invoices), pending amount (sent/overdue invoices), overdue invoice count, total clients, month-over-month revenue change percentage, 6-month revenue chart data, and 5 most recent invoices.",
|
||||
description:
|
||||
"Get a business overview: total revenue (paid invoices), pending amount (sent/overdue invoices), overdue invoice count, total clients, month-over-month revenue change percentage, 6-month revenue chart data, and 5 most recent invoices.",
|
||||
inputSchema: jsonSchemas.empty,
|
||||
schema: z.object({}).optional().default({}),
|
||||
handler: async (_input, caller) => caller.dashboard.getStats(),
|
||||
@@ -812,13 +954,15 @@ const tools = {
|
||||
|
||||
// ── Invoice extras ───────────────────────────────────────────────────────────
|
||||
invoices_get_current_open: defineTool({
|
||||
description: "Get the most recent draft invoice for the authenticated user. Useful for quickly finding the active working invoice.",
|
||||
description:
|
||||
"Get the most recent draft invoice for the authenticated user. Useful for quickly finding the active working invoice.",
|
||||
inputSchema: jsonSchemas.empty,
|
||||
schema: z.object({}).optional().default({}),
|
||||
handler: async (_input, caller) => caller.invoices.getCurrentOpen(),
|
||||
}),
|
||||
invoices_send: defineTool({
|
||||
description: "Send an invoice to the client via email with a PDF attachment. Updates the invoice status to 'sent'. Requires email to be configured (Resend API key on the business or platform).",
|
||||
description:
|
||||
"Send an invoice to the client via email with a PDF attachment. Updates the invoice status to 'sent'. Requires email to be configured (Resend API key on the business or platform).",
|
||||
inputSchema: jsonSchemas.invoiceSend,
|
||||
schema: z.object({
|
||||
invoiceId: z.string(),
|
||||
@@ -827,15 +971,44 @@ const tools = {
|
||||
ccEmails: z.string().optional(),
|
||||
bccEmails: z.string().optional(),
|
||||
}),
|
||||
handler: async (input, caller) => caller.email.sendInvoice({ ...input, useHtml: false }),
|
||||
handler: async (input, caller) =>
|
||||
caller.email.sendInvoice({ ...input, useHtml: false }),
|
||||
}),
|
||||
invoices_schedule_send: defineTool({
|
||||
description:
|
||||
"Schedule an invoice email for a future absolute time. The IANA timezone is retained for consistent display; the worker performs idempotent delivery.",
|
||||
inputSchema: jsonSchemas.invoiceScheduleSend,
|
||||
schema: z.object({
|
||||
invoiceId: z.string(),
|
||||
scheduledAt: z.coerce.date(),
|
||||
timeZone: z.string(),
|
||||
customSubject: z.string().optional(),
|
||||
customMessage: z.string().optional(),
|
||||
ccEmails: z.string().optional(),
|
||||
bccEmails: z.string().optional(),
|
||||
}),
|
||||
handler: async (input, caller) =>
|
||||
caller.email.scheduleInvoice({ ...input, useHtml: false }),
|
||||
}),
|
||||
invoices_cancel_scheduled_send: defineTool({
|
||||
description:
|
||||
"Cancel a pending scheduled invoice email before the worker starts it.",
|
||||
inputSchema: jsonSchemas.id,
|
||||
schema: z.object({ id: z.string() }),
|
||||
handler: async (input, caller) =>
|
||||
caller.email.cancelScheduledInvoice({ invoiceId: input.id }),
|
||||
}),
|
||||
invoices_send_reminder: defineTool({
|
||||
description: "Send a payment reminder email to the client for a sent or overdue invoice.",
|
||||
description:
|
||||
"Send a payment reminder email to the client for a sent or overdue invoice.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
customMessage: { type: "string", description: "Optional custom message to include in the reminder" },
|
||||
customMessage: {
|
||||
type: "string",
|
||||
description: "Optional custom message to include in the reminder",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
@@ -844,17 +1017,26 @@ const tools = {
|
||||
handler: async (input, caller) => caller.invoices.sendReminder(input),
|
||||
}),
|
||||
invoices_generate_public_token: defineTool({
|
||||
description: "Generate a shareable public link for an invoice. Returns a web view URL (/i/{token}) and a direct PDF URL (/api/i/{token}/pdf). Set ttlHours to make the link expire automatically (e.g. 24 for a 24-hour preview link). Omit ttlHours for a permanent link.",
|
||||
description:
|
||||
"Generate a shareable public link for an invoice. Returns a web view URL (/i/{token}) and a direct PDF URL (/api/i/{token}/pdf). Set ttlHours to make the link expire automatically (e.g. 24 for a 24-hour preview link). Omit ttlHours for a permanent link.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
ttlHours: { type: "number", exclusiveMinimum: 0, description: "Hours until the link expires. Omit for a permanent link." },
|
||||
ttlHours: {
|
||||
type: "number",
|
||||
exclusiveMinimum: 0,
|
||||
description:
|
||||
"Hours until the link expires. Omit for a permanent link.",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
ttlHours: z.number().positive().optional(),
|
||||
}),
|
||||
handler: async (input, caller) => {
|
||||
const result = await caller.invoices.generatePublicToken(input);
|
||||
const base = getAppUrl();
|
||||
@@ -866,7 +1048,8 @@ const tools = {
|
||||
},
|
||||
}),
|
||||
invoices_revoke_public_token: defineTool({
|
||||
description: "Revoke the public shareable link for an invoice, making it inaccessible without authentication.",
|
||||
description:
|
||||
"Revoke the public shareable link for an invoice, making it inaccessible without authentication.",
|
||||
inputSchema: jsonSchemas.id,
|
||||
schema: z.object({ id: z.string() }),
|
||||
handler: async (input, caller) => caller.invoices.revokePublicToken(input),
|
||||
@@ -889,13 +1072,15 @@ const tools = {
|
||||
|
||||
// ── Invoice Templates ────────────────────────────────────────────────────────
|
||||
templates_list: defineTool({
|
||||
description: "List all saved invoice templates (notes and terms). Use these to populate invoice notes/terms fields.",
|
||||
description:
|
||||
"List all saved invoice templates (notes and terms). Use these to populate invoice notes/terms fields.",
|
||||
inputSchema: jsonSchemas.empty,
|
||||
schema: z.object({}).optional().default({}),
|
||||
handler: async (_input, caller) => caller.invoiceTemplates.getAll(),
|
||||
}),
|
||||
templates_list_by_type: defineTool({
|
||||
description: "List invoice templates filtered by type: 'notes' for invoice notes, 'terms' for payment terms.",
|
||||
description:
|
||||
"List invoice templates filtered by type: 'notes' for invoice notes, 'terms' for payment terms.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { type: { type: "string", enum: ["notes", "terms"] } },
|
||||
@@ -906,7 +1091,8 @@ const tools = {
|
||||
handler: async (input, caller) => caller.invoiceTemplates.getByType(input),
|
||||
}),
|
||||
templates_create: defineTool({
|
||||
description: "Create an invoice template. Set isDefault=true to automatically apply this template to new invoices of this type.",
|
||||
description:
|
||||
"Create an invoice template. Set isDefault=true to automatically apply this template to new invoices of this type.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -958,20 +1144,31 @@ const tools = {
|
||||
|
||||
// ── Business email config ─────────────────────────────────────────────────────
|
||||
businesses_get_email_config: defineTool({
|
||||
description: "Get the email configuration for a business (Resend domain, from-name, and whether an API key is set). The API key itself is never returned.",
|
||||
description:
|
||||
"Get the email configuration for a business (Resend domain, from-name, and whether an API key is set). The API key itself is never returned.",
|
||||
inputSchema: jsonSchemas.id,
|
||||
schema: z.object({ id: z.string() }),
|
||||
handler: async (input, caller) => caller.businesses.getEmailConfig(input),
|
||||
}),
|
||||
businesses_update_email_config: defineTool({
|
||||
description: "Configure custom email sending for a business via Resend. Set resendApiKey and resendDomain to send invoices from your own domain. Set emailFromName for the sender display name.",
|
||||
description:
|
||||
"Configure custom email sending for a business via Resend. Set resendApiKey and resendDomain to send invoices from your own domain. Set emailFromName for the sender display name.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
resendApiKey: { type: "string", description: "Resend API key (re_...)" },
|
||||
resendDomain: { type: "string", description: "Verified Resend sending domain (e.g. mail.example.com)" },
|
||||
emailFromName: { type: "string", description: "Display name for the From field" },
|
||||
resendApiKey: {
|
||||
type: "string",
|
||||
description: "Resend API key (re_...)",
|
||||
},
|
||||
resendDomain: {
|
||||
type: "string",
|
||||
description: "Verified Resend sending domain (e.g. mail.example.com)",
|
||||
},
|
||||
emailFromName: {
|
||||
type: "string",
|
||||
description: "Display name for the From field",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
@@ -982,12 +1179,14 @@ const tools = {
|
||||
resendDomain: z.string().optional().or(z.literal("")),
|
||||
emailFromName: z.string().optional().or(z.literal("")),
|
||||
}),
|
||||
handler: async (input, caller) => caller.businesses.updateEmailConfig(input),
|
||||
handler: async (input, caller) =>
|
||||
caller.businesses.updateEmailConfig(input),
|
||||
}),
|
||||
|
||||
// ── User profile ──────────────────────────────────────────────────────────────
|
||||
profile_get: defineTool({
|
||||
description: "Get the authenticated user's profile: id, name, email, and role.",
|
||||
description:
|
||||
"Get the authenticated user's profile: id, name, email, and role.",
|
||||
inputSchema: jsonSchemas.empty,
|
||||
schema: z.object({}).optional().default({}),
|
||||
handler: async (_input, caller) => caller.settings.getProfile(),
|
||||
@@ -1045,7 +1244,12 @@ async function handleMcpRequest(request: Request) {
|
||||
|
||||
const ctx = await createTRPCContext({ headers: request.headers });
|
||||
if (!ctx.session?.user || ctx.authSource !== "api-key") {
|
||||
return rpcError(body.id, -32001, "A valid beenvoice API key is required", 401);
|
||||
return rpcError(
|
||||
body.id,
|
||||
-32001,
|
||||
"A valid beenvoice API key is required",
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
if (body.method === "initialize") {
|
||||
@@ -1084,7 +1288,12 @@ async function handleMcpRequest(request: Request) {
|
||||
|
||||
const tool = tools[params.data.name as keyof typeof tools];
|
||||
if (!tool) {
|
||||
return rpcError(body.id, -32602, `Unknown tool: ${params.data.name}`, 400);
|
||||
return rpcError(
|
||||
body.id,
|
||||
-32602,
|
||||
`Unknown tool: ${params.data.name}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const input = tool.schema.safeParse(params.data.arguments ?? {});
|
||||
@@ -1100,7 +1309,10 @@ async function handleMcpRequest(request: Request) {
|
||||
|
||||
try {
|
||||
const caller = createCaller(async () => ctx);
|
||||
return rpcResult(body.id, textResult(await tool.handler(input.data, caller)));
|
||||
return rpcResult(
|
||||
body.id,
|
||||
textResult(await tool.handler(input.data, caller)),
|
||||
);
|
||||
} catch (error) {
|
||||
return rpcError(body.id, -32000, getErrorMessage(error), 500);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { env } from "~/env";
|
||||
import { RegisterForm } from "./register-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Suspense } from "react";
|
||||
import { env } from "~/env";
|
||||
import { SignInForm } from "./signin-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<Suspense
|
||||
|
||||
@@ -46,7 +46,11 @@ export function ActiveTimerWidget({
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (running) {
|
||||
const tick = () =>
|
||||
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000));
|
||||
setElapsed(
|
||||
Math.floor(
|
||||
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
|
||||
),
|
||||
);
|
||||
tick();
|
||||
intervalRef.current = setInterval(tick, 1000);
|
||||
}
|
||||
@@ -73,7 +77,10 @@ export function ActiveTimerWidget({
|
||||
window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
||||
},
|
||||
});
|
||||
} else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") {
|
||||
} else if (
|
||||
data.outcome === "saved_no_invoice" ||
|
||||
data.outcome === "saved_no_client"
|
||||
) {
|
||||
toast.warning("Time saved", { description: message });
|
||||
} else {
|
||||
toast.success(message);
|
||||
@@ -96,12 +103,19 @@ export function ActiveTimerWidget({
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
aria-label={compact ? "Stop timer" : undefined}
|
||||
onClick={() => clockOut.mutate({})}
|
||||
disabled={clockOut.isPending}
|
||||
className={cn(compact && "h-8 px-2", className)}
|
||||
className={cn(compact && "size-8 p-0", className)}
|
||||
>
|
||||
<Square className={cn("h-3.5 w-3.5", !compact && "mr-1.5")} />
|
||||
{!compact && (clockOut.isPending ? "Stopping…" : "Stop")}
|
||||
<Square data-icon={compact ? undefined : "inline-start"} />
|
||||
{compact ? (
|
||||
<span className="sr-only">Stop timer</span>
|
||||
) : clockOut.isPending ? (
|
||||
"Stopping…"
|
||||
) : (
|
||||
"Stop"
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -133,18 +147,18 @@ export function ActiveTimerWidget({
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/dashboard/time-clock"
|
||||
className="border-primary/30 bg-primary/5 relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors hover:bg-primary/10"
|
||||
className="border-primary/30 bg-primary/5 hover:bg-primary/10 relative flex size-10 items-center justify-center rounded-xl border transition-colors"
|
||||
>
|
||||
<Clock className="text-primary h-5 w-5" />
|
||||
<span className="absolute top-1 right-1 flex h-2 w-2">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
|
||||
<Clock className="text-primary size-5" />
|
||||
<span className="absolute top-1 right-1 flex size-2">
|
||||
<span className="bg-primary absolute inline-flex size-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex size-2 rounded-full" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="bg-popover text-popover-foreground border-border max-w-56 space-y-2 border p-3 text-sm [&>svg]:bg-popover [&>svg]:fill-popover"
|
||||
className="bg-popover text-popover-foreground border-border [&>svg]:bg-popover [&>svg]:fill-popover max-w-56 space-y-2 border p-3 text-sm"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{description}
|
||||
@@ -169,10 +183,17 @@ export function ActiveTimerWidget({
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">No invoice selected</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No invoice selected
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="h-8 flex-1"
|
||||
>
|
||||
<Link href="/dashboard/time-clock">Open</Link>
|
||||
</Button>
|
||||
{renderStopButton()}
|
||||
@@ -185,58 +206,41 @@ export function ActiveTimerWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="relative mt-1 flex h-2.5 w-2.5 flex-shrink-0">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-snug font-medium">
|
||||
{description}
|
||||
{running.client && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
· {running.client.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-snug">
|
||||
{invoiceLabel ? (
|
||||
<>
|
||||
Billing to{" "}
|
||||
<Card className="border-primary/30 bg-primary/5 shadow-none">
|
||||
<CardContent className="flex flex-col gap-2.5 p-3">
|
||||
<Link
|
||||
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||
className="text-primary hover:underline"
|
||||
href="/dashboard/time-clock"
|
||||
className="focus-visible:ring-ring flex items-center justify-between gap-3 rounded-md outline-hidden focus-visible:ring-2"
|
||||
>
|
||||
{invoiceLabel}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>No invoice selected — open time clock to assign</>
|
||||
)}
|
||||
{" · "}
|
||||
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
|
||||
Time clock
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="relative flex size-2.5 shrink-0">
|
||||
<span className="bg-primary absolute inline-flex size-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex size-2.5 rounded-full" />
|
||||
</span>
|
||||
<span className="text-sm font-medium">Timer running</span>
|
||||
</span>
|
||||
<span className="text-primary shrink-0 font-mono text-sm font-bold tabular-nums">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</span>
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 w-full">
|
||||
</Link>
|
||||
|
||||
<p
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
{running.client ? ` · ${running.client.name}` : ""}
|
||||
{invoiceLabel ? ` · ${invoiceLabel}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="flex-1">
|
||||
<Link href="/dashboard/time-clock">
|
||||
<Clock className="mr-1 h-3.5 w-3.5" />
|
||||
<Clock data-icon="inline-start" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
{renderStopButton("w-full")}
|
||||
</div>
|
||||
{renderStopButton("flex-1")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
Hash,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
|
||||
import { hasBusinessBrandAsset } from "~/lib/business-branding";
|
||||
|
||||
interface BusinessDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -74,13 +76,12 @@ export default async function BusinessDetailPage({
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{business.logoStorageKey ? (
|
||||
{hasBusinessBrandAsset(business) ? (
|
||||
<div className="bg-muted border-border/40 flex h-9 max-w-32 shrink-0 items-center justify-center overflow-hidden border px-1.5 py-1">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||
<img
|
||||
src={`/api/business-logo/${business.id}`}
|
||||
alt={`${business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
<BusinessBrandImage
|
||||
business={business}
|
||||
kind="icon"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from "~/components/ui/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
|
||||
import { hasBusinessBrandAsset } from "~/lib/business-branding";
|
||||
|
||||
// Type for business data
|
||||
interface Business {
|
||||
@@ -35,6 +37,17 @@ interface Business {
|
||||
taxId: string | null;
|
||||
logoUrl: string | null;
|
||||
logoStorageKey: string | null;
|
||||
logoMimeType: string | null;
|
||||
logoDarkStorageKey: string | null;
|
||||
logoDarkMimeType: string | null;
|
||||
wordmarkLightStorageKey: string | null;
|
||||
wordmarkLightMimeType: string | null;
|
||||
wordmarkDarkStorageKey: string | null;
|
||||
wordmarkDarkMimeType: string | null;
|
||||
iconLightStorageKey: string | null;
|
||||
iconLightMimeType: string | null;
|
||||
iconDarkStorageKey: string | null;
|
||||
iconDarkMimeType: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
@@ -88,12 +101,12 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
|
||||
{business.logoStorageKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset
|
||||
<img
|
||||
src={`/api/business-logo/${business.id}`}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
{hasBusinessBrandAsset(business) ? (
|
||||
<BusinessBrandImage
|
||||
business={business}
|
||||
kind="icon"
|
||||
decorative
|
||||
className="h-full w-full"
|
||||
/>
|
||||
) : (
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
interface ClientDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -34,17 +35,19 @@ export default async function ClientDetailPage({
|
||||
const { id } = await params;
|
||||
|
||||
const client = await api.clients.getById({ id });
|
||||
const profile = await api.settings.getProfile();
|
||||
const timeZone = profile?.timeZone ?? "America/New_York";
|
||||
|
||||
if (!client) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
return formatCalendarDate(date, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
@@ -249,16 +252,19 @@ export default async function ClientDetailPage({
|
||||
getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
timeZone,
|
||||
) === "paid"
|
||||
? "default"
|
||||
: getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
timeZone,
|
||||
) === "sent"
|
||||
? "secondary"
|
||||
: getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
timeZone,
|
||||
) === "overdue"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
@@ -268,6 +274,7 @@ export default async function ClientDetailPage({
|
||||
{getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
timeZone,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||
import {
|
||||
calendarDateFromLocalDate,
|
||||
formatCalendarDate,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -66,7 +70,7 @@ interface ExpenseFormData {
|
||||
}
|
||||
|
||||
const defaultForm: ExpenseFormData = {
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
description: "",
|
||||
amount: 0,
|
||||
currency: "USD",
|
||||
@@ -473,11 +477,11 @@ export default function ExpensesPage() {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
{formatCalendarDate(expense.date, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(expense.date))}
|
||||
})}
|
||||
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||
</p>
|
||||
@@ -690,7 +694,10 @@ export default function ExpensesPage() {
|
||||
<DatePicker
|
||||
date={form.date}
|
||||
onDateChange={(d) =>
|
||||
setForm((p) => ({ ...p, date: d ?? new Date() }))
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
date: d ?? calendarDateFromLocalDate(new Date()),
|
||||
}))
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "~/components/data/data-table";
|
||||
import {
|
||||
formatLineItemDetail,
|
||||
isFixedLineItem,
|
||||
} from "~/lib/invoice-line-item";
|
||||
import { formatLineItemDetail, isFixedLineItem } from "~/lib/invoice-line-item";
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
return formatCalendarDate(date, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Building,
|
||||
CalendarClock,
|
||||
Check,
|
||||
Copy,
|
||||
DollarSign,
|
||||
@@ -19,8 +20,23 @@ import {
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DEFAULT_TIME_ZONE,
|
||||
calendarDateFromLocalDate,
|
||||
formatCalendarDate,
|
||||
formatZonedDateTime,
|
||||
toZonedDateTimeInputValue,
|
||||
zonedDateTimeToInstant,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
|
||||
import { hasBusinessBrandAsset } from "~/lib/business-branding";
|
||||
import Link from "next/link";
|
||||
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
notFound,
|
||||
useParams,
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge } from "~/components/data/status-badge";
|
||||
@@ -58,7 +74,6 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import {
|
||||
getEffectiveInvoiceStatus,
|
||||
isInvoiceOverdue,
|
||||
@@ -103,6 +118,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
||||
id: invoiceId,
|
||||
});
|
||||
const { data: profile } = api.settings.getProfile.useQuery();
|
||||
const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
|
||||
const { data: payments, isLoading: paymentsLoading } =
|
||||
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||
const utils = api.useUtils();
|
||||
@@ -194,12 +211,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
if (!invoice) notFound();
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
|
||||
new Date(date),
|
||||
);
|
||||
formatCalendarDate(date, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const formatCurrency = (amount: number, currency = invoice.currency) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
|
||||
amount,
|
||||
);
|
||||
|
||||
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
@@ -207,9 +228,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
const totalPaid = (payments ?? []).reduce((s, p) => s + p.amount, 0);
|
||||
const balanceDue = total - totalPaid;
|
||||
const storedStatus = invoice.status as StoredInvoiceStatus;
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, invoice.dueDate);
|
||||
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
|
||||
const canSendReminder = effectiveStatus === "sent" || effectiveStatus === "overdue";
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||
storedStatus,
|
||||
invoice.dueDate,
|
||||
timeZone,
|
||||
);
|
||||
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate, timeZone);
|
||||
const canSendReminder =
|
||||
effectiveStatus === "sent" || effectiveStatus === "overdue";
|
||||
|
||||
const publicUrl = invoice.publicToken
|
||||
? `${window.location.origin}/i/${invoice.publicToken}`
|
||||
@@ -231,8 +257,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
createPayment.mutate({
|
||||
invoiceId,
|
||||
amount,
|
||||
date: new Date(),
|
||||
method: paymentMethod as Parameters<typeof createPayment.mutate>[0]["method"],
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
method: paymentMethod as Parameters<
|
||||
typeof createPayment.mutate
|
||||
>[0]["method"],
|
||||
notes: paymentNotes || undefined,
|
||||
});
|
||||
};
|
||||
@@ -243,7 +271,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
title="Invoice Details"
|
||||
description="View and manage invoice information"
|
||||
>
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
||||
<PDFDownloadButton
|
||||
invoiceId={invoice.id}
|
||||
variant="outline"
|
||||
className="hover-lift"
|
||||
/>
|
||||
{storedStatus === "draft" ? (
|
||||
<Button asChild variant="default" className="hover-lift">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
@@ -270,15 +302,21 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<StatusBadge status={effectiveStatus} />
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-sm sm:space-y-0">
|
||||
<div className="sm:inline">Issued {formatDate(invoice.issueDate)}</div>
|
||||
<div className="sm:inline">
|
||||
Issued {formatDate(invoice.issueDate)}
|
||||
</div>
|
||||
<div className="sm:inline sm:before:content-['_•_']">
|
||||
Due {formatDate(invoice.dueDate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-left sm:text-right">
|
||||
<p className="text-muted-foreground text-sm">Total Amount</p>
|
||||
<p className="text-primary text-3xl font-bold">{formatCurrency(total)}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Total Amount
|
||||
</p>
|
||||
<p className="text-primary text-3xl font-bold">
|
||||
{formatCurrency(total)}
|
||||
</p>
|
||||
{totalPaid > 0 && balanceDue > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
Balance due: {formatCurrency(balanceDue)}
|
||||
@@ -300,7 +338,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<p className="font-medium">Invoice Overdue</p>
|
||||
<p className="text-sm">
|
||||
{Math.ceil(
|
||||
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
|
||||
(new Date().getTime() -
|
||||
new Date(invoice.dueDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)}{" "}
|
||||
days past due date
|
||||
@@ -321,14 +360,18 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<h3 className="text-foreground text-xl font-semibold">{invoice.client.name}</h3>
|
||||
<h3 className="text-foreground text-xl font-semibold">
|
||||
{invoice.client.name}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{invoice.client.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">{invoice.client.email}</span>
|
||||
<span className="text-sm break-all">
|
||||
{invoice.client.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.client.phone && (
|
||||
@@ -345,8 +388,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<MapPin className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{invoice.client.addressLine1 && <div>{invoice.client.addressLine1}</div>}
|
||||
{invoice.client.addressLine2 && <div>{invoice.client.addressLine2}</div>}
|
||||
{invoice.client.addressLine1 && (
|
||||
<div>{invoice.client.addressLine1}</div>
|
||||
)}
|
||||
{invoice.client.addressLine2 && (
|
||||
<div>{invoice.client.addressLine2}</div>
|
||||
)}
|
||||
{(invoice.client.city ??
|
||||
invoice.client.state ??
|
||||
invoice.client.postalCode) && (
|
||||
@@ -360,7 +407,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{invoice.client.country && <div>{invoice.client.country}</div>}
|
||||
{invoice.client.country && (
|
||||
<div>{invoice.client.country}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -377,13 +426,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.business.logoStorageKey && (
|
||||
<div className="bg-muted border-border/40 flex h-12 max-w-40 w-fit items-center justify-center overflow-hidden border px-2 py-1.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||
<img
|
||||
src={`/api/business-logo/${invoice.business.id}`}
|
||||
alt={`${invoice.business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
{hasBusinessBrandAsset(invoice.business) && (
|
||||
<div className="bg-muted border-border/40 flex h-12 w-fit max-w-40 items-center justify-center overflow-hidden border px-2 py-1.5">
|
||||
<BusinessBrandImage
|
||||
business={invoice.business}
|
||||
kind="logo"
|
||||
className="h-full max-w-36 min-w-20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -396,7 +444,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">{invoice.business.email}</span>
|
||||
<span className="text-sm break-all">
|
||||
{invoice.business.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.business.phone && (
|
||||
@@ -404,7 +454,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm">{invoice.business.phone}</span>
|
||||
<span className="text-sm">
|
||||
{invoice.business.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -437,7 +489,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<span className="whitespace-nowrap">
|
||||
{item.hours.toString()} hours
|
||||
</span>
|
||||
<span className="whitespace-nowrap">@ ${item.rate}/hr</span>
|
||||
<span className="whitespace-nowrap">
|
||||
@ ${item.rate}/hr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-primary flex-shrink-0 self-start text-lg font-semibold">
|
||||
@@ -449,15 +503,21 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
))}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-secondary rounded-lg p-4 space-y-3">
|
||||
<div className="bg-secondary space-y-3 rounded-lg p-4">
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-medium">{formatCurrency(subtotal)}</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span>
|
||||
<span className="font-medium">{formatCurrency(taxAmount)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
Tax ({invoice.taxRate}%):
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
@@ -469,14 +529,18 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<>
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-sm">
|
||||
<span className="text-muted-foreground">Paid:</span>
|
||||
<span className="text-green-600 font-medium">
|
||||
<span className="font-medium text-green-600">
|
||||
− {formatCurrency(totalPaid)}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 font-bold">
|
||||
<span>Balance Due:</span>
|
||||
<span className={balanceDue <= 0 ? "text-green-600" : "text-primary"}>
|
||||
<span
|
||||
className={
|
||||
balanceDue <= 0 ? "text-green-600" : "text-primary"
|
||||
}
|
||||
>
|
||||
{formatCurrency(Math.max(0, balanceDue))}
|
||||
</span>
|
||||
</div>
|
||||
@@ -508,7 +572,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
{paymentsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
) : (payments ?? []).length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No payments recorded yet.</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No payments recorded yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(payments ?? []).map((p) => (
|
||||
@@ -517,11 +583,17 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
className="bg-secondary flex items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold">{formatCurrency(p.amount)}</span>
|
||||
<Badge variant="secondary">{methodLabel(p.method)}</Badge>
|
||||
<span className="text-muted-foreground">{formatDate(p.date)}</span>
|
||||
<span className="font-semibold">
|
||||
{formatCurrency(p.amount)}
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{methodLabel(p.method)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(p.date)}
|
||||
</span>
|
||||
{p.notes && (
|
||||
<span className="text-muted-foreground truncate max-w-[200px]">
|
||||
<span className="text-muted-foreground max-w-[200px] truncate">
|
||||
{p.notes}
|
||||
</span>
|
||||
)}
|
||||
@@ -529,7 +601,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7 p-0 shrink-0"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7 shrink-0 p-0"
|
||||
onClick={() => deletePayment.mutate({ id: p.id })}
|
||||
disabled={deletePayment.isPending}
|
||||
>
|
||||
@@ -549,7 +621,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-foreground whitespace-pre-wrap">{invoice.notes}</p>
|
||||
<p className="text-foreground whitespace-pre-wrap">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -557,8 +631,39 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
{invoice.scheduledSendStatus === "pending" &&
|
||||
invoice.scheduledSendAt ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
Scheduled send
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{formatZonedDateTime(
|
||||
invoice.scheduledSendAt,
|
||||
invoice.scheduledSendTimeZone ?? "UTC",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{invoice.scheduledSendTimeZone ?? "UTC"}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="w-full">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/send`}>
|
||||
Manage scheduled send
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{storedStatus === "draft" && (
|
||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||
<InvoiceTimerCard
|
||||
invoiceId={invoiceId}
|
||||
clientId={invoice.clientId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card className="lg:sticky lg:top-6">
|
||||
@@ -579,7 +684,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
) : null}
|
||||
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
||||
<PDFDownloadButton
|
||||
invoiceId={invoice.id}
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveStatus === "draft" && (
|
||||
@@ -595,7 +704,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
|
||||
invoiceId={invoiceId}
|
||||
savedReminderAt={invoice.sendReminderAt}
|
||||
formatDate={formatDate}
|
||||
timeZone={timeZone}
|
||||
isSaving={updateInvoice.isPending}
|
||||
onSave={(sendReminderAt) =>
|
||||
updateInvoice.mutate({
|
||||
@@ -604,12 +713,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
})
|
||||
}
|
||||
onClear={() =>
|
||||
updateInvoice.mutate({ id: invoiceId, sendReminderAt: null })
|
||||
updateInvoice.mutate({
|
||||
id: invoiceId,
|
||||
sendReminderAt: null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||
{(effectiveStatus === "sent" ||
|
||||
effectiveStatus === "overdue") && (
|
||||
<EnhancedSendInvoiceButton
|
||||
invoiceId={invoice.id}
|
||||
className="w-full"
|
||||
@@ -632,7 +745,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
{invoice.lastReminderSentAt && (
|
||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
||||
Last sent {daysSince(invoice.lastReminderSentAt)} day
|
||||
{daysSince(invoice.lastReminderSentAt) === 1 ? "" : "s"} ago
|
||||
{daysSince(invoice.lastReminderSentAt) === 1
|
||||
? ""
|
||||
: "s"}{" "}
|
||||
ago
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -655,7 +771,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
className="h-6 w-6 shrink-0 p-0"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{copied ? (
|
||||
@@ -669,7 +785,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 w-full"
|
||||
onClick={() => revokePublicToken.mutate({ id: invoiceId })}
|
||||
onClick={() =>
|
||||
revokePublicToken.mutate({ id: invoiceId })
|
||||
}
|
||||
disabled={revokePublicToken.isPending}
|
||||
>
|
||||
<Link2Off className="mr-1.5 h-3.5 w-3.5" />
|
||||
@@ -679,13 +797,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Generate a shareable link your client can use to view this invoice without
|
||||
logging in.
|
||||
Generate a shareable link your client can use to view
|
||||
this invoice without logging in.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => generatePublicToken.mutate({ id: invoiceId })}
|
||||
onClick={() =>
|
||||
generatePublicToken.mutate({ id: invoiceId })
|
||||
}
|
||||
disabled={generatePublicToken.isPending}
|
||||
>
|
||||
{generatePublicToken.isPending ? (
|
||||
@@ -701,9 +821,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</Popover>
|
||||
|
||||
{/* Mark as Paid */}
|
||||
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||
{(effectiveStatus === "sent" ||
|
||||
effectiveStatus === "overdue") && (
|
||||
<Button
|
||||
onClick={() => updateStatus.mutate({ id: invoiceId, status: "paid" })}
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: invoiceId, status: "paid" })
|
||||
}
|
||||
disabled={updateStatus.isPending}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
@@ -779,10 +902,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRecordPaymentOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRecordPaymentOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRecordPayment} disabled={createPayment.isPending}>
|
||||
<Button
|
||||
onClick={handleRecordPayment}
|
||||
disabled={createPayment.isPending}
|
||||
>
|
||||
{createPayment.isPending ? "Saving…" : "Record Payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -823,9 +952,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
disabled={sendReminder.isPending}
|
||||
>
|
||||
{sendReminder.isPending ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending…</>
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending…
|
||||
</>
|
||||
) : (
|
||||
<><Bell className="mr-2 h-4 w-4" /> Send Reminder</>
|
||||
<>
|
||||
<Bell className="mr-2 h-4 w-4" /> Send Reminder
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -838,8 +971,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete invoice <strong>{invoice.invoiceNumber}</strong>?
|
||||
This action cannot be undone.
|
||||
Are you sure you want to delete invoice{" "}
|
||||
<strong>{invoice.invoiceNumber}</strong>? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -867,28 +1001,30 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
function SendReminderEditor({
|
||||
invoiceId,
|
||||
savedReminderAt,
|
||||
formatDate,
|
||||
timeZone,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClear,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
savedReminderAt: Date | null | undefined;
|
||||
formatDate: (date: Date) => string;
|
||||
timeZone: string;
|
||||
isSaving: boolean;
|
||||
onSave: (sendReminderAt: Date | null) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() =>
|
||||
savedReminderAt ? new Date(savedReminderAt) : undefined,
|
||||
const [sendReminderAt, setSendReminderAt] = useState(() =>
|
||||
savedReminderAt ? toZonedDateTimeInputValue(savedReminderAt, timeZone) : "",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
|
||||
<DatePicker
|
||||
date={sendReminderAt}
|
||||
onDateChange={setSendReminderAt}
|
||||
<Input
|
||||
id={`send-reminder-at-${invoiceId}`}
|
||||
type="datetime-local"
|
||||
value={sendReminderAt}
|
||||
onChange={(event) => setSendReminderAt(event.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
@@ -896,7 +1032,21 @@ function SendReminderEditor({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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}
|
||||
>
|
||||
Save reminder
|
||||
@@ -906,7 +1056,7 @@ function SendReminderEditor({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSendReminderAt(undefined);
|
||||
setSendReminderAt("");
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
@@ -918,7 +1068,7 @@ function SendReminderEditor({
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{new Date(savedReminderAt) <= new Date()
|
||||
? "Reminder is due — time to send this invoice."
|
||||
: `Scheduled for ${formatDate(savedReminderAt)}`}
|
||||
: `Scheduled for ${formatZonedDateTime(savedReminderAt, timeZone)}`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,21 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
formatZonedDateTime,
|
||||
getDefaultScheduledSendAt,
|
||||
DEFAULT_TIME_ZONE,
|
||||
toZonedDateTimeInputValue,
|
||||
zonedDateTimeToInstant,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -44,6 +59,7 @@ import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
FileText,
|
||||
CalendarClock,
|
||||
} from "lucide-react";
|
||||
|
||||
function SendEmailPageSkeleton() {
|
||||
@@ -54,7 +70,9 @@ function SendEmailPageSkeleton() {
|
||||
description="Loading invoice email"
|
||||
/>
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
|
||||
<div
|
||||
className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}
|
||||
>
|
||||
<div className="bg-muted h-96 animate-pulse" />
|
||||
</div>
|
||||
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||
@@ -101,6 +119,12 @@ export default function SendEmailPage() {
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [minimumScheduledAt, setMinimumScheduledAt] = useState("");
|
||||
const [scheduleDisambiguation, setScheduleDisambiguation] = useState<
|
||||
"earlier" | "later"
|
||||
>("earlier");
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
// Email content state
|
||||
@@ -115,9 +139,11 @@ export default function SendEmailPage() {
|
||||
api.invoices.getById.useQuery({
|
||||
id: invoiceId,
|
||||
});
|
||||
const { data: profile } = api.settings.getProfile.useQuery();
|
||||
|
||||
// Get utils for cache invalidation
|
||||
const utils = api.useUtils();
|
||||
const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
|
||||
|
||||
// Email sending mutation
|
||||
const sendEmailMutation = api.email.sendInvoice.useMutation({
|
||||
@@ -183,6 +209,31 @@ export default function SendEmailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleEmailMutation = api.email.scheduleInvoice.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
toast.success("Invoice scheduled", {
|
||||
description: `It will send ${formatZonedDateTime(data.scheduledAt, data.timeZone)}.`,
|
||||
});
|
||||
router.push(`/dashboard/invoices/${invoiceId}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Could not schedule invoice", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelScheduleMutation = api.email.cancelScheduledInvoice.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
toast.success("Scheduled send cancelled");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Could not cancel scheduled send", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Transform invoice data for components
|
||||
const invoice = useMemo(() => {
|
||||
return invoiceData
|
||||
@@ -196,6 +247,9 @@ export default function SendEmailPage() {
|
||||
taxRate: invoiceData.taxRate,
|
||||
currency: invoiceData.currency,
|
||||
emailMessage: invoiceData.emailMessage,
|
||||
scheduledSendAt: invoiceData.scheduledSendAt,
|
||||
scheduledSendTimeZone: invoiceData.scheduledSendTimeZone,
|
||||
scheduledSendStatus: invoiceData.scheduledSendStatus,
|
||||
client: invoiceData.client
|
||||
? {
|
||||
name: invoiceData.client.name,
|
||||
@@ -210,6 +264,19 @@ export default function SendEmailPage() {
|
||||
email: invoiceData.business.email,
|
||||
logoStorageKey: invoiceData.business.logoStorageKey,
|
||||
logoMimeType: invoiceData.business.logoMimeType,
|
||||
logoDarkStorageKey: invoiceData.business.logoDarkStorageKey,
|
||||
logoDarkMimeType: invoiceData.business.logoDarkMimeType,
|
||||
wordmarkLightStorageKey:
|
||||
invoiceData.business.wordmarkLightStorageKey,
|
||||
wordmarkLightMimeType:
|
||||
invoiceData.business.wordmarkLightMimeType,
|
||||
wordmarkDarkStorageKey:
|
||||
invoiceData.business.wordmarkDarkStorageKey,
|
||||
wordmarkDarkMimeType: invoiceData.business.wordmarkDarkMimeType,
|
||||
iconLightStorageKey: invoiceData.business.iconLightStorageKey,
|
||||
iconLightMimeType: invoiceData.business.iconLightMimeType,
|
||||
iconDarkStorageKey: invoiceData.business.iconDarkStorageKey,
|
||||
iconDarkMimeType: invoiceData.business.iconDarkMimeType,
|
||||
}
|
||||
: undefined,
|
||||
items: invoiceData.items?.map((item) => ({
|
||||
@@ -287,6 +354,48 @@ export default function SendEmailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmScheduleEmail = async () => {
|
||||
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 (
|
||||
Number.isNaN(sendAt.getTime()) ||
|
||||
sendAt.getTime() < Date.now() + 60_000
|
||||
) {
|
||||
toast.error("Choose a future send time", {
|
||||
description: "The scheduled time must be at least one minute from now.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await scheduleEmailMutation.mutateAsync({
|
||||
invoiceId,
|
||||
scheduledAt: sendAt,
|
||||
timeZone,
|
||||
customSubject: subject,
|
||||
customContent: emailContent,
|
||||
customMessage: normalizedCustomMessage,
|
||||
useHtml: true,
|
||||
ccEmails: ccEmail.trim() || undefined,
|
||||
bccEmails: bccEmail.trim() || undefined,
|
||||
});
|
||||
setShowScheduleDialog(false);
|
||||
} catch {
|
||||
// The mutation displays the server error.
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
if (retryCount < 2) {
|
||||
setRetryCount((prev) => prev + 1);
|
||||
@@ -348,6 +457,31 @@ export default function SendEmailPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{invoice.scheduledSendStatus === "pending" && invoice.scheduledSendAt ? (
|
||||
<Alert>
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span>
|
||||
Scheduled for{" "}
|
||||
{formatZonedDateTime(
|
||||
invoice.scheduledSendAt,
|
||||
invoice.scheduledSendTimeZone ?? timeZone,
|
||||
)}{" "}
|
||||
({invoice.scheduledSendTimeZone ?? timeZone})
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={cancelScheduleMutation.isPending}
|
||||
onClick={() => cancelScheduleMutation.mutate({ invoiceId })}
|
||||
>
|
||||
Cancel scheduled send
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className="lg:col-span-2">
|
||||
@@ -579,6 +713,27 @@ export default function SendEmailPage() {
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMinimumScheduledAt(
|
||||
toZonedDateTimeInputValue(
|
||||
new Date(Date.now() + 60_000),
|
||||
timeZone,
|
||||
),
|
||||
);
|
||||
setScheduledAt(
|
||||
toZonedDateTimeInputValue(getDefaultScheduledSendAt(), timeZone),
|
||||
);
|
||||
setShowScheduleDialog(true);
|
||||
}}
|
||||
disabled={!canSend || scheduleEmailMutation.isPending}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<CalendarClock className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Schedule</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleSendEmail}
|
||||
disabled={!canSend || isSending}
|
||||
@@ -655,6 +810,69 @@ export default function SendEmailPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showScheduleDialog} onOpenChange={setShowScheduleDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose when this invoice should be emailed to{" "}
|
||||
<strong>{toEmail}</strong>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduled-send-at">Send date and time</Label>
|
||||
<Input
|
||||
id="scheduled-send-at"
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
min={minimumScheduledAt}
|
||||
onChange={(event) => setScheduledAt(event.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Time zone: {timeZone}. The worker stores the equivalent UTC
|
||||
instant, so daylight saving changes and other devices will not
|
||||
shift this send.
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowScheduleDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void confirmScheduleEmail()}
|
||||
disabled={scheduleEmailMutation.isPending || !scheduledAt}
|
||||
>
|
||||
{scheduleEmailMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CalendarClock className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Schedule send
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { toast } from "sonner";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import { formatCurrency } from "~/lib/currency";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
@@ -81,22 +82,27 @@ interface Invoice {
|
||||
|
||||
interface InvoicesDataTableProps {
|
||||
invoices: Invoice[];
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
const getStatusType = (invoice: Invoice): StatusType =>
|
||||
const getStatusType = (invoice: Invoice, timeZone: string): StatusType =>
|
||||
getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
timeZone,
|
||||
);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
formatCalendarDate(date, {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
});
|
||||
|
||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
export function InvoicesDataTable({
|
||||
invoices,
|
||||
timeZone,
|
||||
}: InvoicesDataTableProps) {
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
||||
@@ -183,7 +189,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
||||
<StatusBadge
|
||||
status={getStatusType(invoice)}
|
||||
status={getStatusType(invoice, timeZone)}
|
||||
className="text-xs"
|
||||
/>
|
||||
<span className="text-foreground text-xs font-semibold">
|
||||
@@ -218,14 +224,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
status={getStatusType(row.original)}
|
||||
status={getStatusType(row.original, timeZone)}
|
||||
className={
|
||||
getStatusType(row.original) === "sent" ? "status-pending" : ""
|
||||
getStatusType(row.original, timeZone) === "sent"
|
||||
? "status-pending"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
),
|
||||
filterFn: (row, _id, value: string[]) =>
|
||||
value.includes(getStatusType(row.original)),
|
||||
value.includes(getStatusType(row.original, timeZone)),
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
|
||||
@@ -11,8 +11,14 @@ import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
// Invoices Table Component
|
||||
async function InvoicesTable() {
|
||||
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() {
|
||||
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
} from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
DEFAULT_TIME_ZONE,
|
||||
formatZonedDateTime,
|
||||
getDefaultScheduledSendAt,
|
||||
toZonedDateTimeInputValue,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
const SCHEDULES = [
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
@@ -66,10 +72,13 @@ interface RecurringFormState {
|
||||
currency: string;
|
||||
notes: string;
|
||||
emailMessage: string;
|
||||
timeZone: string;
|
||||
nextRunLocal: string;
|
||||
disambiguation: "earlier" | "later" | "reject";
|
||||
items: RecurringItemInput[];
|
||||
}
|
||||
|
||||
const defaultForm = (): RecurringFormState => ({
|
||||
const defaultForm = (timeZone = DEFAULT_TIME_ZONE): RecurringFormState => ({
|
||||
name: "",
|
||||
clientId: "",
|
||||
businessId: "",
|
||||
@@ -79,15 +88,17 @@ const defaultForm = (): RecurringFormState => ({
|
||||
currency: "USD",
|
||||
notes: "",
|
||||
emailMessage: "",
|
||||
timeZone,
|
||||
nextRunLocal: toZonedDateTimeInputValue(
|
||||
getDefaultScheduledSendAt(),
|
||||
timeZone,
|
||||
),
|
||||
disambiguation: "reject",
|
||||
items: [{ description: "", hours: 0, rate: 0 }],
|
||||
});
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
function formatDate(date: Date, timeZone: string) {
|
||||
return formatZonedDateTime(date, timeZone);
|
||||
}
|
||||
|
||||
function scheduleLabel(s: string) {
|
||||
@@ -106,19 +117,28 @@ function RecurringForm({
|
||||
businesses: { id: string; name: string }[];
|
||||
}) {
|
||||
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) =>
|
||||
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) => ({
|
||||
...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 (
|
||||
<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">
|
||||
<Label>Template name</Label>
|
||||
<Input
|
||||
@@ -173,7 +193,9 @@ function RecurringForm({
|
||||
<Label>Schedule</Label>
|
||||
<Select
|
||||
value={form.schedule}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, schedule: v as Schedule }))}
|
||||
onValueChange={(v) =>
|
||||
setForm((f) => ({ ...f, schedule: v as Schedule }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -193,11 +215,65 @@ function RecurringForm({
|
||||
maxLength={3}
|
||||
placeholder="USD"
|
||||
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 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">
|
||||
<Label>Tax rate (%)</Label>
|
||||
<NumberInput
|
||||
@@ -226,7 +302,7 @@ function RecurringForm({
|
||||
type="button"
|
||||
size="sm"
|
||||
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)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -281,7 +357,9 @@ export default function RecurringInvoicesPage() {
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
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: businesses = [] } = api.businesses.getAll.useQuery();
|
||||
const utils = api.useUtils();
|
||||
@@ -289,27 +367,47 @@ export default function RecurringInvoicesPage() {
|
||||
const invalidate = () => void utils.recurringInvoices.getAll.invalidate();
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
const pause = api.recurringInvoices.pause.useMutation({
|
||||
onSuccess: () => { toast.success("Paused"); invalidate(); },
|
||||
onSuccess: () => {
|
||||
toast.success("Paused");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const resume = api.recurringInvoices.resume.useMutation({
|
||||
onSuccess: () => { toast.success("Resumed"); invalidate(); },
|
||||
onSuccess: () => {
|
||||
toast.success("Resumed");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -333,6 +431,9 @@ export default function RecurringInvoicesPage() {
|
||||
currency: rec.currency,
|
||||
notes: rec.notes ?? "",
|
||||
emailMessage: rec.emailMessage ?? "",
|
||||
timeZone: rec.timeZone,
|
||||
nextRunLocal: toZonedDateTimeInputValue(rec.nextDueAt, rec.timeZone),
|
||||
disambiguation: "reject",
|
||||
items: rec.items.map((i) => ({
|
||||
description: i.description,
|
||||
hours: i.hours,
|
||||
@@ -365,7 +466,12 @@ export default function RecurringInvoicesPage() {
|
||||
title="Recurring Invoices"
|
||||
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" />
|
||||
New recurring
|
||||
</Button>
|
||||
@@ -383,7 +489,12 @@ export default function RecurringInvoicesPage() {
|
||||
title="Create your first recurring invoice"
|
||||
description="Automatically generate draft invoices on a schedule you choose."
|
||||
action={
|
||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setForm(defaultForm(profile?.timeZone));
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create recurring invoice
|
||||
</Button>
|
||||
@@ -400,7 +511,11 @@ export default function RecurringInvoicesPage() {
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-semibold">{rec.name}</p>
|
||||
<Badge variant={rec.status === "active" ? "default" : "secondary"}>
|
||||
<Badge
|
||||
variant={
|
||||
rec.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{rec.status}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -408,14 +523,18 @@ export default function RecurringInvoicesPage() {
|
||||
{rec.client.name} · {scheduleLabel(rec.schedule)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Next: {formatDate(rec.nextDueAt)}
|
||||
Next: {formatDate(rec.nextDueAt, rec.timeZone)}
|
||||
{rec.lastGeneratedAt && (
|
||||
<> · Last generated: {formatDate(rec.lastGeneratedAt)}</>
|
||||
<>
|
||||
{" "}
|
||||
· Last generated:{" "}
|
||||
{formatDate(rec.lastGeneratedAt, rec.timeZone)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 shrink-0">
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -473,14 +592,21 @@ export default function RecurringInvoicesPage() {
|
||||
<Dialog
|
||||
open={createOpen || editId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }
|
||||
if (!open) {
|
||||
setCreateOpen(false);
|
||||
setEditId(null);
|
||||
setForm(defaultForm());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit recurring invoice" : "New recurring invoice"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{editId ? "Edit recurring invoice" : "New recurring invoice"}
|
||||
</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<RecurringForm
|
||||
@@ -492,17 +618,30 @@ export default function RecurringInvoicesPage() {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }}
|
||||
onClick={() => {
|
||||
setCreateOpen(false);
|
||||
setEditId(null);
|
||||
setForm(defaultForm());
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !form.name || !form.clientId}>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !form.name || !form.clientId}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…</>
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : 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>
|
||||
</DialogFooter>
|
||||
@@ -510,12 +649,18 @@ export default function RecurringInvoicesPage() {
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
|
||||
<Dialog
|
||||
open={deleteId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteId(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete recurring invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will stop automatic generation. Already-generated invoices are not affected.
|
||||
This will stop automatic generation. Already-generated invoices
|
||||
are not affected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
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 { StatusBadge } from "~/components/data/status-badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -24,6 +27,10 @@ import {
|
||||
import { formatCurrency } from "~/lib/currency";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import {
|
||||
formatCalendarDate,
|
||||
getZonedDateTimeParts,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
@@ -63,7 +70,9 @@ export default function ReportsPage() {
|
||||
|
||||
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 filteredInvoices = useMemo(() => {
|
||||
@@ -76,10 +85,11 @@ export default function ReportsPage() {
|
||||
if (!filteredInvoices.length) return null;
|
||||
|
||||
const now = new Date();
|
||||
const current = getZonedDateTimeParts(now, reportTimeZone);
|
||||
const monthMap: Record<string, number> = {};
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
const d = new Date(Date.UTC(current.year, current.month - 1 - i, 1));
|
||||
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
monthMap[key] = 0;
|
||||
}
|
||||
|
||||
@@ -91,10 +101,11 @@ export default function ReportsPage() {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
reportTimeZone,
|
||||
);
|
||||
if (status === "paid") {
|
||||
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;
|
||||
} else if (status === "sent" || status === "overdue") {
|
||||
totalPending += inv.totalAmount;
|
||||
@@ -103,7 +114,7 @@ export default function ReportsPage() {
|
||||
}
|
||||
|
||||
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
||||
month: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||
month: formatCalendarDate(month + "-01", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
@@ -115,6 +126,7 @@ export default function ReportsPage() {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
reportTimeZone,
|
||||
);
|
||||
if (status === "paid" && inv.client) {
|
||||
const id = inv.client.id;
|
||||
@@ -139,6 +151,7 @@ export default function ReportsPage() {
|
||||
const s = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
reportTimeZone,
|
||||
);
|
||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||
}
|
||||
@@ -151,7 +164,7 @@ export default function ReportsPage() {
|
||||
totalHours,
|
||||
statusCount,
|
||||
};
|
||||
}, [filteredInvoices]);
|
||||
}, [filteredInvoices, reportTimeZone]);
|
||||
|
||||
// Tax summary for selected year
|
||||
const taxData = useMemo(() => {
|
||||
@@ -161,13 +174,14 @@ export default function ReportsPage() {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
reportTimeZone,
|
||||
);
|
||||
return (
|
||||
status === "paid" && new Date(inv.issueDate).getFullYear() === year
|
||||
status === "paid" && new Date(inv.issueDate).getUTCFullYear() === year
|
||||
);
|
||||
});
|
||||
const yearExpenses = expenses.filter(
|
||||
(exp) => new Date(exp.date).getFullYear() === year,
|
||||
(exp) => new Date(exp.date).getUTCFullYear() === year,
|
||||
);
|
||||
|
||||
const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
|
||||
@@ -211,10 +225,12 @@ export default function ReportsPage() {
|
||||
return {
|
||||
label: `Q${q}`,
|
||||
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),
|
||||
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),
|
||||
};
|
||||
});
|
||||
@@ -233,13 +249,13 @@ export default function ReportsPage() {
|
||||
yearInvoices,
|
||||
yearExpenses,
|
||||
};
|
||||
}, [filteredInvoices, expenses, taxYear]);
|
||||
}, [filteredInvoices, expenses, taxYear, reportTimeZone]);
|
||||
|
||||
const availableYears = useMemo(() => {
|
||||
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||
for (const inv of filteredInvoices)
|
||||
years.add(new Date(inv.issueDate).getFullYear());
|
||||
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||
years.add(new Date(inv.issueDate).getUTCFullYear());
|
||||
for (const exp of expenses) years.add(new Date(exp.date).getUTCFullYear());
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}, [filteredInvoices, expenses, currentYear]);
|
||||
|
||||
@@ -251,6 +267,7 @@ export default function ReportsPage() {
|
||||
getEffectiveInvoiceStatus(
|
||||
i.status as StoredInvoiceStatus,
|
||||
i.dueDate,
|
||||
reportTimeZone,
|
||||
) === "paid",
|
||||
).length || 1)
|
||||
: 0;
|
||||
@@ -272,7 +289,7 @@ export default function ReportsPage() {
|
||||
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
|
||||
const taxAmt = inv.totalAmount - invoiceSubtotal;
|
||||
return [
|
||||
new Date(inv.issueDate).toLocaleDateString("en-US"),
|
||||
formatCalendarDate(inv.issueDate),
|
||||
inv.invoiceNumber,
|
||||
`"${inv.client?.name ?? ""}"`,
|
||||
invoiceSubtotal.toFixed(2),
|
||||
@@ -287,7 +304,7 @@ export default function ReportsPage() {
|
||||
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
||||
...taxData.yearExpenses.map((exp) =>
|
||||
[
|
||||
new Date(exp.date).toLocaleDateString("en-US"),
|
||||
formatCalendarDate(exp.date),
|
||||
`"${exp.description}"`,
|
||||
`"${exp.category ?? ""}"`,
|
||||
exp.amount.toFixed(2),
|
||||
@@ -634,7 +651,7 @@ export default function ReportsPage() {
|
||||
<div>
|
||||
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{new Date(inv.issueDate).toLocaleDateString("en-US", {
|
||||
{formatCalendarDate(inv.issueDate, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
@@ -647,6 +664,7 @@ export default function ReportsPage() {
|
||||
getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
reportTimeZone,
|
||||
) as never
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -92,6 +92,7 @@ import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance";
|
||||
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
|
||||
import { ApiAccessSettings } from "./api-access-settings";
|
||||
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
|
||||
import { DEFAULT_TIME_ZONE } from "@beenvoice/domain/time-zone";
|
||||
|
||||
const InvoiceImportPage = dynamic(
|
||||
() =>
|
||||
@@ -147,6 +148,7 @@ export function SettingsContent({
|
||||
|
||||
const { data: session } = useAuthSession();
|
||||
const [name, setName] = useState("");
|
||||
const [timeZone, setTimeZone] = useState(DEFAULT_TIME_ZONE);
|
||||
const [nameInitialized, setNameInitialized] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [importData, setImportData] = useState("");
|
||||
@@ -309,7 +311,7 @@ export function SettingsContent({
|
||||
toast.error("Please enter your name");
|
||||
return;
|
||||
}
|
||||
updateProfileMutation.mutate({ name: name.trim() });
|
||||
updateProfileMutation.mutate({ name: name.trim(), timeZone });
|
||||
};
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent) => {
|
||||
@@ -423,8 +425,15 @@ export function SettingsContent({
|
||||
if (nameInitialized || !profileFetched) return;
|
||||
// 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 ?? "");
|
||||
setTimeZone(profile?.timeZone ?? DEFAULT_TIME_ZONE);
|
||||
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)
|
||||
|
||||
@@ -497,6 +506,19 @@ export function SettingsContent({
|
||||
Email address cannot be changed
|
||||
</p>
|
||||
</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
|
||||
type="submit"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
|
||||
@@ -9,29 +9,54 @@ import { api } from "~/trpc/react";
|
||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||
import { formatLineItemDetail } from "~/lib/invoice-line-item";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
formatCalendarDate,
|
||||
getEffectiveInvoiceStatus,
|
||||
} from "@beenvoice/domain";
|
||||
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
|
||||
import { hasBusinessBrandAsset } from "~/lib/business-branding";
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
return formatCalendarDate(date, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
});
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const overdue = status === "sent" && new Date(dueDate) < new Date();
|
||||
const label = overdue ? "Overdue" : status.charAt(0).toUpperCase() + status.slice(1);
|
||||
function StatusPill({
|
||||
status,
|
||||
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
|
||||
? "bg-red-50 text-red-700 border-red-200"
|
||||
: status === "paid"
|
||||
? "bg-green-50 text-green-700 border-green-200"
|
||||
: "bg-yellow-50 text-yellow-700 border-yellow-200";
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
@@ -40,7 +65,11 @@ function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
|
||||
function PublicInvoiceView({ token }: { token: string }) {
|
||||
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 () => {
|
||||
if (!invoice || downloading) return;
|
||||
@@ -79,7 +108,9 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
return (
|
||||
<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-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>
|
||||
);
|
||||
}
|
||||
@@ -92,53 +123,67 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||
: invoice.business.name
|
||||
: null;
|
||||
const hasLogo = Boolean(invoice.business?.logoStorageKey);
|
||||
const hasLogo = hasBusinessBrandAsset(invoice.business);
|
||||
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
|
||||
|
||||
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">
|
||||
{/* Card */}
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
|
||||
{hasLogo && (
|
||||
// Uploaded SVGs are sanitized and served by our route. next/image's
|
||||
// optimizer intentionally rejects SVG, so a native img is required.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/business-logo/${invoice.business!.id}`}
|
||||
alt=""
|
||||
className="h-16 w-auto max-w-[220px] shrink-0 rounded bg-white object-contain px-2 py-1.5"
|
||||
<BusinessBrandImage
|
||||
business={invoice.business!}
|
||||
kind="logo"
|
||||
theme="dark"
|
||||
decorative
|
||||
className="h-16 w-[220px] max-w-[42%] shrink-0 rounded px-2 py-1.5"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{!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 && (
|
||||
<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>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-8 py-6 space-y-6">
|
||||
<div className="space-y-6 px-8 py-6">
|
||||
{/* Invoice meta */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<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">
|
||||
Issued {formatDate(invoice.issueDate)} · Due {formatDate(invoice.dueDate)}
|
||||
Issued {formatDate(invoice.issueDate)} · Due{" "}
|
||||
{formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill status={invoice.status} dueDate={invoice.dueDate} />
|
||||
<StatusPill
|
||||
status={invoice.status}
|
||||
dueDate={invoice.dueDate}
|
||||
timeZone={invoice.createdBy.timeZone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bill to */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Bill to</p>
|
||||
<p className="font-semibold text-gray-900">{invoice.client.name}</p>
|
||||
<p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
|
||||
Bill to
|
||||
</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{invoice.client.name}
|
||||
</p>
|
||||
{invoice.client.email && (
|
||||
<p className="text-sm text-gray-500">{invoice.client.email}</p>
|
||||
)}
|
||||
@@ -149,18 +194,21 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
{/* Line items */}
|
||||
<div className="space-y-3">
|
||||
{invoice.items.map((item) => (
|
||||
<div key={item.id} className="flex justify-between gap-4 text-sm">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
||||
<div
|
||||
key={item.id}
|
||||
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">
|
||||
{formatLineItemDetail(
|
||||
item.hours,
|
||||
item.rate,
|
||||
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
|
||||
{formatLineItemDetail(item.hours, item.rate, (amount) =>
|
||||
formatCurrency(amount, invoice.currency ?? "USD"),
|
||||
)}
|
||||
</p>
|
||||
</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")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -173,15 +221,19 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-gray-500">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatCurrency(subtotal, invoice.currency ?? "USD")}</span>
|
||||
<span>
|
||||
{formatCurrency(subtotal, invoice.currency ?? "USD")}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex justify-between text-gray-500">
|
||||
<span>Tax ({invoice.taxRate}%)</span>
|
||||
<span>{formatCurrency(taxAmount, invoice.currency ?? "USD")}</span>
|
||||
<span>
|
||||
{formatCurrency(taxAmount, invoice.currency ?? "USD")}
|
||||
</span>
|
||||
</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>{formatCurrency(total, invoice.currency ?? "USD")}</span>
|
||||
</div>
|
||||
@@ -192,8 +244,12 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Notes</p>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
|
||||
<p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
|
||||
Notes
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap text-gray-700">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -206,9 +262,14 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
className="w-full"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@@ -41,12 +41,6 @@ const geistSans = localFont({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const playfair = localFont({
|
||||
src: "../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
|
||||
variable: "--font-playfair",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = localFont({
|
||||
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||
variable: "--font-geist-mono",
|
||||
@@ -61,7 +55,7 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
lang="en"
|
||||
data-color-mode="system"
|
||||
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||
className={`${geistSans.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<AppearanceInitScript />
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
businessBrandAssetPath,
|
||||
hasBusinessBrandAsset,
|
||||
type BrandAssetKind,
|
||||
type BrandAssetTheme,
|
||||
type BusinessBrandAssets,
|
||||
} from "~/lib/business-branding";
|
||||
|
||||
type BusinessBrandImageProps = {
|
||||
business: BusinessBrandAssets & { id: string; name?: string | null };
|
||||
kind?: BrandAssetKind;
|
||||
theme?: BrandAssetTheme | "auto";
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
decorative?: boolean;
|
||||
};
|
||||
|
||||
export function BusinessBrandImage({
|
||||
business,
|
||||
kind = "icon",
|
||||
theme = "auto",
|
||||
className,
|
||||
imageClassName,
|
||||
decorative = false,
|
||||
}: BusinessBrandImageProps) {
|
||||
if (!hasBusinessBrandAsset(business)) return null;
|
||||
const alt = decorative ? "" : `${business.name ?? "Business"} ${kind}`;
|
||||
const image = (variant: BrandAssetTheme, variantClassName?: string) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed SVG/raster brand asset
|
||||
<img
|
||||
src={businessBrandAssetPath(business.id, kind, variant)}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
"h-full w-full object-contain",
|
||||
imageClassName,
|
||||
variantClassName,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className={cn("block overflow-hidden", className)}>
|
||||
{theme === "auto" ? (
|
||||
<>
|
||||
{image("light", "dark:hidden")}
|
||||
{image("dark", "hidden dark:block")}
|
||||
</>
|
||||
) : (
|
||||
image(theme)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
|
||||
export function DashboardBrand({ compact = false }: { compact?: boolean }) {
|
||||
return <Logo size={compact ? "icon" : "sm"} />;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
export function CurrentOpenInvoiceCard() {
|
||||
const { data: currentInvoice, isLoading } =
|
||||
@@ -20,10 +21,10 @@ export function CurrentOpenInvoiceCard() {
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
return formatCalendarDate(date, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
Plus,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
|
||||
export function InvoiceList() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -72,7 +73,7 @@ export function InvoiceList() {
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString();
|
||||
return formatCalendarDate(date);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
|
||||
@@ -24,7 +24,10 @@ import { toast } from "sonner";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
@@ -43,6 +46,13 @@ import {
|
||||
VALIDATION_MESSAGES,
|
||||
} from "~/lib/form-constants";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
businessBrandAssetPath,
|
||||
getBrandAssetFieldNames,
|
||||
hasBusinessBrandAsset,
|
||||
type BrandAssetKind,
|
||||
type BrandAssetTheme,
|
||||
} from "~/lib/business-branding";
|
||||
|
||||
interface BusinessFormProps {
|
||||
businessId?: string;
|
||||
@@ -114,6 +124,54 @@ const ACCEPTED_LOGO_TYPES = new Set([
|
||||
"image/svg+xml",
|
||||
]);
|
||||
|
||||
const BRAND_ASSET_SLOTS: Array<{
|
||||
kind: BrandAssetKind;
|
||||
theme: BrandAssetTheme;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
kind: "logo",
|
||||
theme: "light",
|
||||
label: "Logo · light",
|
||||
description: "Icon + text for light backgrounds",
|
||||
},
|
||||
{
|
||||
kind: "logo",
|
||||
theme: "dark",
|
||||
label: "Logo · dark",
|
||||
description: "Icon + text for dark backgrounds",
|
||||
},
|
||||
{
|
||||
kind: "wordmark",
|
||||
theme: "light",
|
||||
label: "Wordmark · light",
|
||||
description: "Text-only mark for light backgrounds",
|
||||
},
|
||||
{
|
||||
kind: "wordmark",
|
||||
theme: "dark",
|
||||
label: "Wordmark · dark",
|
||||
description: "Text-only mark for dark backgrounds",
|
||||
},
|
||||
{
|
||||
kind: "icon",
|
||||
theme: "light",
|
||||
label: "Icon · light",
|
||||
description: "Compact mark for light UI",
|
||||
},
|
||||
{
|
||||
kind: "icon",
|
||||
theme: "dark",
|
||||
label: "Icon · dark",
|
||||
description: "Compact mark for dark UI",
|
||||
},
|
||||
];
|
||||
|
||||
function assetSlotKey(kind: BrandAssetKind, theme: BrandAssetTheme) {
|
||||
return `${kind}-${theme}`;
|
||||
}
|
||||
|
||||
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
@@ -123,7 +181,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
|
||||
|
||||
// Fetch business data if editing
|
||||
const { data: business, isLoading: isLoadingBusiness } =
|
||||
@@ -165,20 +223,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
});
|
||||
|
||||
const uploadLogo = api.businesses.uploadLogo.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_data, variables) => {
|
||||
await utils.businesses.getById.invalidate({ id: businessId });
|
||||
toast.success("Logo updated");
|
||||
toast.success(`${variables.kind} ${variables.theme} variant updated`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to upload logo");
|
||||
},
|
||||
onSettled: () => setIsUploadingLogo(false),
|
||||
onSettled: () => setUploadingAsset(null),
|
||||
});
|
||||
|
||||
const removeLogo = api.businesses.removeLogo.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_data, variables) => {
|
||||
await utils.businesses.getById.invalidate({ id: businessId });
|
||||
toast.success("Logo removed");
|
||||
toast.success(`${variables.kind} ${variables.theme} variant removed`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to remove logo");
|
||||
@@ -187,6 +245,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
|
||||
const handleLogoFileSelected = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
kind: BrandAssetKind,
|
||||
theme: BrandAssetTheme,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
@@ -201,7 +261,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingLogo(true);
|
||||
setUploadingAsset(assetSlotKey(kind, theme));
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
@@ -217,6 +277,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
data,
|
||||
kind,
|
||||
theme,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -229,12 +291,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
|
||||
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
|
||||
useEffect(() => {
|
||||
if (
|
||||
business &&
|
||||
mode === "edit" &&
|
||||
!initialized &&
|
||||
!isLoadingEmailConfig
|
||||
) {
|
||||
if (business && mode === "edit" && !initialized && !isLoadingEmailConfig) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
|
||||
setFormData({
|
||||
name: business.name,
|
||||
@@ -732,74 +789,128 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
<ImageIcon className="text-muted-foreground h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Logo</CardTitle>
|
||||
<CardTitle>Brand assets</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Shown on invoices sent to your clients. PNG, JPEG,
|
||||
WebP, or SVG, up to 5MB.
|
||||
Add logos, wordmarks, and icons for light and dark
|
||||
backgrounds. Missing variants fall back automatically.
|
||||
PNG, JPEG, WebP, or SVG, up to 5MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-muted border-border/40 flex h-20 min-w-20 max-w-[240px] shrink-0 items-center justify-center overflow-hidden border px-2">
|
||||
{business?.logoStorageKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external/object-storage-backed image, not a static asset
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{BRAND_ASSET_SLOTS.map((slot) => {
|
||||
const [storageField] = getBrandAssetFieldNames(
|
||||
slot.kind,
|
||||
slot.theme,
|
||||
);
|
||||
const hasAsset = Boolean(business?.[storageField]);
|
||||
const key = assetSlotKey(slot.kind, slot.theme);
|
||||
const inputId = `brand-asset-${key}`;
|
||||
const isUploading = uploadingAsset === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="border-border/60 overflow-hidden rounded-xl border"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-28 items-center justify-center p-4",
|
||||
slot.theme === "dark"
|
||||
? "bg-neutral-950"
|
||||
: "bg-white",
|
||||
)}
|
||||
>
|
||||
{hasAsset ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image
|
||||
<img
|
||||
src={`/api/business-logo/${businessId}?v=${business.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
|
||||
alt={`${business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
src={`${businessBrandAssetPath(businessId, slot.kind, slot.theme)}&v=${business?.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
|
||||
alt={`${business?.name ?? "Business"} ${slot.label}`}
|
||||
className={cn(
|
||||
"max-h-full max-w-full object-contain",
|
||||
slot.kind === "icon" && "aspect-square",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="text-muted-foreground/50 h-8 w-8" />
|
||||
<ImageIcon
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
slot.theme === "dark"
|
||||
? "text-white/35"
|
||||
: "text-black/25",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="space-y-3 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{slot.label}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{slot.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isUploadingLogo}
|
||||
className="flex-1"
|
||||
disabled={Boolean(uploadingAsset)}
|
||||
onClick={() =>
|
||||
document.getElementById("logo-upload-input")?.click()
|
||||
document.getElementById(inputId)?.click()
|
||||
}
|
||||
>
|
||||
{isUploadingLogo ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 sm:mr-2" />
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{business?.logoStorageKey
|
||||
? "Replace logo"
|
||||
: "Upload logo"}
|
||||
</span>
|
||||
{hasAsset ? "Replace" : "Upload"}
|
||||
</Button>
|
||||
{business?.logoStorageKey && (
|
||||
{hasAsset ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
aria-label={`Remove ${slot.label}`}
|
||||
disabled={removeLogo.isPending}
|
||||
onClick={() =>
|
||||
businessId && removeLogo.mutate({ id: businessId })
|
||||
businessId &&
|
||||
removeLogo.mutate({
|
||||
id: businessId,
|
||||
kind: slot.kind,
|
||||
theme: slot.theme,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Remove</span>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
<input
|
||||
id="logo-upload-input"
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={handleLogoFileSelected}
|
||||
onChange={(event) =>
|
||||
void handleLogoFileSelected(
|
||||
event,
|
||||
slot.kind,
|
||||
slot.theme,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{business?.logoStorageKey && (
|
||||
{hasBusinessBrandAsset(business) && (
|
||||
<div className="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
@@ -809,8 +920,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
Hide business name on invoices
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Show only the logo in the invoice header — useful
|
||||
if your logo already includes your business name.
|
||||
Show only the logo in the invoice header — useful if
|
||||
your logo already includes your business name.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
import type { BusinessBrandAssets } from "~/lib/business-branding";
|
||||
|
||||
interface EmailPreviewProps {
|
||||
subject: string;
|
||||
@@ -28,9 +29,7 @@ interface EmailPreviewProps {
|
||||
id?: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoMimeType?: string | null;
|
||||
};
|
||||
} & BusinessBrandAssets;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
date?: Date;
|
||||
@@ -87,7 +86,8 @@ export function EmailPreview({
|
||||
description: item.description ?? "Service",
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
|
||||
amount:
|
||||
item.amount ?? calculateLineItemAmount(item.hours, item.rate),
|
||||
})) ?? [],
|
||||
},
|
||||
customContent: content,
|
||||
|
||||
@@ -24,6 +24,10 @@ import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
calendarDateFromLocalDate,
|
||||
calendarDateToLocalDate,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
@@ -77,7 +81,7 @@ export function InvoiceCalendarView({
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter((wrapper) => {
|
||||
const itemDate = new Date(wrapper.item.date);
|
||||
const itemDate = calendarDateToLocalDate(wrapper.item.date);
|
||||
return isSameDay(itemDate, date);
|
||||
});
|
||||
}, [items, date]);
|
||||
@@ -88,7 +92,7 @@ export function InvoiceCalendarView({
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter((wrapper) => {
|
||||
const itemDate = new Date(wrapper.item.date);
|
||||
const itemDate = calendarDateToLocalDate(wrapper.item.date);
|
||||
return isSameDay(itemDate, targetDate);
|
||||
});
|
||||
},
|
||||
@@ -103,7 +107,7 @@ export function InvoiceCalendarView({
|
||||
|
||||
const handleAddNewItem = () => {
|
||||
if (date) {
|
||||
onAddItem(date);
|
||||
onAddItem(calendarDateFromLocalDate(date));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -407,7 +411,11 @@ export function InvoiceCalendarView({
|
||||
</p>
|
||||
</div>
|
||||
{!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" />
|
||||
Log Time
|
||||
</Button>
|
||||
@@ -494,7 +502,11 @@ export function InvoiceCalendarView({
|
||||
Total
|
||||
</span>
|
||||
<span className="text-primary text-lg font-bold">
|
||||
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
|
||||
$
|
||||
{calculateLineItemAmount(
|
||||
item.hours,
|
||||
item.rate,
|
||||
).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,8 @@ import {
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
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 {
|
||||
DropdownMenu,
|
||||
@@ -108,13 +109,14 @@ function plainTextToHtml(value: string) {
|
||||
}
|
||||
|
||||
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||
const today = calendarDateFromLocalDate(new Date());
|
||||
return {
|
||||
invoiceNumber: generateInvoiceNumber(),
|
||||
invoicePrefix: "#",
|
||||
businessId: "",
|
||||
clientId: "",
|
||||
issueDate: new Date(),
|
||||
dueDate: new Date(),
|
||||
issueDate: today,
|
||||
dueDate: defaultDueDate(today),
|
||||
status: "draft",
|
||||
notes: "",
|
||||
emailMessage: "",
|
||||
@@ -124,7 +126,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||
items: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
date: today,
|
||||
description: "",
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
@@ -209,7 +211,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
description: "",
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
@@ -320,7 +322,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
...prev.items,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
date: calendarDateFromLocalDate(new Date()),
|
||||
description: parsed.description,
|
||||
hours: parsed.hours ?? 1,
|
||||
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
||||
@@ -350,7 +352,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
items: prev.items.map((item, i) => {
|
||||
if (i !== idx) return item;
|
||||
|
||||
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
|
||||
if (
|
||||
field === "billingType" &&
|
||||
(value === "hourly" || value === "fixed")
|
||||
) {
|
||||
const next = applyBillingTypeChange(value, item);
|
||||
return {
|
||||
...item,
|
||||
@@ -401,7 +406,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToSave = formData.items.filter((item) => item.description?.trim());
|
||||
const itemsToSave = formData.items.filter((item) =>
|
||||
item.description?.trim(),
|
||||
);
|
||||
|
||||
let invalidItemIndex = -1;
|
||||
for (let i = 0; i < formData.items.length; i++) {
|
||||
@@ -515,7 +522,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
<PageTabs
|
||||
value={activeTab}
|
||||
className="w-full"
|
||||
onValueChange={setActiveTab}
|
||||
>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
||||
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
||||
@@ -606,7 +617,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<DatePicker
|
||||
date={formData.issueDate}
|
||||
onDateChange={(d) =>
|
||||
updateField("issueDate", d ?? new Date())
|
||||
updateField(
|
||||
"issueDate",
|
||||
d ?? calendarDateFromLocalDate(new Date()),
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -616,7 +630,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<DatePicker
|
||||
date={formData.dueDate}
|
||||
onDateChange={(d) =>
|
||||
updateField("dueDate", d ?? new Date())
|
||||
updateField(
|
||||
"dueDate",
|
||||
d ?? calendarDateFromLocalDate(new Date()),
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -721,7 +738,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<CardContent>
|
||||
<Textarea
|
||||
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..."
|
||||
className="min-h-[140px]"
|
||||
/>
|
||||
@@ -818,7 +837,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
onRemoveItem={removeItem}
|
||||
onUpdateItem={updateItem}
|
||||
onAddItemWithValues={addItemWithValues}
|
||||
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
||||
invoiceId={
|
||||
invoiceId && invoiceId !== "new" ? invoiceId : undefined
|
||||
}
|
||||
clientId={formData.clientId || undefined}
|
||||
defaultRate={formData.items[0]?.rate}
|
||||
readOnly={formData.status !== "draft"}
|
||||
@@ -925,7 +946,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
amount: calculateLineItemAmount(
|
||||
item.hours,
|
||||
item.rate,
|
||||
),
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -51,6 +51,10 @@ import {
|
||||
} from "~/lib/invoice-import";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
addCalendarDays,
|
||||
formatCalendarDate,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
interface StagedInvoice extends ImportInvoice {
|
||||
id: string;
|
||||
@@ -173,9 +177,10 @@ export function InvoiceImportPage() {
|
||||
if (inv.id !== id) return inv;
|
||||
const updated = { ...inv, ...updates };
|
||||
if (updates.issueDate !== undefined && !updates.dueDate) {
|
||||
const due = new Date(updated.issueDate ?? new Date());
|
||||
due.setDate(due.getDate() + 30);
|
||||
updated.dueDate = due;
|
||||
updated.dueDate = addCalendarDays(
|
||||
updated.issueDate ?? new Date(),
|
||||
30,
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
@@ -628,12 +633,14 @@ export function InvoiceImportPage() {
|
||||
{previewInvoice.items.map((item, idx) => (
|
||||
<tr key={idx} className="border-border border-b">
|
||||
<td className="p-2 text-sm whitespace-nowrap">
|
||||
{item.date?.toLocaleDateString() ?? "—"}
|
||||
{item.date ? formatCalendarDate(item.date) : "—"}
|
||||
</td>
|
||||
<td className="max-w-xs truncate p-2 text-sm">
|
||||
{item.description}
|
||||
</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">
|
||||
{item.rate.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
} from "~/components/layout/sidebar-provider";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { DashboardBrand } from "~/components/branding/dashboard-brand";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "~/components/ui/sheet";
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
|
||||
|
||||
@@ -40,21 +45,23 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-background h-10 w-10 shadow-sm"
|
||||
className="bg-background size-10 shadow-sm"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<Menu />
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
|
||||
<Logo size="sm" className="shrink-0" />
|
||||
<DashboardBrand />
|
||||
<ActiveTimerWidget compact />
|
||||
</div>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<div className="sr-only">
|
||||
<h2 id="mobile-nav-title">Navigation Menu</h2>
|
||||
</div>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="w-80 max-w-[90vw] gap-0 p-0"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
|
||||
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -64,7 +71,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
|
||||
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
|
||||
!isOnboarding && (isCollapsed ? "md:ml-20" : "md:ml-72"),
|
||||
)}
|
||||
>
|
||||
{isOnboarding ? (
|
||||
|
||||
@@ -13,29 +13,52 @@ const SidebarContext = React.createContext<SidebarContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "sidebar-collapsed";
|
||||
const SIDEBAR_CHANGE_EVENT = "beenvoice:sidebar-change";
|
||||
|
||||
function getSidebarSnapshot() {
|
||||
return localStorage.getItem(SIDEBAR_STORAGE_KEY) === "true";
|
||||
}
|
||||
|
||||
function getServerSidebarSnapshot() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function subscribeToSidebar(callback: () => void) {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === SIDEBAR_STORAGE_KEY) callback();
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(SIDEBAR_CHANGE_EVENT, callback);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(SIDEBAR_CHANGE_EVENT, callback);
|
||||
};
|
||||
}
|
||||
|
||||
function saveSidebarState(collapsed: boolean) {
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(collapsed));
|
||||
window.dispatchEvent(new Event(SIDEBAR_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const saved = localStorage.getItem("sidebar-collapsed");
|
||||
return saved ? (JSON.parse(saved) as boolean) : false;
|
||||
});
|
||||
const isCollapsed = React.useSyncExternalStore(
|
||||
subscribeToSidebar,
|
||||
getSidebarSnapshot,
|
||||
getServerSidebarSnapshot,
|
||||
);
|
||||
|
||||
const toggleCollapse = React.useCallback(() => {
|
||||
setIsCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
saveSidebarState(!isCollapsed);
|
||||
}, [isCollapsed]);
|
||||
|
||||
const expand = React.useCallback(() => {
|
||||
setIsCollapsed(false);
|
||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
|
||||
saveSidebarState(false);
|
||||
}, []);
|
||||
|
||||
const collapse = React.useCallback(() => {
|
||||
setIsCollapsed(true);
|
||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
|
||||
saveSidebarState(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,220 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
import { DashboardBrand } from "~/components/branding/dashboard-brand";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
|
||||
import { useSidebar } from "./sidebar-provider";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { getGravatarUrl } from "~/lib/gravatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { getGravatarUrl } from "~/lib/gravatar";
|
||||
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useDashboardUser } from "./dashboard-user-context";
|
||||
import { useSidebar } from "./sidebar-provider";
|
||||
|
||||
interface SidebarProps {
|
||||
mobile?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||
interface SidebarLinkProps {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
active: boolean;
|
||||
collapsed: boolean;
|
||||
mobile: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
name,
|
||||
href,
|
||||
icon: Icon,
|
||||
active,
|
||||
collapsed,
|
||||
mobile,
|
||||
onClose,
|
||||
}: SidebarLinkProps) {
|
||||
const link = (
|
||||
<Link
|
||||
href={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={mobile ? onClose : undefined}
|
||||
className={cn(
|
||||
"group focus-visible:ring-ring relative flex h-10 items-center rounded-xl text-sm font-medium outline-hidden transition-colors focus-visible:ring-2",
|
||||
collapsed ? "w-11 justify-center" : "gap-3 px-3",
|
||||
active
|
||||
? "bg-accent text-accent-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4.5 shrink-0" />
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate">{name}</span>
|
||||
{active ? (
|
||||
<span className="bg-primary size-1.5 shrink-0 rounded-full" />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (!collapsed) return link;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ mobile = false, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { data: session, isPending } = useAuthSession();
|
||||
const { isAdmin } = useDashboardUser();
|
||||
const { isCollapsed, toggleCollapse } = useSidebar();
|
||||
const navSections = getNavigationForUser(isAdmin);
|
||||
|
||||
// If mobile, always expanded
|
||||
const collapsed = mobile ? false : isCollapsed;
|
||||
|
||||
const SidebarContent = (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
{/* Header / Logo */}
|
||||
<div
|
||||
const sidebarContent = (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header
|
||||
className={cn(
|
||||
"mb-2 flex h-14 items-center px-4",
|
||||
collapsed ? "justify-center px-2" : "justify-between",
|
||||
"border-border/70 flex h-20 shrink-0 items-center border-b",
|
||||
collapsed ? "justify-center px-2" : "px-4",
|
||||
)}
|
||||
>
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{collapsed && <Logo size="icon" />}
|
||||
|
||||
{!mobile && !collapsed && (
|
||||
<div className="h-8 w-8" /> // Spacer to keep alignment if needed, or just remove
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={mobile ? onClose : undefined}
|
||||
aria-label="Beenvoice dashboard"
|
||||
className={cn(
|
||||
"mt-4 flex flex-col gap-6 px-2",
|
||||
"focus-visible:ring-ring flex min-w-0 items-center outline-hidden focus-visible:ring-2",
|
||||
collapsed ? "justify-center rounded-lg" : "flex-1",
|
||||
)}
|
||||
>
|
||||
<DashboardBrand compact={collapsed} />
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<nav
|
||||
id="dashboard-sidebar-navigation"
|
||||
aria-label="Dashboard navigation"
|
||||
className="min-h-0 flex-1 overflow-y-auto px-3 py-4"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className={cn("flex", collapsed && "justify-center")}>
|
||||
{collapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
className="size-11 rounded-xl"
|
||||
>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus />
|
||||
<span className="sr-only">New invoice</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
New invoice
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button asChild className="w-full justify-start">
|
||||
<Link
|
||||
href="/dashboard/invoices/new"
|
||||
onClick={mobile ? onClose : undefined}
|
||||
>
|
||||
<Plus data-icon="inline-start" />
|
||||
New invoice
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{navSections.map((section, sectionIndex) => (
|
||||
<section
|
||||
key={section.title}
|
||||
aria-label={collapsed ? section.title : undefined}
|
||||
aria-labelledby={
|
||||
collapsed ? undefined : `sidebar-section-${sectionIndex}`
|
||||
}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{collapsed ? (
|
||||
sectionIndex > 0 ? (
|
||||
<Separator className="mx-auto w-8" />
|
||||
) : null
|
||||
) : (
|
||||
<h2
|
||||
id={`sidebar-section-${sectionIndex}`}
|
||||
className="text-muted-foreground px-3 text-[0.6875rem] font-semibold tracking-[0.12em] uppercase"
|
||||
>
|
||||
{section.title}
|
||||
</h2>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
collapsed && "items-center",
|
||||
)}
|
||||
>
|
||||
{navSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
{!collapsed && (
|
||||
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{section.title}
|
||||
{section.links.map((link) => (
|
||||
<SidebarLink
|
||||
key={link.href}
|
||||
{...link}
|
||||
active={isNavLinkActive(pathname, link.href)}
|
||||
collapsed={collapsed}
|
||||
mobile={mobile}
|
||||
onClose={onClose}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
{section.links.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const isActive = isNavLinkActive(pathname, link.href);
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<TooltipProvider key={link.href} delayDuration={0}>
|
||||
{!mobile ? (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 px-3 pb-3",
|
||||
collapsed && "flex justify-center",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={link.href}
|
||||
data-active={isActive ? "true" : undefined}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={collapsed ? "icon" : "default"}
|
||||
aria-label={
|
||||
collapsed ? "Expand sidebar" : "Collapse sidebar"
|
||||
}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls="dashboard-sidebar-navigation"
|
||||
onClick={toggleCollapse}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
"text-muted-foreground h-10 rounded-xl",
|
||||
collapsed
|
||||
? "mx-auto size-11"
|
||||
: "w-full justify-start gap-3 px-3",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</Link>
|
||||
{collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
|
||||
{!collapsed ? <span>Collapse sidebar</span> : null}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="font-medium"
|
||||
>
|
||||
{link.name}
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
data-active={isActive ? "true" : undefined}
|
||||
onClick={mobile ? onClose : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{link.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Footer / User */}
|
||||
<div className="mt-auto space-y-2 p-2">
|
||||
{!mobile && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
collapsed ? "justify-center" : "justify-end px-2",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<footer className="border-border/70 flex shrink-0 flex-col gap-2 border-t p-3">
|
||||
<ActiveTimerWidget collapsed={collapsed} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 border-t pt-4",
|
||||
collapsed ? "flex flex-col items-center gap-2" : "px-2",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
collapsed ? "justify-center" : "px-2",
|
||||
"flex h-12 items-center gap-3 px-2",
|
||||
collapsed && "justify-center px-0",
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
{!collapsed && (
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
{!collapsed ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-2 w-24" />
|
||||
<Skeleton className="h-2.5 w-28" />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
) : session?.user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={collapsed ? "icon" : "default"}
|
||||
aria-label={collapsed ? "Open account menu" : undefined}
|
||||
className={cn(
|
||||
"w-full justify-start p-0 hover:bg-transparent",
|
||||
collapsed && "justify-center",
|
||||
"h-auto min-h-12 rounded-xl",
|
||||
collapsed
|
||||
? "mx-auto size-11 p-0"
|
||||
: "w-full justify-start gap-3 px-2 py-1.5",
|
||||
)}
|
||||
>
|
||||
{/* FIXED: Changed div to span to prevent hydration error */}
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
collapsed ? "justify-center" : "w-full",
|
||||
)}
|
||||
>
|
||||
<Avatar className="border-border h-9 w-9 border">
|
||||
<Avatar className="border-border size-9 shrink-0 border">
|
||||
<AvatarImage
|
||||
src={getGravatarUrl(session.user.email)}
|
||||
alt={session.user.name ?? "User"}
|
||||
alt=""
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{session.user.name?.[0] ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{session.user.name}
|
||||
@@ -223,57 +301,58 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||
{session.user.email}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="text-muted-foreground" />
|
||||
</>
|
||||
) : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
side={mobile ? "top" : "right"}
|
||||
align="end"
|
||||
className="bg-background/80 border-border/50 w-56 backdrop-blur-xl"
|
||||
sideOffset={10}
|
||||
sideOffset={8}
|
||||
className="border-border w-60 border"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{session.user.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
<DropdownMenuLabel className="flex flex-col gap-1">
|
||||
<span className="truncate">{session.user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||
{session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
await authClient.signOut();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="text-red-600 focus:bg-red-100/50 focus:text-red-600 dark:focus:bg-red-900/20"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
<LogOut />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
if (mobile) {
|
||||
return <div className="bg-background h-full">{SidebarContent}</div>;
|
||||
return <div className="bg-background h-full">{sidebarContent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"border-border bg-background fixed top-0 bottom-0 left-0 z-30 hidden flex-col rounded-none border-r shadow-none transition-all duration-300 ease-in-out md:flex",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
"border-border bg-background fixed inset-y-0 left-0 z-30 hidden border-r transition-[width] duration-200 ease-out md:block",
|
||||
isCollapsed ? "w-20" : "w-72",
|
||||
)}
|
||||
>
|
||||
{SidebarContent}
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,15 +46,10 @@ import {
|
||||
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
||||
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||
import { toLocalDateTimeInputValue } from "@beenvoice/domain/time-zone";
|
||||
|
||||
type StartMode = "now" | "pick" | "ago";
|
||||
|
||||
function toDatetimeLocalValue(value: Date | string) {
|
||||
const start = new Date(value);
|
||||
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
|
||||
return start.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function RunningTextFields({
|
||||
running,
|
||||
updateRunningPending,
|
||||
@@ -68,7 +63,7 @@ function RunningTextFields({
|
||||
}) {
|
||||
const [title, setTitle] = useState(running.description ?? "");
|
||||
const [runningStartedAt, setRunningStartedAt] = useState(() =>
|
||||
toDatetimeLocalValue(running.startedAt),
|
||||
toLocalDateTimeInputValue(new Date(running.startedAt)),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -121,10 +116,8 @@ export function TimeClockPanel({
|
||||
compact = false,
|
||||
}: TimeClockPanelProps) {
|
||||
const utils = api.useUtils();
|
||||
const { data: running, isLoading: runningLoading } = api.timeEntries.getRunning.useQuery(
|
||||
undefined,
|
||||
{ refetchInterval: 30_000 },
|
||||
);
|
||||
const { data: running, isLoading: runningLoading } =
|
||||
api.timeEntries.getRunning.useQuery(undefined, { refetchInterval: 30_000 });
|
||||
const { data: clients } = api.clients.getAll.useQuery();
|
||||
|
||||
const todayStart = useMemo(() => {
|
||||
@@ -169,7 +162,9 @@ export function TimeClockPanel({
|
||||
if (!running) return;
|
||||
|
||||
const tick = () =>
|
||||
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000));
|
||||
setElapsed(
|
||||
Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000),
|
||||
);
|
||||
tick();
|
||||
intervalRef.current = setInterval(tick, 1000);
|
||||
return () => {
|
||||
@@ -223,7 +218,10 @@ export function TimeClockPanel({
|
||||
window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
||||
},
|
||||
});
|
||||
} else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") {
|
||||
} else if (
|
||||
data.outcome === "saved_no_invoice" ||
|
||||
data.outcome === "saved_no_client"
|
||||
) {
|
||||
toast.warning("Time saved", { description: message });
|
||||
} else {
|
||||
toast.success(message);
|
||||
@@ -289,7 +287,7 @@ export function TimeClockPanel({
|
||||
if (mode === "pick" && !pickedStart) {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
setPickedStart(now.toISOString().slice(0, 16));
|
||||
setPickedStart(toLocalDateTimeInputValue(now));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +312,9 @@ export function TimeClockPanel({
|
||||
if (runningLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="text-muted-foreground p-6 text-sm">Loading timer…</CardContent>
|
||||
<CardContent className="text-muted-foreground p-6 text-sm">
|
||||
Loading timer…
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -330,7 +330,12 @@ export function TimeClockPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", !compact && "xl:grid xl:grid-cols-[minmax(0,1fr)_22rem]")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
!compact && "xl:grid xl:grid-cols-[minmax(0,1fr)_22rem]",
|
||||
)}
|
||||
>
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -338,7 +343,7 @@ export function TimeClockPanel({
|
||||
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{running ? "In progress" : "Ready to start"}
|
||||
</p>
|
||||
<CardTitle className="text-pretty text-2xl">
|
||||
<CardTitle className="text-2xl text-pretty">
|
||||
{running ? runningTitle : "What are you working on?"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-pretty">
|
||||
@@ -403,10 +408,16 @@ export function TimeClockPanel({
|
||||
<Label htmlFor="clock-client">Client</Label>
|
||||
<Select
|
||||
value={activeClientId || "__none__"}
|
||||
onValueChange={(value) => handleClientChange(value === "__none__" ? "" : value)}
|
||||
onValueChange={(value) =>
|
||||
handleClientChange(value === "__none__" ? "" : value)
|
||||
}
|
||||
disabled={Boolean(running && updateRunning.isPending)}
|
||||
>
|
||||
<SelectTrigger id="clock-client" aria-label="Client" className="h-11 w-full">
|
||||
<SelectTrigger
|
||||
id="clock-client"
|
||||
aria-label="Client"
|
||||
className="h-11 w-full"
|
||||
>
|
||||
<SelectValue placeholder="Select client…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -427,16 +438,28 @@ export function TimeClockPanel({
|
||||
<Select
|
||||
value={activeInvoiceId || "__none__"}
|
||||
onValueChange={handleInvoiceChange}
|
||||
disabled={!activeClientId || Boolean(running && updateRunning.isPending)}
|
||||
disabled={
|
||||
!activeClientId || Boolean(running && updateRunning.isPending)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="clock-invoice"
|
||||
aria-label="Invoice"
|
||||
className="h-11 w-full"
|
||||
>
|
||||
<SelectTrigger id="clock-invoice" aria-label="Invoice" className="h-11 w-full">
|
||||
<SelectValue
|
||||
placeholder={activeClientId ? "Select invoice…" : "Choose a client first"}
|
||||
placeholder={
|
||||
activeClientId
|
||||
? "Select invoice…"
|
||||
: "Choose a client first"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="__none__">Entry only — no invoice</SelectItem>
|
||||
<SelectItem value="__none__">
|
||||
Entry only — no invoice
|
||||
</SelectItem>
|
||||
{billableInvoices?.map((invoice) => (
|
||||
<SelectItem key={invoice.id} value={invoice.id}>
|
||||
{invoiceLabel(invoice)}
|
||||
@@ -507,7 +530,9 @@ export function TimeClockPanel({
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
|
||||
{clientId &&
|
||||
rate === 0 &&
|
||||
selectedClient?.defaultHourlyRate ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{`Uses ${selectedClient.defaultHourlyRate}/hr from ${selectedClient.name}.`}
|
||||
</p>
|
||||
@@ -543,7 +568,9 @@ export function TimeClockPanel({
|
||||
autoComplete="off"
|
||||
type="datetime-local"
|
||||
value={pickedStart}
|
||||
onChange={(event) => setPickedStart(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setPickedStart(event.target.value)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{startMode === "ago" ? (
|
||||
@@ -556,10 +583,14 @@ export function TimeClockPanel({
|
||||
min={1}
|
||||
max={1440}
|
||||
value={minutesAgo}
|
||||
onChange={(event) => setMinutesAgo(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setMinutesAgo(event.target.value)
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">minutes ago</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
minutes ago
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -618,19 +649,25 @@ export function TimeClockPanel({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{completedToday.length > 0 ? (
|
||||
<TimeEntryList entries={completedToday} onEdit={(entry) => setEditEntryId(entry.id)} />
|
||||
<TimeEntryList
|
||||
entries={completedToday}
|
||||
onEdit={(entry) => setEditEntryId(entry.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 py-6 text-center">
|
||||
<p className="font-medium">No time logged yet</p>
|
||||
<p className="text-muted-foreground text-sm text-pretty">
|
||||
Start your first timer or open history to add an entry manually.
|
||||
Start your first timer or open history to add an entry
|
||||
manually.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/time-clock/entries">View time history</Link>
|
||||
<Link href="/dashboard/time-clock/entries">
|
||||
View time history
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { TimeEntryListItem } from "~/lib/time-entry-display";
|
||||
|
||||
export function TimeEntriesHistory() {
|
||||
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
||||
const { data: profile } = api.settings.getProfile.useQuery();
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
|
||||
const completedEntries = useMemo(
|
||||
@@ -22,8 +23,8 @@ export function TimeEntriesHistory() {
|
||||
);
|
||||
|
||||
const grouped = useMemo(
|
||||
() => groupEntriesByDate(completedEntries),
|
||||
[completedEntries],
|
||||
() => groupEntriesByDate(completedEntries, profile?.timeZone),
|
||||
[completedEntries, profile?.timeZone],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -23,15 +23,10 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||
import type { RouterOutputs } from "~/trpc/react";
|
||||
import { toLocalDateTimeInputValue } from "@beenvoice/domain/time-zone";
|
||||
|
||||
type TimeEntry = RouterOutputs["timeEntries"]["getById"];
|
||||
|
||||
function toDatetimeLocalValue(value: Date | string) {
|
||||
const start = new Date(value);
|
||||
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
|
||||
return start.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export type TimeEntryEditDialogProps = {
|
||||
entryId: string | null;
|
||||
open: boolean;
|
||||
@@ -56,9 +51,11 @@ function TimeEntryEditForm({
|
||||
const [clientId, setClientId] = useState(entry.clientId ?? "");
|
||||
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
|
||||
const [rate, setRate] = useState(entry.rate ?? 0);
|
||||
const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt));
|
||||
const [startedAt, setStartedAt] = useState(() =>
|
||||
toLocalDateTimeInputValue(new Date(entry.startedAt)),
|
||||
);
|
||||
const [endedAt, setEndedAt] = useState(() =>
|
||||
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "",
|
||||
entry.endedAt ? toLocalDateTimeInputValue(new Date(entry.endedAt)) : "",
|
||||
);
|
||||
|
||||
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
|
||||
@@ -70,7 +67,8 @@ function TimeEntryEditForm({
|
||||
if (!startedAt || !endedAt) return null;
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()))
|
||||
return null;
|
||||
return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000);
|
||||
}, [endedAt, startedAt]);
|
||||
|
||||
@@ -228,7 +226,11 @@ function TimeEntryEditForm({
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateEntry.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
@@ -246,7 +248,9 @@ export function TimeEntryEditDialog({
|
||||
{ id: entryId ?? "" },
|
||||
{ enabled: Boolean(entryId) && open },
|
||||
);
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open });
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, {
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
calendarDateFromLocalDate,
|
||||
calendarDateToLocalDate,
|
||||
formatCalendarDate,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
day: "2-digit",
|
||||
@@ -25,7 +30,7 @@ function formatDate(date: Date | undefined) {
|
||||
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).
|
||||
@@ -54,7 +59,9 @@ export function DatePicker({
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
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 = {
|
||||
sm: "h-9 text-xs",
|
||||
@@ -67,7 +74,7 @@ export function DatePicker({
|
||||
React.useEffect(() => {
|
||||
// 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));
|
||||
setMonth(date);
|
||||
setMonth(date ? calendarDateToLocalDate(date) : undefined);
|
||||
}, [date]);
|
||||
|
||||
return (
|
||||
@@ -81,7 +88,7 @@ export function DatePicker({
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"invisible block whitespace-nowrap px-3 pr-10",
|
||||
"invisible block px-3 pr-10 whitespace-nowrap",
|
||||
sizeClasses[size],
|
||||
inputClassName,
|
||||
)}
|
||||
@@ -102,7 +109,8 @@ export function DatePicker({
|
||||
setValue(e.target.value);
|
||||
const parsedDate = parseDate(e.target.value);
|
||||
if (parsedDate) {
|
||||
onDateChange(parsedDate);
|
||||
const calendarDate = calendarDateFromLocalDate(parsedDate);
|
||||
onDateChange(calendarDate);
|
||||
setMonth(parsedDate);
|
||||
}
|
||||
}}
|
||||
@@ -130,13 +138,16 @@ export function DatePicker({
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
selected={date ? calendarDateToLocalDate(date) : undefined}
|
||||
captionLayout="dropdown"
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
onSelect={(selectedDate) => {
|
||||
onDateChange(selectedDate);
|
||||
setValue(formatDate(selectedDate));
|
||||
const calendarDate = selectedDate
|
||||
? calendarDateFromLocalDate(selectedDate)
|
||||
: undefined;
|
||||
onDateChange(calendarDate);
|
||||
setValue(formatDate(calendarDate));
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -27,8 +27,14 @@ export const env = createEnv({
|
||||
: z.string().optional(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
BETTER_AUTH_URL: z.string().url().optional(),
|
||||
EMAIL_PROVIDER: z.enum(["mailpit", "smtp", "resend"]).default("resend"),
|
||||
EMAIL_FROM: z.string().min(1).optional(),
|
||||
SMTP_HOST: z.string().min(1).optional(),
|
||||
SMTP_PORT: z.string().regex(/^\d+$/).optional(),
|
||||
SMTP_SECURE: optionalEnvBoolean(),
|
||||
RESEND_API_KEY: z.string().min(1).optional(),
|
||||
RESEND_DOMAIN: z.string().optional(),
|
||||
RESEND_FROM: z.string().min(1).optional(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
@@ -76,8 +82,14 @@ export const env = createEnv({
|
||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
|
||||
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
|
||||
EMAIL_FROM: process.env.EMAIL_FROM,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
||||
RESEND_DOMAIN: process.env.RESEND_DOMAIN,
|
||||
RESEND_FROM: process.env.RESEND_FROM,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
DB_DISABLE_SSL: process.env.DB_DISABLE_SSL,
|
||||
DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
brandAssetKinds,
|
||||
brandAssetThemes,
|
||||
getBrandAssetFieldNames,
|
||||
hasBusinessBrandAsset,
|
||||
resolveBusinessBrandAsset,
|
||||
} from "@beenvoice/domain/brand-assets";
|
||||
export type {
|
||||
BrandAssetKind,
|
||||
BrandAssetTheme,
|
||||
BusinessBrandAssets,
|
||||
} from "@beenvoice/domain/brand-assets";
|
||||
|
||||
import type {
|
||||
BrandAssetKind,
|
||||
BrandAssetTheme,
|
||||
} from "@beenvoice/domain/brand-assets";
|
||||
|
||||
export function businessBrandAssetPath(
|
||||
businessId: string,
|
||||
kind: BrandAssetKind = "logo",
|
||||
theme: BrandAssetTheme = "light",
|
||||
format?: "png",
|
||||
): string {
|
||||
const params = new URLSearchParams({ kind, theme });
|
||||
if (format) params.set("format", format);
|
||||
return `/api/business-logo/${businessId}?${params.toString()}`;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { addCalendarDays } from "@beenvoice/domain/time-zone";
|
||||
|
||||
/** Default invoice number format (matches web/mobile create forms). */
|
||||
export function generateInvoiceNumber(now = new Date()): string {
|
||||
const date = [
|
||||
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
|
||||
}
|
||||
|
||||
export function defaultDueDate(issueDate: Date): Date {
|
||||
const due = new Date(issueDate);
|
||||
due.setDate(due.getDate() + 30);
|
||||
return due;
|
||||
return addCalendarDays(issueDate, 30);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
import {
|
||||
businessBrandAssetPath,
|
||||
resolveBusinessBrandAsset,
|
||||
type BusinessBrandAssets,
|
||||
} from "~/lib/business-branding";
|
||||
|
||||
// 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
|
||||
// non-raster logos are requested through the same on-the-fly PNG
|
||||
// rasterization the PDF export uses.
|
||||
function resolveEmailLogoUrl(
|
||||
business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined,
|
||||
business:
|
||||
| ({
|
||||
id?: string;
|
||||
} & BusinessBrandAssets)
|
||||
| null
|
||||
| undefined,
|
||||
baseUrl: string,
|
||||
): string | null {
|
||||
if (!business?.id || !business.logoStorageKey) return null;
|
||||
const needsRaster =
|
||||
business.logoMimeType != null &&
|
||||
!["image/png", "image/jpeg"].includes(business.logoMimeType);
|
||||
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
|
||||
if (!business?.id) return null;
|
||||
const asset = resolveBusinessBrandAsset(business, "logo", "light");
|
||||
if (!asset) return null;
|
||||
const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
|
||||
const path = businessBrandAssetPath(
|
||||
business.id,
|
||||
"logo",
|
||||
"light",
|
||||
needsRaster ? "png" : undefined,
|
||||
);
|
||||
return `${baseUrl.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
@@ -29,7 +45,8 @@ interface InvoiceEmailTemplateProps {
|
||||
name: string;
|
||||
email: string | null;
|
||||
};
|
||||
business?: {
|
||||
business?:
|
||||
| ({
|
||||
id?: string;
|
||||
name: string;
|
||||
nickname?: string | null;
|
||||
@@ -41,9 +58,8 @@ interface InvoiceEmailTemplateProps {
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoMimeType?: string | null;
|
||||
} | null;
|
||||
} & BusinessBrandAssets)
|
||||
| null;
|
||||
items: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
@@ -57,6 +73,7 @@ interface InvoiceEmailTemplateProps {
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
baseUrl?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export function generateInvoiceEmailTemplate({
|
||||
@@ -66,13 +83,14 @@ export function generateInvoiceEmailTemplate({
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl = getAppUrl(),
|
||||
timeZone = "America/New_York",
|
||||
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
return formatCalendarDate(date, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
@@ -83,7 +101,13 @@ export function generateInvoiceEmailTemplate({
|
||||
};
|
||||
|
||||
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 < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
formatCalendarDate,
|
||||
getEffectiveInvoiceStatus,
|
||||
} from "@beenvoice/domain";
|
||||
|
||||
interface ReminderEmailTemplateProps {
|
||||
invoice: {
|
||||
invoiceNumber: string;
|
||||
@@ -15,6 +20,7 @@ interface ReminderEmailTemplateProps {
|
||||
customMessage?: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export function generateReminderEmailTemplate({
|
||||
@@ -22,11 +28,18 @@ export function generateReminderEmailTemplate({
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } {
|
||||
timeZone = "America/New_York",
|
||||
}: ReminderEmailTemplateProps): {
|
||||
html: string;
|
||||
text: string;
|
||||
subject: string;
|
||||
} {
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format(
|
||||
new Date(date),
|
||||
);
|
||||
formatCalendarDate(date, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const formatCurrency = (amount: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
@@ -34,14 +47,14 @@ export function generateReminderEmailTemplate({
|
||||
currency: invoice.currency ?? "USD",
|
||||
}).format(amount);
|
||||
|
||||
const senderName =
|
||||
invoice.business?.name
|
||||
const senderName = invoice.business?.name
|
||||
? invoice.business.nickname
|
||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||
: 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)}`;
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
addCalendarDays,
|
||||
calendarDateFromLocalDate,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
export type ImportFormat = "csv" | "json";
|
||||
|
||||
export interface ImportItem {
|
||||
@@ -86,8 +91,9 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
|
||||
// ISO date (YYYY-MM-DD)
|
||||
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
|
||||
if (isoMatch) {
|
||||
const d = new Date(trimmed);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
const key = `${isoMatch[1]}-${isoMatch[2]}-${isoMatch[3]}`;
|
||||
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
|
||||
@@ -98,11 +104,11 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
|
||||
let year = parseInt(slashParts[2] ?? "2000", 10);
|
||||
if (year < 100) year += 2000;
|
||||
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);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -128,13 +134,11 @@ function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
|
||||
if (itemDates.length > 0) {
|
||||
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
|
||||
}
|
||||
return fallback ?? new Date();
|
||||
return fallback ?? calendarDateFromLocalDate(new Date());
|
||||
}
|
||||
|
||||
function defaultDueDate(issueDate: Date): Date {
|
||||
const due = new Date(issueDate);
|
||||
due.setDate(due.getDate() + 30);
|
||||
return due;
|
||||
return addCalendarDays(issueDate, 30);
|
||||
}
|
||||
|
||||
export function parseInvoiceCSV(
|
||||
@@ -262,7 +266,9 @@ function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
|
||||
const rate = item.rate ?? 0;
|
||||
|
||||
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) {
|
||||
errors.push(
|
||||
@@ -356,7 +362,9 @@ export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
|
||||
{
|
||||
name: "JSON Import",
|
||||
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(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
timeZone?: string,
|
||||
): EffectiveInvoiceStatus {
|
||||
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate, timeZone);
|
||||
}
|
||||
|
||||
export function isInvoiceOverdue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
timeZone?: string,
|
||||
): boolean {
|
||||
return isSharedInvoiceOverdue(storedStatus, dueDate);
|
||||
return isSharedInvoiceOverdue(storedStatus, dueDate, timeZone);
|
||||
}
|
||||
|
||||
export function getDaysPastDue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
timeZone?: string,
|
||||
): number {
|
||||
return getSharedDaysPastDue(storedStatus, dueDate);
|
||||
return getSharedDaysPastDue(storedStatus, dueDate, timeZone);
|
||||
}
|
||||
|
||||
export const statusConfig = {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "~/env";
|
||||
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
||||
import { sendEmail } from "@beenvoice/email";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||
import {
|
||||
@@ -10,6 +8,7 @@ import {
|
||||
} from "~/lib/reset-token";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
import { resolveEmailSender } from "~/server/services/email-sender";
|
||||
|
||||
export type PasswordResetResult = {
|
||||
success: boolean;
|
||||
@@ -22,15 +21,7 @@ export async function sendPasswordResetEmail(input: {
|
||||
userName?: string;
|
||||
resetToken: string;
|
||||
}): Promise<PasswordResetResult> {
|
||||
if (!env.RESEND_API_KEY) {
|
||||
console.warn(
|
||||
"Password reset requested, but RESEND_API_KEY is not configured.",
|
||||
);
|
||||
return { success: true, emailSent: false, userEmail: input.userEmail };
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = new Resend(env.RESEND_API_KEY);
|
||||
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`;
|
||||
const emailTemplate = generatePasswordResetEmailTemplate({
|
||||
userEmail: input.userEmail,
|
||||
@@ -39,10 +30,8 @@ export async function sendPasswordResetEmail(input: {
|
||||
resetUrl,
|
||||
expiryHours: 1,
|
||||
});
|
||||
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
|
||||
|
||||
await resend.emails.send({
|
||||
from: `beenvoice <noreply@${fromDomain}>`,
|
||||
await sendEmail({
|
||||
...resolveEmailSender(null, "beenvoice"),
|
||||
to: input.userEmail,
|
||||
subject: emailTemplate.subject,
|
||||
html: emailTemplate.html,
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
type Styles,
|
||||
} from "@react-pdf/renderer";
|
||||
import { saveAs } from "file-saver";
|
||||
import {
|
||||
isFixedLineItem,
|
||||
} from "~/lib/invoice-line-item";
|
||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
||||
import { isFixedLineItem } from "~/lib/invoice-line-item";
|
||||
import React from "react";
|
||||
import {
|
||||
type PdfFontFamily,
|
||||
@@ -19,6 +18,11 @@ import {
|
||||
pdfFontCacheKey,
|
||||
resolvePdfFonts,
|
||||
} from "~/lib/pdf-fonts";
|
||||
import {
|
||||
businessBrandAssetPath,
|
||||
resolveBusinessBrandAsset,
|
||||
type BusinessBrandAssets,
|
||||
} from "~/lib/business-branding";
|
||||
|
||||
// Fallback download function for better browser compatibility
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
@@ -74,7 +78,8 @@ export interface InvoiceData {
|
||||
taxRate: number;
|
||||
currency?: string | null;
|
||||
notes?: string | null;
|
||||
business?: {
|
||||
business?:
|
||||
| ({
|
||||
id?: string;
|
||||
name: string;
|
||||
nickname?: string | null;
|
||||
@@ -88,10 +93,9 @@ export interface InvoiceData {
|
||||
country?: string | null;
|
||||
website?: string | null;
|
||||
taxId?: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoMimeType?: string | null;
|
||||
hideNameWithLogo?: boolean | null;
|
||||
} | null;
|
||||
} & BusinessBrandAssets)
|
||||
| null;
|
||||
client?: {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
@@ -136,10 +140,7 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
|
||||
return { ...defaultPDFSettings, ...settings };
|
||||
}
|
||||
|
||||
function mapLegacyPdfFont(
|
||||
fontFamily: string,
|
||||
fonts: ResolvedPdfFonts,
|
||||
): string {
|
||||
function mapLegacyPdfFont(fontFamily: string, fonts: ResolvedPdfFonts): string {
|
||||
switch (fontFamily) {
|
||||
case "Helvetica-Bold":
|
||||
return fonts.bold;
|
||||
@@ -177,9 +178,7 @@ type PdfStyleBundle = {
|
||||
styles: typeof baseStyles;
|
||||
minimalStyles: typeof baseMinimalStyles;
|
||||
fonts: ResolvedPdfFonts;
|
||||
getStatusStyle: (
|
||||
status: string,
|
||||
) => Array<Record<string, string | number>>;
|
||||
getStatusStyle: (status: string) => Array<Record<string, string | number>>;
|
||||
};
|
||||
|
||||
const pdfStyleCache = new Map<string, PdfStyleBundle>();
|
||||
@@ -816,7 +815,7 @@ const formatCurrency = (amount: number, currency = "USD") => {
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
return formatCalendarDate(date, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@@ -854,12 +853,17 @@ function resolveBusinessLogoSrc(
|
||||
business: InvoiceData["business"],
|
||||
baseUrlOverride?: string,
|
||||
): string | null {
|
||||
if (!business?.id || !business.logoStorageKey) return null;
|
||||
if (!business?.id) return null;
|
||||
const asset = resolveBusinessBrandAsset(business, "logo", "light");
|
||||
if (!asset) return null;
|
||||
|
||||
const needsRaster =
|
||||
business.logoMimeType != null &&
|
||||
!["image/png", "image/jpeg"].includes(business.logoMimeType);
|
||||
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
|
||||
const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
|
||||
const path = businessBrandAssetPath(
|
||||
business.id,
|
||||
"logo",
|
||||
"light",
|
||||
needsRaster ? "png" : undefined,
|
||||
);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.origin}${path}`;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
DEFAULT_TIME_ZONE,
|
||||
getZonedDateTimeParts,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
export function invoiceLabel(inv: {
|
||||
invoicePrefix: string | null;
|
||||
invoiceNumber: string;
|
||||
@@ -37,12 +42,13 @@ export type TimeEntryListItem = {
|
||||
|
||||
export function groupEntriesByDate<T extends { startedAt: Date }>(
|
||||
entries: T[],
|
||||
timeZone = DEFAULT_TIME_ZONE,
|
||||
): { dateKey: string; label: string; entries: T[] }[] {
|
||||
const groups = new Map<string, T[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const d = new Date(entry.startedAt);
|
||||
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const parts = getZonedDateTimeParts(entry.startedAt, timeZone);
|
||||
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
||||
const existing = groups.get(dateKey);
|
||||
if (existing) {
|
||||
existing.push(entry);
|
||||
@@ -58,6 +64,7 @@ export function groupEntriesByDate<T extends { startedAt: Date }>(
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone,
|
||||
});
|
||||
return { dateKey, label, entries: groupEntries };
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export function proxy(request: NextRequest) {
|
||||
"/api/mcp",
|
||||
"/api/i",
|
||||
"/api/business-logo",
|
||||
"/api/health",
|
||||
];
|
||||
|
||||
// Allow API routes to pass through
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
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 { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
|
||||
|
||||
type Db = typeof db;
|
||||
|
||||
@@ -110,6 +111,10 @@ export async function syncLinkedInvoiceItem(
|
||||
const rate = entry.rate ?? 0;
|
||||
const amount = hours * rate;
|
||||
const description = resolveBillingDescription(entry.description ?? "");
|
||||
const owner = await database.query.users.findFirst({
|
||||
where: eq(users.id, linked.invoice.createdById),
|
||||
columns: { timeZone: true },
|
||||
});
|
||||
|
||||
await database
|
||||
.update(invoiceItems)
|
||||
@@ -118,7 +123,10 @@ export async function syncLinkedInvoiceItem(
|
||||
hours,
|
||||
rate,
|
||||
amount,
|
||||
date: entry.endedAt ?? entry.startedAt,
|
||||
date: calendarDateFromInstant(
|
||||
entry.endedAt ?? entry.startedAt,
|
||||
owner?.timeZone ?? "America/New_York",
|
||||
),
|
||||
})
|
||||
.where(eq(invoiceItems.id, linked.id));
|
||||
|
||||
@@ -136,7 +144,10 @@ export async function syncLinkedInvoiceItem(
|
||||
.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);
|
||||
if (!linked?.invoice) return;
|
||||
|
||||
@@ -190,6 +201,10 @@ export async function relinkTimeEntryToInvoice(
|
||||
});
|
||||
|
||||
if (!invoice) return null;
|
||||
const owner = await database.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { timeZone: true },
|
||||
});
|
||||
|
||||
return insertInvoiceLineForTimeEntry(database, {
|
||||
invoice,
|
||||
@@ -197,6 +212,9 @@ export async function relinkTimeEntryToInvoice(
|
||||
description: resolveBillingDescription(entry.description ?? ""),
|
||||
hours: entry.hours,
|
||||
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 { timeEntriesRouter } from "~/server/api/routers/time-entries";
|
||||
import { adminRouter } from "~/server/api/routers/admin";
|
||||
import { notificationsRouter } from "~/server/api/routers/notifications";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
apiKeys: apiKeysRouter,
|
||||
timeEntries: timeEntriesRouter,
|
||||
admin: adminRouter,
|
||||
notifications: notificationsRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -7,6 +7,11 @@ import { invoices } from "~/server/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { deleteObject, putObject } from "~/lib/object-storage";
|
||||
import { sanitizeSvg } from "~/lib/svg-sanitize";
|
||||
import {
|
||||
brandAssetKinds,
|
||||
brandAssetThemes,
|
||||
getBrandAssetFieldNames,
|
||||
} from "~/lib/business-branding";
|
||||
|
||||
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
|
||||
const allowedLogoMimeTypes = new Set([
|
||||
@@ -287,6 +292,7 @@ export const businessesRouter = createTRPCRouter({
|
||||
"Business not found or you don't have permission to delete it",
|
||||
);
|
||||
}
|
||||
const existingBusiness = business[0];
|
||||
|
||||
// Check if this business has any invoices
|
||||
const invoiceCount = await ctx.db
|
||||
@@ -300,6 +306,13 @@ export const businessesRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
const storageKeys = brandAssetKinds.flatMap((kind) =>
|
||||
brandAssetThemes.flatMap((theme) => {
|
||||
const [storageField] = getBrandAssetFieldNames(kind, theme);
|
||||
const storageKey = existingBusiness[storageField];
|
||||
return storageKey ? [storageKey] : [];
|
||||
}),
|
||||
);
|
||||
await ctx.db
|
||||
.delete(businesses)
|
||||
.where(
|
||||
@@ -309,6 +322,12 @@ export const businessesRouter = createTRPCRouter({
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[...new Set(storageKeys)].map((key) =>
|
||||
deleteObject(key).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
@@ -432,7 +451,8 @@ export const businessesRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
// Upload (or replace) a business logo, shown on invoices
|
||||
// Upload (or replace) a business brand asset. Kind/theme default to the
|
||||
// legacy combined/light logo so older clients remain compatible.
|
||||
uploadLogo: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -440,6 +460,8 @@ export const businessesRouter = createTRPCRouter({
|
||||
filename: z.string().min(1).max(255),
|
||||
mimeType: z.string().min(1).max(100),
|
||||
data: z.string().min(1),
|
||||
kind: z.enum(brandAssetKinds).default("logo"),
|
||||
theme: z.enum(brandAssetThemes).default("light"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -457,7 +479,8 @@ export const businessesRouter = createTRPCRouter({
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Business not found or you don't have permission to update it",
|
||||
message:
|
||||
"Business not found or you don't have permission to update it",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -465,7 +488,7 @@ export const businessesRouter = createTRPCRouter({
|
||||
if (!allowedLogoMimeTypes.has(mimeType)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Logo must be a PNG, JPEG, WebP, or SVG image",
|
||||
message: "Brand asset must be a PNG, JPEG, WebP, or SVG image",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -473,7 +496,7 @@ export const businessesRouter = createTRPCRouter({
|
||||
if (!body.length || body.length > MAX_LOGO_BYTES) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Logo must be between 1 byte and 5MB",
|
||||
message: "Brand asset must be between 1 byte and 5MB",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -482,20 +505,24 @@ export const businessesRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${crypto.randomUUID()}-${safeName}`;
|
||||
const previousStorageKey = business.logoStorageKey;
|
||||
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${input.kind}/${input.theme}/${crypto.randomUUID()}-${safeName}`;
|
||||
const [storageField, mimeField] = getBrandAssetFieldNames(
|
||||
input.kind,
|
||||
input.theme,
|
||||
);
|
||||
const previousStorageKey = business[storageField];
|
||||
|
||||
try {
|
||||
await putObject(storageKey, body, mimeType);
|
||||
} catch (error) {
|
||||
console.error("[businesses.uploadLogo] Failed to store logo", {
|
||||
console.error("[businesses.uploadLogo] Failed to store brand asset", {
|
||||
backendError: error,
|
||||
businessId: business.id,
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"Logo storage is unavailable. Check the object-storage service and try again.",
|
||||
"Brand asset storage is unavailable. Check the object-storage service and try again.",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -503,8 +530,8 @@ export const businessesRouter = createTRPCRouter({
|
||||
const [updatedBusiness] = await ctx.db
|
||||
.update(businesses)
|
||||
.set({
|
||||
logoStorageKey: storageKey,
|
||||
logoMimeType: mimeType,
|
||||
[storageField]: storageKey,
|
||||
[mimeField]: mimeType,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(businesses.id, business.id))
|
||||
@@ -517,9 +544,15 @@ export const businessesRouter = createTRPCRouter({
|
||||
return updatedBusiness;
|
||||
}),
|
||||
|
||||
// Remove a business logo
|
||||
// Remove one business brand asset. Defaults preserve older clients.
|
||||
removeLogo: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(brandAssetKinds).default("logo"),
|
||||
theme: z.enum(brandAssetThemes).default("light"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [business] = await ctx.db
|
||||
.select()
|
||||
@@ -535,17 +568,27 @@ export const businessesRouter = createTRPCRouter({
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Business not found or you don't have permission to update it",
|
||||
message:
|
||||
"Business not found or you don't have permission to update it",
|
||||
});
|
||||
}
|
||||
|
||||
if (business.logoStorageKey) {
|
||||
await deleteObject(business.logoStorageKey).catch(() => undefined);
|
||||
const [storageField, mimeField] = getBrandAssetFieldNames(
|
||||
input.kind,
|
||||
input.theme,
|
||||
);
|
||||
const storageKey = business[storageField];
|
||||
if (storageKey) {
|
||||
await deleteObject(storageKey).catch(() => undefined);
|
||||
}
|
||||
|
||||
const [updatedBusiness] = await ctx.db
|
||||
.update(businesses)
|
||||
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() })
|
||||
.set({
|
||||
[storageField]: null,
|
||||
[mimeField]: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(businesses.id, business.id))
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { and, desc, eq, gte, lt } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
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";
|
||||
|
||||
type LiteInvoice = {
|
||||
@@ -12,20 +16,28 @@ type LiteInvoice = {
|
||||
issueDate: Date;
|
||||
};
|
||||
|
||||
function buildRevenueMonthKeys(now: Date, count: number) {
|
||||
function buildRevenueMonthKeys(now: Date, count: number, timeZone: string) {
|
||||
const current = getZonedDateTimeParts(now, timeZone);
|
||||
const keys: string[] = [];
|
||||
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(
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
|
||||
`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
function aggregateDashboardMetrics(
|
||||
userInvoices: LiteInvoice[],
|
||||
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 pendingAmount = 0;
|
||||
@@ -34,7 +46,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
||||
let lastMonthRevenue = 0;
|
||||
|
||||
const revenueByMonth = Object.fromEntries(
|
||||
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]),
|
||||
buildRevenueMonthKeys(now, 6, timeZone).map((key) => [key, 0]),
|
||||
) as Record<string, number>;
|
||||
|
||||
const statusTotals: Record<
|
||||
@@ -58,6 +70,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
timeZone,
|
||||
);
|
||||
const amount = inv.totalAmount;
|
||||
const issueDate = new Date(inv.issueDate);
|
||||
@@ -67,14 +80,11 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
||||
|
||||
if (issueDate >= currentMonthStart) {
|
||||
currentMonthRevenue += amount;
|
||||
} else if (
|
||||
issueDate >= lastMonthStart &&
|
||||
issueDate < currentMonthStart
|
||||
) {
|
||||
} else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) {
|
||||
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];
|
||||
if (monthRevenue !== undefined) {
|
||||
revenueByMonth[revenueKey] = monthRevenue + amount;
|
||||
@@ -95,7 +105,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
||||
statusTotals[effectiveStatus].count += 1;
|
||||
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] ??= {
|
||||
month: monthKey,
|
||||
totalInvoices: 0,
|
||||
@@ -126,7 +136,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
||||
.map(([month, revenue]) => ({
|
||||
month,
|
||||
revenue,
|
||||
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||
monthLabel: formatCalendarDate(month + "-01", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
@@ -143,7 +153,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
||||
.slice(-6)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
|
||||
monthLabel: formatCalendarDate(item.month + "-01", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
@@ -167,6 +177,12 @@ export const dashboardRouter = createTRPCRouter({
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
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 [
|
||||
userInvoices,
|
||||
@@ -203,8 +219,14 @@ export const dashboardRouter = createTRPCRouter({
|
||||
ctx.db.query.invoices.findMany({
|
||||
where: and(
|
||||
eq(invoices.createdById, userId),
|
||||
gte(invoices.issueDate, new Date(now.getFullYear(), now.getMonth(), 1)),
|
||||
lt(invoices.issueDate, new Date(now.getFullYear(), now.getMonth() + 1, 1)),
|
||||
gte(
|
||||
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: [
|
||||
desc(invoices.issueDate),
|
||||
@@ -249,7 +271,7 @@ export const dashboardRouter = createTRPCRouter({
|
||||
}),
|
||||
]);
|
||||
|
||||
const metrics = aggregateDashboardMetrics(userInvoices, now);
|
||||
const metrics = aggregateDashboardMetrics(userInvoices, now, timeZone);
|
||||
|
||||
return {
|
||||
...metrics,
|
||||
|
||||
@@ -1,346 +1,196 @@
|
||||
import { isValidTimeZone } from "@beenvoice/domain/time-zone";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { Resend } from "resend";
|
||||
import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
|
||||
import { invoices, platformSettings } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "~/env";
|
||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||
|
||||
import { getRequestOrigin } from "~/lib/app-url";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
|
||||
import { backgroundJobs, invoices } from "~/server/db/schema";
|
||||
import { enqueueJob, jobTypes } from "~/server/jobs/queue";
|
||||
import { deliverInvoiceEmail } from "~/server/services/send-invoice-email";
|
||||
|
||||
function plainTextToHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function normalizeEmailNoteHtml(value: string) {
|
||||
const visibleText = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ |\u00a0/g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
|
||||
return visibleText ? value.trim() : "";
|
||||
}
|
||||
const emailOptionsSchema = z.object({
|
||||
invoiceId: z.string().min(1),
|
||||
customSubject: z.string().max(500).optional(),
|
||||
customContent: z.string().max(50_000).optional(),
|
||||
customMessage: z.string().max(10_000).optional(),
|
||||
useHtml: z.boolean().default(false),
|
||||
ccEmails: z.string().max(2_000).optional(),
|
||||
bccEmails: z.string().max(2_000).optional(),
|
||||
});
|
||||
|
||||
export const emailRouter = createTRPCRouter({
|
||||
sendInvoice: sessionProcedure
|
||||
.input(emailOptionsSchema)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deliverInvoiceEmail({
|
||||
...input,
|
||||
actorUserId: ctx.session.user.id,
|
||||
baseUrl: getRequestOrigin(ctx.headers),
|
||||
}),
|
||||
),
|
||||
|
||||
scheduleInvoice: sessionProcedure
|
||||
.input(
|
||||
z.object({
|
||||
invoiceId: z.string(),
|
||||
customSubject: z.string().optional(),
|
||||
customContent: z.string().optional(),
|
||||
customMessage: z.string().optional(),
|
||||
useHtml: z.boolean().default(false),
|
||||
ccEmails: z.string().optional(),
|
||||
bccEmails: z.string().optional(),
|
||||
emailOptionsSchema.extend({
|
||||
scheduledAt: z.coerce.date(),
|
||||
timeZone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.refine(isValidTimeZone, "Invalid time zone"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Fetch invoice with relations
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.invoiceId),
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
items: true,
|
||||
},
|
||||
where: and(
|
||||
eq(invoices.id, input.invoiceId),
|
||||
eq(invoices.createdById, ctx.session.user.id),
|
||||
),
|
||||
with: { client: true, items: true },
|
||||
});
|
||||
if (!invoice)
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error("Invoice not found");
|
||||
}
|
||||
|
||||
// Check if invoice belongs to the current user
|
||||
if (invoice.createdById !== ctx.session.user.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
if (!invoice.client?.email) {
|
||||
throw new Error("Client has no email address");
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Client has no email address",
|
||||
});
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invoice.client.email)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid client email address format",
|
||||
});
|
||||
}
|
||||
|
||||
if (!invoice.items.length) {
|
||||
throw new Error("Add at least one line item before sending this invoice");
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(invoice.client.email)) {
|
||||
throw new Error("Invalid client email address format");
|
||||
}
|
||||
|
||||
// Generate PDF for attachment
|
||||
let pdfBuffer: Buffer;
|
||||
try {
|
||||
const settings = await ctx.db.query.platformSettings.findFirst({
|
||||
where: eq(platformSettings.id, "global"),
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Add at least one line item before sending this invoice",
|
||||
});
|
||||
const pdfBlob = await generateInvoicePDFBlob(
|
||||
invoice,
|
||||
{
|
||||
pdfTemplate: settings?.pdfTemplate as
|
||||
| "classic"
|
||||
| "minimal"
|
||||
| undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
},
|
||||
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
|
||||
);
|
||||
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||
|
||||
// Validate PDF was generated successfully
|
||||
if (pdfBuffer.length === 0) {
|
||||
throw new Error("Generated PDF is empty");
|
||||
}
|
||||
} catch (pdfError) {
|
||||
console.error("PDF generation error:", pdfError);
|
||||
// Re-throw the original error with more context
|
||||
if (pdfError instanceof Error) {
|
||||
throw new Error(
|
||||
`Failed to generate invoice PDF for attachment: ${pdfError.message}`,
|
||||
);
|
||||
}
|
||||
throw new Error("Failed to generate invoice PDF for attachment");
|
||||
}
|
||||
|
||||
// Create email content
|
||||
const subject =
|
||||
input.customSubject ??
|
||||
`Invoice ${invoice.invoiceNumber} from ${invoice.business ? `${invoice.business.name}${invoice.business.nickname ? ` (${invoice.business.nickname})` : ""}` : "Your Business"}`;
|
||||
|
||||
const userName =
|
||||
invoice.business?.emailFromName ??
|
||||
invoice.business?.name ??
|
||||
ctx.session.user?.name ??
|
||||
"Your Name";
|
||||
const userEmail =
|
||||
invoice.business?.email ?? ctx.session.user?.email ?? "";
|
||||
const customMessage =
|
||||
input.customMessage !== undefined
|
||||
? normalizeEmailNoteHtml(input.customMessage)
|
||||
: invoice.emailMessage
|
||||
? plainTextToHtml(invoice.emailMessage)
|
||||
: undefined;
|
||||
|
||||
// Generate branded email template
|
||||
const emailTemplate = generateInvoiceEmailTemplate({
|
||||
invoice: {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issueDate: invoice.issueDate,
|
||||
dueDate: invoice.dueDate,
|
||||
status: invoice.status,
|
||||
totalAmount: invoice.totalAmount,
|
||||
taxRate: invoice.taxRate,
|
||||
currency: invoice.currency,
|
||||
client: {
|
||||
name: invoice.client.name,
|
||||
email: invoice.client.email,
|
||||
},
|
||||
business: invoice.business,
|
||||
items: invoice.items,
|
||||
},
|
||||
customContent: input.customContent,
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl: getRequestOrigin(ctx.headers),
|
||||
if (input.scheduledAt.getTime() < Date.now() + 60_000) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Choose a send time at least one minute in the future",
|
||||
});
|
||||
|
||||
// Determine Resend instance and email configuration to use
|
||||
let resendInstance: Resend;
|
||||
let fromEmail: string;
|
||||
|
||||
// Check if business has custom Resend configuration
|
||||
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
|
||||
// Use business's custom Resend setup
|
||||
resendInstance = new Resend(invoice.business.resendApiKey);
|
||||
const fromName =
|
||||
invoice.business.emailFromName ??
|
||||
(invoice.business.nickname
|
||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||
: invoice.business.name) ??
|
||||
userName;
|
||||
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
|
||||
} else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
|
||||
// Use system Resend configuration
|
||||
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
||||
} else if (env.RESEND_API_KEY) {
|
||||
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Email delivery is not configured. Add a Resend API key globally or on this business.",
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare CC and BCC lists
|
||||
const ccEmails: string[] = [];
|
||||
const bccEmails: string[] = [];
|
||||
|
||||
// Parse CC emails from input
|
||||
if (input.ccEmails) {
|
||||
const ccList = input.ccEmails
|
||||
.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email);
|
||||
for (const email of ccList) {
|
||||
if (emailRegex.test(email)) {
|
||||
ccEmails.push(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse BCC emails from input
|
||||
if (input.bccEmails) {
|
||||
const bccList = input.bccEmails
|
||||
.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email);
|
||||
for (const email of bccList) {
|
||||
if (emailRegex.test(email)) {
|
||||
bccEmails.push(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include business email in CC if it exists and is different from sender
|
||||
if (invoice.business?.email && invoice.business.email !== fromEmail) {
|
||||
// Validate business email format before adding to CC
|
||||
if (emailRegex.test(invoice.business.email)) {
|
||||
ccEmails.push(invoice.business.email);
|
||||
}
|
||||
}
|
||||
|
||||
// Send email with Resend
|
||||
let emailResult;
|
||||
try {
|
||||
// Send HTML email with plain text fallback
|
||||
emailResult = await resendInstance.emails.send({
|
||||
from: fromEmail,
|
||||
to: [invoice.client?.email ?? ""],
|
||||
cc: ccEmails.length > 0 ? ccEmails : undefined,
|
||||
bcc: bccEmails.length > 0 ? bccEmails : undefined,
|
||||
subject: subject,
|
||||
html: emailTemplate.html,
|
||||
text: emailTemplate.text,
|
||||
headers: {
|
||||
"X-Priority": "3",
|
||||
"X-MSMail-Priority": "Normal",
|
||||
"X-Mailer": "beenvoice",
|
||||
"MIME-Version": "1.0",
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
filename: `invoice-${invoice.invoiceNumber}.pdf`,
|
||||
content: pdfBuffer,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Email service is currently unavailable. Please try again later.",
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced error checking
|
||||
if (emailResult.error) {
|
||||
const errorMsg = emailResult.error.message?.toLowerCase() ?? "";
|
||||
|
||||
// Provide more specific error messages based on error type
|
||||
if (
|
||||
errorMsg.includes("invalid email") ||
|
||||
errorMsg.includes("invalid recipient")
|
||||
invoice.scheduledSendJobId &&
|
||||
invoice.scheduledSendStatus === "processing"
|
||||
) {
|
||||
throw new Error("Invalid recipient email address");
|
||||
} else if (
|
||||
errorMsg.includes("domain") ||
|
||||
errorMsg.includes("not verified")
|
||||
) {
|
||||
throw new Error(
|
||||
"Email domain not verified. Please configure your Resend domain in business settings.",
|
||||
);
|
||||
} else if (
|
||||
errorMsg.includes("rate limit") ||
|
||||
errorMsg.includes("too many")
|
||||
) {
|
||||
throw new Error("Rate limit exceeded. Please try again later.");
|
||||
} else if (
|
||||
errorMsg.includes("api key") ||
|
||||
errorMsg.includes("unauthorized")
|
||||
) {
|
||||
throw new Error(
|
||||
"Email service configuration error. Please check your Resend API key.",
|
||||
);
|
||||
} else if (
|
||||
errorMsg.includes("attachment") ||
|
||||
errorMsg.includes("file size")
|
||||
) {
|
||||
throw new Error("Invoice PDF is too large to send via email.");
|
||||
} else {
|
||||
throw new Error(
|
||||
`Email delivery failed: ${emailResult.error.message ?? "Unknown error"}`,
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "This invoice is already being sent",
|
||||
});
|
||||
}
|
||||
const idempotencyKey = `${jobTypes.sendInvoice}:${invoice.id}:${input.scheduledAt.toISOString()}:${crypto.randomUUID()}`;
|
||||
const job = await enqueueJob({
|
||||
type: jobTypes.sendInvoice,
|
||||
idempotencyKey,
|
||||
runAt: input.scheduledAt,
|
||||
payload: {
|
||||
invoiceId: invoice.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
customSubject: input.customSubject,
|
||||
customContent: input.customContent,
|
||||
customMessage: input.customMessage,
|
||||
useHtml: input.useHtml,
|
||||
ccEmails: input.ccEmails,
|
||||
bccEmails: input.bccEmails,
|
||||
timeZone: input.timeZone,
|
||||
},
|
||||
});
|
||||
if (!job) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Unable to schedule invoice",
|
||||
});
|
||||
}
|
||||
|
||||
if (!emailResult.data?.id) {
|
||||
throw new Error(
|
||||
"Email was not sent successfully - no delivery ID received",
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
if (
|
||||
invoice.scheduledSendJobId &&
|
||||
invoice.scheduledSendStatus === "pending"
|
||||
) {
|
||||
await tx
|
||||
.update(backgroundJobs)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(backgroundJobs.id, invoice.scheduledSendJobId),
|
||||
eq(backgroundJobs.status, "pending"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update invoice status to "sent" if it was draft
|
||||
if (invoice.status === "draft") {
|
||||
try {
|
||||
await ctx.db
|
||||
await tx
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: "sent",
|
||||
scheduledSendAt: input.scheduledAt,
|
||||
scheduledSendTimeZone: input.timeZone,
|
||||
scheduledSendJobId: job.id,
|
||||
scheduledSendStatus: "pending",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, input.invoiceId));
|
||||
} catch {
|
||||
// Don't throw here - email was sent successfully, status update is secondary
|
||||
}
|
||||
}
|
||||
.where(eq(invoices.id, invoice.id));
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
emailId: emailResult.data.id,
|
||||
message: `Invoice sent successfully to ${invoice.client?.email ?? "client"}${ccEmails.length > 0 ? ` (CC: ${ccEmails.join(", ")})` : ""}${bccEmails.length > 0 ? ` (BCC: ${bccEmails.join(", ")})` : ""}`,
|
||||
deliveryDetails: {
|
||||
to: invoice.client?.email ?? "",
|
||||
cc: ccEmails,
|
||||
bcc: bccEmails,
|
||||
sentAt: new Date().toISOString(),
|
||||
},
|
||||
jobId: job.id,
|
||||
scheduledAt: input.scheduledAt.toISOString(),
|
||||
timeZone: input.timeZone,
|
||||
};
|
||||
}),
|
||||
|
||||
cancelScheduledInvoice: sessionProcedure
|
||||
.input(z.object({ invoiceId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: and(
|
||||
eq(invoices.id, input.invoiceId),
|
||||
eq(invoices.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!invoice)
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
if (
|
||||
!invoice.scheduledSendJobId ||
|
||||
invoice.scheduledSendStatus !== "pending"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "This scheduled send can no longer be cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
const cancelled = await ctx.db
|
||||
.update(backgroundJobs)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(backgroundJobs.id, invoice.scheduledSendJobId),
|
||||
eq(backgroundJobs.status, "pending"),
|
||||
),
|
||||
)
|
||||
.returning({ id: backgroundJobs.id });
|
||||
if (!cancelled.length) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "The worker has already started sending this invoice",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(invoices)
|
||||
.set({ scheduledSendStatus: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invoice.id));
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { sendEmail } from "@beenvoice/email";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
@@ -12,17 +13,18 @@ import {
|
||||
clients,
|
||||
businesses,
|
||||
platformSettings,
|
||||
users,
|
||||
backgroundJobs,
|
||||
} from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { getRequestOrigin } from "~/lib/app-url";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "~/env";
|
||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
|
||||
import type { db } from "~/server/db";
|
||||
import { resolveEmailSender } from "~/server/services/email-sender";
|
||||
import { jobTypes } from "~/server/jobs/queue";
|
||||
|
||||
type InvoiceRouterContext = {
|
||||
db: typeof db;
|
||||
@@ -204,9 +206,7 @@ function findExistingClient(
|
||||
|
||||
if (clientRef.email?.trim()) {
|
||||
const email = clientRef.email.trim().toLowerCase();
|
||||
const byEmail = userClients.find(
|
||||
(c) => c.email?.toLowerCase() === email,
|
||||
);
|
||||
const byEmail = userClients.find((c) => c.email?.toLowerCase() === email);
|
||||
if (byEmail) return byEmail;
|
||||
}
|
||||
|
||||
@@ -235,20 +235,24 @@ function deriveIssueDateFromItems(
|
||||
export const invoicesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
z
|
||||
.object({
|
||||
status: z.enum(["draft", "sent", "paid"]).optional(),
|
||||
clientId: z.string().optional(),
|
||||
}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const conditions = [eq(invoices.createdById, ctx.session.user.id)];
|
||||
if (input?.status) conditions.push(eq(invoices.status, input.status));
|
||||
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId));
|
||||
if (input?.clientId)
|
||||
conditions.push(eq(invoices.clientId, input.clientId));
|
||||
|
||||
return await ctx.db.query.invoices.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
createdBy: { columns: { timeZone: true } },
|
||||
business: true,
|
||||
client: true,
|
||||
items: {
|
||||
@@ -282,7 +286,8 @@ export const invoicesRouter = createTRPCRouter({
|
||||
eq(invoices.createdById, ctx.session.user.id),
|
||||
eq(invoices.status, "draft"),
|
||||
];
|
||||
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId));
|
||||
if (input?.clientId)
|
||||
conditions.push(eq(invoices.clientId, input.clientId));
|
||||
|
||||
return ctx.db.query.invoices.findMany({
|
||||
where: and(...conditions),
|
||||
@@ -346,6 +351,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
const currentInvoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.createdById, ctx.session.user.id),
|
||||
with: {
|
||||
createdBy: { columns: { timeZone: true } },
|
||||
business: true,
|
||||
client: true,
|
||||
items: {
|
||||
@@ -385,6 +391,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
with: {
|
||||
createdBy: { columns: { timeZone: true } },
|
||||
business: true,
|
||||
client: true,
|
||||
items: {
|
||||
@@ -451,10 +458,16 @@ export const invoicesRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
const invoiceId = crypto.randomUUID();
|
||||
const sendReminderJobId = cleanInvoiceData.sendReminderAt
|
||||
? crypto.randomUUID()
|
||||
: null;
|
||||
const [invoice] = await tx
|
||||
.insert(invoices)
|
||||
.values({
|
||||
id: invoiceId,
|
||||
...cleanInvoiceData,
|
||||
sendReminderJobId,
|
||||
totalAmount,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
@@ -478,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;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -560,6 +584,34 @@ export const invoicesRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
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) {
|
||||
const totalAmount = calculateInvoiceTotal(
|
||||
items,
|
||||
@@ -570,6 +622,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
.update(invoices)
|
||||
.set({
|
||||
...cleanInvoiceData,
|
||||
...reminderJobPatch,
|
||||
totalAmount,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -600,6 +653,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
.update(invoices)
|
||||
.set({
|
||||
...cleanInvoiceData,
|
||||
...reminderJobPatch,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, id))
|
||||
@@ -897,8 +951,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
|
||||
invoicesCreated++;
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Unknown error";
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
rowErrors.push(`${label}: ${msg}`);
|
||||
}
|
||||
}
|
||||
@@ -1006,7 +1059,9 @@ export const invoicesRouter = createTRPCRouter({
|
||||
// ── Public token (shareable link) ──────────────────────────────────────────
|
||||
|
||||
generatePublicToken: sessionProcedure
|
||||
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
|
||||
.input(
|
||||
z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
@@ -1048,6 +1103,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
where: eq(invoices.publicToken, input.token),
|
||||
with: {
|
||||
client: true,
|
||||
createdBy: { columns: { timeZone: true } },
|
||||
// Explicit allowlist: this is a publicProcedure — never let
|
||||
// secret fields (resendApiKey, resendDomain) reach an
|
||||
// unauthenticated caller via the business relation.
|
||||
@@ -1068,6 +1124,16 @@ export const invoicesRouter = createTRPCRouter({
|
||||
taxId: true,
|
||||
logoStorageKey: true,
|
||||
logoMimeType: true,
|
||||
logoDarkStorageKey: true,
|
||||
logoDarkMimeType: true,
|
||||
wordmarkLightStorageKey: true,
|
||||
wordmarkLightMimeType: true,
|
||||
wordmarkDarkStorageKey: true,
|
||||
wordmarkDarkMimeType: true,
|
||||
iconLightStorageKey: true,
|
||||
iconLightMimeType: true,
|
||||
iconDarkStorageKey: true,
|
||||
iconDarkMimeType: true,
|
||||
hideNameWithLogo: true,
|
||||
},
|
||||
},
|
||||
@@ -1081,8 +1147,14 @@ export const invoicesRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
if (!invoice) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "This link has expired" });
|
||||
if (
|
||||
invoice.publicTokenExpiresAt &&
|
||||
new Date(invoice.publicTokenExpiresAt) < new Date()
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "This link has expired",
|
||||
});
|
||||
}
|
||||
return invoice;
|
||||
}),
|
||||
@@ -1100,12 +1172,22 @@ export const invoicesRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
if (!invoice.client?.email) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Client has no email address" });
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Client has no email address",
|
||||
});
|
||||
}
|
||||
|
||||
const userName =
|
||||
invoice.business?.emailFromName ?? invoice.business?.name ?? ctx.session.user.name ?? "";
|
||||
invoice.business?.emailFromName ??
|
||||
invoice.business?.name ??
|
||||
ctx.session.user.name ??
|
||||
"";
|
||||
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({
|
||||
invoice: {
|
||||
@@ -1120,40 +1202,23 @@ export const invoicesRouter = createTRPCRouter({
|
||||
customMessage: input.customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
timeZone: owner?.timeZone ?? "America/New_York",
|
||||
});
|
||||
|
||||
// Resolve Resend instance (same two-tier logic as email router)
|
||||
let resendInstance: Resend;
|
||||
let fromEmail: string;
|
||||
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
|
||||
resendInstance = new Resend(invoice.business.resendApiKey);
|
||||
const fromName = invoice.business.emailFromName ?? invoice.business.name;
|
||||
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
|
||||
} else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
|
||||
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
||||
} else if (env.RESEND_API_KEY) {
|
||||
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
|
||||
} else {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Email delivery is not configured. Add a Resend API key.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await resendInstance.emails.send({
|
||||
from: fromEmail,
|
||||
try {
|
||||
await sendEmail({
|
||||
...resolveEmailSender(invoice.business, userName || "beenvoice"),
|
||||
to: [invoice.client.email],
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
idempotencyKey: `invoice-reminder:${invoice.id}:${Date.now()}`,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: result.error.message,
|
||||
message:
|
||||
error instanceof Error ? error.message : "Email delivery failed",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -1,109 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import { and, eq, lte } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import {
|
||||
recurringInvoices,
|
||||
recurringInvoiceItems,
|
||||
invoices,
|
||||
invoiceItems,
|
||||
clients,
|
||||
businesses,
|
||||
} from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { db as DbType } from "~/server/db";
|
||||
import { generateInvoiceFromRecurring } from "~/server/services/recurring-invoices";
|
||||
import {
|
||||
DEFAULT_TIME_ZONE,
|
||||
isValidTimeZone,
|
||||
zonedDateTimeToInstant,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
export function nextDueDate(schedule: string, from = new Date()): Date {
|
||||
const d = new Date(from);
|
||||
switch (schedule) {
|
||||
case "weekly": d.setDate(d.getDate() + 7); break;
|
||||
case "biweekly": d.setDate(d.getDate() + 14); break;
|
||||
case "monthly": d.setMonth(d.getMonth() + 1); break;
|
||||
case "quarterly": d.setMonth(d.getMonth() + 3); break;
|
||||
case "yearly": d.setFullYear(d.getFullYear() + 1); break;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
|
||||
items: (typeof recurringInvoiceItems.$inferSelect)[];
|
||||
};
|
||||
|
||||
export async function generateInvoiceFromRecurring(
|
||||
db: typeof DbType,
|
||||
recurring: RecurringWithItems,
|
||||
): Promise<{ id: string }> {
|
||||
const now = new Date();
|
||||
const invoiceNumber = `REC-${Date.now()}`;
|
||||
|
||||
const subtotal = recurring.items.reduce((s, i) => s + i.hours * i.rate, 0);
|
||||
const taxAmount = (subtotal * recurring.taxRate) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
const [newInvoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
invoiceNumber,
|
||||
invoicePrefix: recurring.invoicePrefix ?? "#",
|
||||
clientId: recurring.clientId,
|
||||
businessId: recurring.businessId ?? null,
|
||||
issueDate: now,
|
||||
dueDate: nextDueDate("monthly", now),
|
||||
status: "draft",
|
||||
totalAmount: total,
|
||||
taxRate: recurring.taxRate,
|
||||
notes: recurring.notes ?? null,
|
||||
emailMessage: recurring.emailMessage ?? null,
|
||||
currency: recurring.currency,
|
||||
createdById: recurring.createdById,
|
||||
})
|
||||
.returning({ id: invoices.id });
|
||||
|
||||
if (!newInvoice) throw new Error("Failed to create invoice");
|
||||
|
||||
if (recurring.items.length > 0) {
|
||||
await db.insert(invoiceItems).values(
|
||||
recurring.items.map((item, idx) => ({
|
||||
invoiceId: newInvoice.id,
|
||||
date: now,
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.hours * item.rate,
|
||||
position: item.position ?? idx,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return newInvoice;
|
||||
}
|
||||
|
||||
export async function generateDueRecurringInvoices(db: typeof DbType): Promise<number> {
|
||||
const now = new Date();
|
||||
const due = await db.query.recurringInvoices.findMany({
|
||||
where: and(
|
||||
eq(recurringInvoices.status, "active"),
|
||||
lte(recurringInvoices.nextDueAt, now),
|
||||
),
|
||||
with: { items: true },
|
||||
});
|
||||
|
||||
let generated = 0;
|
||||
for (const rec of due) {
|
||||
try {
|
||||
await generateInvoiceFromRecurring(db, rec);
|
||||
await db
|
||||
.update(recurringInvoices)
|
||||
.set({ lastGeneratedAt: now, nextDueAt: nextDueDate(rec.schedule, now) })
|
||||
.where(eq(recurringInvoices.id, rec.id));
|
||||
generated++;
|
||||
} catch {
|
||||
// continue on individual failures
|
||||
}
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
const scheduleEnum = z.enum(["weekly", "biweekly", "monthly", "quarterly", "yearly"]);
|
||||
const scheduleEnum = z.enum([
|
||||
"weekly",
|
||||
"biweekly",
|
||||
"monthly",
|
||||
"quarterly",
|
||||
"yearly",
|
||||
]);
|
||||
|
||||
const recurringItemSchema = z.object({
|
||||
description: z.string().min(1),
|
||||
@@ -122,9 +40,27 @@ const recurringInvoiceSchema = z.object({
|
||||
currency: z.string().length(3).default("USD"),
|
||||
notes: 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),
|
||||
});
|
||||
|
||||
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({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.query.recurringInvoices.findMany({
|
||||
@@ -141,14 +77,20 @@ export const recurringInvoicesRouter = createTRPCRouter({
|
||||
where: eq(clients.id, input.clientId),
|
||||
});
|
||||
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) {
|
||||
const biz = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, input.businessId),
|
||||
});
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +107,8 @@ export const recurringInvoicesRouter = createTRPCRouter({
|
||||
currency: input.currency,
|
||||
notes: input.notes ?? null,
|
||||
emailMessage: input.emailMessage ?? null,
|
||||
nextDueAt: nextDueDate(input.schedule),
|
||||
nextDueAt: parseNextRun(input),
|
||||
timeZone: input.timeZone,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning({ id: recurringInvoices.id });
|
||||
@@ -207,6 +150,8 @@ export const recurringInvoicesRouter = createTRPCRouter({
|
||||
currency: input.currency,
|
||||
notes: input.notes ?? null,
|
||||
emailMessage: input.emailMessage ?? null,
|
||||
nextDueAt: parseNextRun(input),
|
||||
timeZone: input.timeZone,
|
||||
})
|
||||
.where(eq(recurringInvoices.id, input.id));
|
||||
|
||||
@@ -285,11 +230,12 @@ export const recurringInvoicesRouter = createTRPCRouter({
|
||||
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
|
||||
.update(recurringInvoices)
|
||||
.set({ lastGeneratedAt: new Date(), nextDueAt: nextDueDate(rec.schedule) })
|
||||
.set({ lastGeneratedAt: now })
|
||||
.where(eq(recurringInvoices.id, input.id));
|
||||
|
||||
return { invoiceId: newInvoice.id };
|
||||
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
type ColorMode,
|
||||
} from "~/lib/branding";
|
||||
import { revokeUserSessions } from "~/lib/session-security";
|
||||
import {
|
||||
DEFAULT_TIME_ZONE,
|
||||
isValidTimeZone,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
function resolveBusinessId(
|
||||
refs: { businessName?: string; businessNickname?: string },
|
||||
@@ -156,6 +160,7 @@ const RecurringInvoiceBackupSchema = z.object({
|
||||
currency: z.string().default("USD"),
|
||||
notes: z.string().optional(),
|
||||
emailMessage: z.string().optional(),
|
||||
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
|
||||
nextDueAt: z.coerce.date(),
|
||||
lastGeneratedAt: z.coerce.date().optional(),
|
||||
items: z.array(RecurringInvoiceItemBackupSchema),
|
||||
@@ -197,6 +202,7 @@ const BackupDataSchema = z.object({
|
||||
prefersReducedMotion: z.boolean().optional(),
|
||||
animationSpeedMultiplier: z.number().optional(),
|
||||
theme: z.string().optional(),
|
||||
timeZone: z.string().refine(isValidTimeZone).optional(),
|
||||
onboardingCompletedAt: z.coerce.date().nullable().optional(),
|
||||
}),
|
||||
clients: z.array(ClientBackupSchema),
|
||||
@@ -291,6 +297,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
email: true,
|
||||
image: true,
|
||||
role: true,
|
||||
timeZone: true,
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
@@ -507,6 +514,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -514,6 +522,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
.update(users)
|
||||
.set({
|
||||
name: input.name,
|
||||
timeZone: input.timeZone,
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
|
||||
@@ -621,6 +630,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
prefersReducedMotion: true,
|
||||
animationSpeedMultiplier: true,
|
||||
theme: true,
|
||||
timeZone: true,
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
@@ -759,6 +769,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
prefersReducedMotion: user?.prefersReducedMotion ?? false,
|
||||
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
|
||||
theme: user?.theme ?? "system",
|
||||
timeZone: user?.timeZone ?? DEFAULT_TIME_ZONE,
|
||||
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
|
||||
},
|
||||
clients: userClients.map((client) => ({
|
||||
@@ -835,6 +846,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
currency: recurring.currency,
|
||||
notes: recurring.notes ?? undefined,
|
||||
emailMessage: recurring.emailMessage ?? undefined,
|
||||
timeZone: recurring.timeZone,
|
||||
nextDueAt: recurring.nextDueAt,
|
||||
lastGeneratedAt: recurring.lastGeneratedAt ?? undefined,
|
||||
items: recurring.items,
|
||||
@@ -1002,6 +1014,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
currency: recurringData.currency,
|
||||
notes: recurringData.notes,
|
||||
emailMessage: recurringData.emailMessage,
|
||||
timeZone: recurringData.timeZone,
|
||||
nextDueAt: recurringData.nextDueAt,
|
||||
lastGeneratedAt: recurringData.lastGeneratedAt,
|
||||
createdById: userId,
|
||||
@@ -1110,6 +1123,9 @@ export const settingsRouter = createTRPCRouter({
|
||||
...(input.user.animationSpeedMultiplier !== undefined && {
|
||||
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
|
||||
}),
|
||||
...(input.user.timeZone !== undefined && {
|
||||
timeZone: input.user.timeZone,
|
||||
}),
|
||||
...(input.user.theme !== undefined && {
|
||||
theme: input.user.theme,
|
||||
}),
|
||||
@@ -1196,14 +1212,21 @@ export const settingsRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const [receiptObjects, logoObjects] = await Promise.all([
|
||||
const [receiptObjects, brandObjects] = await Promise.all([
|
||||
ctx.db
|
||||
.select({ storageKey: expenseReceipts.storageKey })
|
||||
.from(expenseReceipts)
|
||||
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
|
||||
.where(eq(expenses.createdById, userId)),
|
||||
ctx.db
|
||||
.select({ storageKey: businesses.logoStorageKey })
|
||||
.select({
|
||||
logo: businesses.logoStorageKey,
|
||||
logoDark: businesses.logoDarkStorageKey,
|
||||
wordmarkLight: businesses.wordmarkLightStorageKey,
|
||||
wordmarkDark: businesses.wordmarkDarkStorageKey,
|
||||
iconLight: businesses.iconLightStorageKey,
|
||||
iconDark: businesses.iconDarkStorageKey,
|
||||
})
|
||||
.from(businesses)
|
||||
.where(eq(businesses.createdById, userId)),
|
||||
]);
|
||||
@@ -1211,9 +1234,16 @@ export const settingsRouter = createTRPCRouter({
|
||||
// Delete uploaded personal data before removing its database pointers. If object
|
||||
// storage is unavailable, the account remains intact so the user can retry.
|
||||
await Promise.all(
|
||||
[...receiptObjects, ...logoObjects].flatMap(({ storageKey }) =>
|
||||
storageKey ? [deleteObject(storageKey)] : [],
|
||||
[
|
||||
...receiptObjects.flatMap(({ storageKey }) =>
|
||||
storageKey ? [storageKey] : [],
|
||||
),
|
||||
...brandObjects.flatMap((assets) =>
|
||||
Object.values(assets).filter((storageKey): storageKey is string =>
|
||||
Boolean(storageKey),
|
||||
),
|
||||
),
|
||||
].map((storageKey) => deleteObject(storageKey)),
|
||||
);
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
||||
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 type { db } from "~/server/db";
|
||||
import {
|
||||
@@ -17,6 +23,7 @@ import {
|
||||
removeLinkedInvoiceItem,
|
||||
syncLinkedInvoiceItem,
|
||||
} from "~/server/api/lib/time-entry-invoice-sync";
|
||||
import { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
|
||||
|
||||
type Db = typeof db;
|
||||
|
||||
@@ -55,20 +62,31 @@ function computeHours(startedAt: Date, endedAt: Date): number {
|
||||
|
||||
async function addEntryToInvoice(
|
||||
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,
|
||||
description: string,
|
||||
hours: number,
|
||||
rate: number,
|
||||
date: Date,
|
||||
): 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, {
|
||||
invoice,
|
||||
entryId,
|
||||
description,
|
||||
hours,
|
||||
rate,
|
||||
date,
|
||||
date: calendarDateFromInstant(date, owner?.timeZone ?? "America/New_York"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,11 +118,21 @@ async function findOrCreateDraftInvoice(
|
||||
if (!client) return null;
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
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
|
||||
.insert(invoices)
|
||||
.values({
|
||||
@@ -135,10 +163,23 @@ async function addEntryToLatestInvoice(
|
||||
hours: number,
|
||||
rate: number,
|
||||
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);
|
||||
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(
|
||||
@@ -150,7 +191,11 @@ async function addEntryToSpecificInvoice(
|
||||
hours: number,
|
||||
rate: number,
|
||||
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({
|
||||
where: and(
|
||||
eq(invoices.id, invoiceId),
|
||||
@@ -161,7 +206,16 @@ async function addEntryToSpecificInvoice(
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -177,13 +231,19 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
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?.to) conditions.push(lte(timeEntries.startedAt, input.to));
|
||||
|
||||
return ctx.db.query.timeEntries.findMany({
|
||||
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)],
|
||||
});
|
||||
}),
|
||||
@@ -198,7 +258,11 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
),
|
||||
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;
|
||||
}),
|
||||
|
||||
@@ -247,10 +311,17 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
let clientRecord: { defaultHourlyRate: number | null } | null = null;
|
||||
if (clientId) {
|
||||
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 },
|
||||
});
|
||||
if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||
if (!found)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Client not found",
|
||||
});
|
||||
clientRecord = found;
|
||||
}
|
||||
|
||||
@@ -282,7 +353,10 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
|
||||
const startedAt = input.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) {
|
||||
@@ -337,7 +411,10 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
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: {
|
||||
@@ -369,9 +446,16 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
const clientId = input.clientId.trim() || null;
|
||||
if (clientId) {
|
||||
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;
|
||||
updates.clientId = clientId;
|
||||
@@ -427,7 +511,10 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Update failed",
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
@@ -435,10 +522,12 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
|
||||
clockOut: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const conditions = [
|
||||
@@ -452,24 +541,41 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
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 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 rate = entry.rate ?? 0;
|
||||
|
||||
const [updated] = await ctx.db
|
||||
.update(timeEntries)
|
||||
.set({ endedAt, hours, description: rawDescription, updatedAt: new Date() })
|
||||
.set({
|
||||
endedAt,
|
||||
hours,
|
||||
description: rawDescription,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(timeEntries.id, entry.id))
|
||||
.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";
|
||||
|
||||
if (hours > 0) {
|
||||
@@ -518,9 +624,16 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
const clientId = normalizeOptionalId(input.clientId);
|
||||
if (clientId) {
|
||||
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;
|
||||
@@ -542,9 +655,17 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
})
|
||||
.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) {
|
||||
linkedInvoice = await addEntryToLatestInvoice(
|
||||
ctx.db,
|
||||
@@ -576,7 +697,11 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
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) {
|
||||
throw new TRPCError({
|
||||
@@ -590,16 +715,28 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
|
||||
if (clientId) {
|
||||
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;
|
||||
const startedAt = data.startedAt ?? existing.startedAt;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -619,11 +756,19 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
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) {
|
||||
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 {
|
||||
await syncLinkedInvoiceItem(ctx.db, updated);
|
||||
}
|
||||
@@ -640,7 +785,11 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
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 ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
|
||||
@@ -649,10 +798,12 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
|
||||
getSummary: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
z
|
||||
.object({
|
||||
from: z.date().optional(),
|
||||
to: z.date().optional(),
|
||||
}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const conditions = [
|
||||
|
||||
@@ -20,21 +20,22 @@ export const users = createTable("user", (d) => ({
|
||||
email: d.varchar({ length: 255 }).notNull().unique(),
|
||||
emailVerified: d.boolean().default(false).notNull(),
|
||||
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
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
password: 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
|
||||
prefersReducedMotion: d.boolean().default(false).notNull(),
|
||||
animationSpeedMultiplier: d.real().default(1).notNull(),
|
||||
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
||||
role: d.varchar({ length: 20 }).default("user").notNull(),
|
||||
onboardingCompletedAt: d.timestamp(),
|
||||
onboardingCompletedAt: d.timestamp({ withTimezone: true }),
|
||||
}));
|
||||
|
||||
export const platformSettings = createTable("platform_setting", (d) => ({
|
||||
@@ -49,9 +50,9 @@ export const platformSettings = createTable("platform_setting", (d) => ({
|
||||
.notNull(),
|
||||
pdfShowLogo: 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
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
@@ -68,6 +69,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
||||
invoiceTemplates: many(invoiceTemplates),
|
||||
recurringInvoices: many(recurringInvoices),
|
||||
timeEntries: many(timeEntries),
|
||||
pushTokens: many(pushTokens),
|
||||
auditLogsAsActor: many(auditLog),
|
||||
}));
|
||||
|
||||
@@ -87,7 +89,7 @@ export const auditLog = createTable(
|
||||
targetType: d.varchar({ length: 50 }).notNull(),
|
||||
targetId: d.varchar({ length: 255 }),
|
||||
metadata: d.jsonb().$type<Record<string, unknown>>(),
|
||||
createdAt: d.timestamp().notNull().defaultNow(),
|
||||
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
}),
|
||||
(t) => [
|
||||
index("audit_log_actor_user_id_idx").on(t.actorUserId),
|
||||
@@ -119,14 +121,14 @@ export const accounts = createTable(
|
||||
providerId: d.varchar({ length: 255 }).notNull(),
|
||||
accessToken: d.text(),
|
||||
refreshToken: d.text(),
|
||||
accessTokenExpiresAt: d.timestamp(),
|
||||
refreshTokenExpiresAt: d.timestamp(),
|
||||
accessTokenExpiresAt: d.timestamp({ withTimezone: true }),
|
||||
refreshTokenExpiresAt: d.timestamp({ withTimezone: true }),
|
||||
scope: d.varchar({ length: 255 }),
|
||||
idToken: d.text(),
|
||||
password: d.text(), // Matched DB: text
|
||||
createdAt: d.timestamp().notNull().defaultNow(),
|
||||
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
@@ -151,12 +153,12 @@ export const sessions = createTable(
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
token: d.varchar({ length: 255 }).notNull().unique(),
|
||||
expiresAt: d.timestamp().notNull(),
|
||||
expiresAt: d.timestamp({ withTimezone: true }).notNull(),
|
||||
ipAddress: 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
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
@@ -183,12 +185,12 @@ export const apiKeys = createTable(
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
lastUsedAt: d.timestamp(),
|
||||
expiresAt: d.timestamp(),
|
||||
revokedAt: d.timestamp(),
|
||||
createdAt: d.timestamp().notNull().defaultNow(),
|
||||
lastUsedAt: d.timestamp({ withTimezone: true }),
|
||||
expiresAt: d.timestamp({ withTimezone: true }),
|
||||
revokedAt: d.timestamp({ withTimezone: true }),
|
||||
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
@@ -214,10 +216,10 @@ export const verificationTokens = createTable(
|
||||
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
||||
identifier: d.varchar({ length: 255 }).notNull(),
|
||||
value: d.text().notNull(),
|
||||
expiresAt: d.timestamp().notNull(),
|
||||
createdAt: d.timestamp().notNull().defaultNow(),
|
||||
expiresAt: d.timestamp({ withTimezone: true }).notNull(),
|
||||
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
@@ -241,9 +243,9 @@ export const ssoProviders = createTable(
|
||||
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
|
||||
oidcConfig: d.text(),
|
||||
samlConfig: d.text(),
|
||||
createdAt: d.timestamp().notNull().defaultNow(),
|
||||
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
@@ -276,10 +278,10 @@ export const clients = createTable(
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("client_created_by_idx").on(t.createdById),
|
||||
@@ -318,8 +320,20 @@ export const businesses = createTable(
|
||||
website: d.varchar({ length: 255 }),
|
||||
taxId: d.varchar({ length: 100 }),
|
||||
logoUrl: d.varchar({ length: 500 }),
|
||||
// Brand assets: the original logo columns are the combined/light variant
|
||||
// for backwards compatibility with existing uploads.
|
||||
logoStorageKey: d.varchar({ length: 500 }),
|
||||
logoMimeType: d.varchar({ length: 100 }),
|
||||
logoDarkStorageKey: d.varchar({ length: 500 }),
|
||||
logoDarkMimeType: d.varchar({ length: 100 }),
|
||||
wordmarkLightStorageKey: d.varchar({ length: 500 }),
|
||||
wordmarkLightMimeType: d.varchar({ length: 100 }),
|
||||
wordmarkDarkStorageKey: d.varchar({ length: 500 }),
|
||||
wordmarkDarkMimeType: d.varchar({ length: 100 }),
|
||||
iconLightStorageKey: d.varchar({ length: 500 }),
|
||||
iconLightMimeType: d.varchar({ length: 100 }),
|
||||
iconDarkStorageKey: d.varchar({ length: 500 }),
|
||||
iconDarkMimeType: d.varchar({ length: 100 }),
|
||||
hideNameWithLogo: d.boolean().default(false).notNull(),
|
||||
isDefault: d.boolean().default(false),
|
||||
// Email configuration for custom Resend setup
|
||||
@@ -331,10 +345,10 @@ export const businesses = createTable(
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("business_created_by_idx").on(t.createdById),
|
||||
@@ -368,8 +382,8 @@ export const invoices = createTable(
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => clients.id),
|
||||
issueDate: d.timestamp().notNull(),
|
||||
dueDate: d.timestamp().notNull(),
|
||||
issueDate: d.date({ mode: "date" }).notNull(),
|
||||
dueDate: d.date({ mode: "date" }).notNull(),
|
||||
status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed)
|
||||
totalAmount: d.real().notNull().default(0),
|
||||
taxRate: d.real().notNull().default(0.0),
|
||||
@@ -381,14 +395,20 @@ export const invoices = createTable(
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
publicToken: d.varchar({ length: 255 }).unique(),
|
||||
publicTokenExpiresAt: d.timestamp(),
|
||||
lastReminderSentAt: d.timestamp(),
|
||||
sendReminderAt: d.timestamp(),
|
||||
publicTokenExpiresAt: d.timestamp({ withTimezone: true }),
|
||||
lastReminderSentAt: d.timestamp({ withTimezone: true }),
|
||||
sendReminderAt: d.timestamp({ withTimezone: true }),
|
||||
sendReminderJobId: d.varchar({ length: 255 }),
|
||||
sentAt: d.timestamp({ withTimezone: true }),
|
||||
scheduledSendAt: d.timestamp({ withTimezone: true }),
|
||||
scheduledSendTimeZone: d.varchar({ length: 100 }),
|
||||
scheduledSendJobId: d.varchar({ length: 255 }),
|
||||
scheduledSendStatus: d.varchar({ length: 20 }), // pending | processing | completed | failed | cancelled
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("invoice_business_id_idx").on(t.businessId),
|
||||
@@ -397,6 +417,8 @@ export const invoices = createTable(
|
||||
index("invoice_number_idx").on(t.invoiceNumber),
|
||||
index("invoice_status_idx").on(t.status),
|
||||
index("invoice_public_token_idx").on(t.publicToken),
|
||||
index("invoice_scheduled_send_at_idx").on(t.scheduledSendAt),
|
||||
index("invoice_scheduled_send_job_idx").on(t.scheduledSendJobId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -429,7 +451,7 @@ export const invoiceItems = createTable(
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: "cascade" }),
|
||||
date: d.timestamp().notNull(),
|
||||
date: d.date({ mode: "date" }).notNull(),
|
||||
description: d.varchar({ length: 500 }).notNull(),
|
||||
hours: d.real().notNull(),
|
||||
rate: d.real().notNull(),
|
||||
@@ -439,7 +461,7 @@ export const invoiceItems = createTable(
|
||||
.varchar({ length: 255 })
|
||||
.references(() => timeEntries.id, { onDelete: "set null" }),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}),
|
||||
@@ -474,7 +496,7 @@ export const expenses = createTable(
|
||||
invoiceId: d
|
||||
.varchar({ length: 255 })
|
||||
.references(() => invoices.id, { onDelete: "set null" }),
|
||||
date: d.timestamp().notNull(),
|
||||
date: d.date({ mode: "date" }).notNull(),
|
||||
description: d.varchar({ length: 500 }).notNull(),
|
||||
amount: d.real().notNull(),
|
||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||
@@ -488,10 +510,10 @@ export const expenses = createTable(
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("expense_created_by_idx").on(t.createdById),
|
||||
@@ -520,7 +542,7 @@ export const expenseReceipts = createTable(
|
||||
mimeType: d.varchar({ length: 100 }).notNull(),
|
||||
sizeBytes: d.integer().notNull(),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}),
|
||||
@@ -574,10 +596,10 @@ export const invoiceTemplates = createTable(
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("invoice_template_created_by_idx").on(t.createdById),
|
||||
@@ -611,7 +633,7 @@ export const invoicePayments = createTable(
|
||||
.references(() => invoices.id, { onDelete: "cascade" }),
|
||||
amount: d.real().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
|
||||
notes: d.varchar({ length: 500 }),
|
||||
createdById: d
|
||||
@@ -619,7 +641,7 @@ export const invoicePayments = createTable(
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}),
|
||||
@@ -666,17 +688,18 @@ export const recurringInvoices = createTable(
|
||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||
notes: d.varchar({ length: 1000 }),
|
||||
emailMessage: d.varchar({ length: 2000 }),
|
||||
nextDueAt: d.timestamp().notNull(),
|
||||
lastGeneratedAt: d.timestamp(),
|
||||
nextDueAt: d.timestamp({ withTimezone: true }).notNull(),
|
||||
lastGeneratedAt: d.timestamp({ withTimezone: true }),
|
||||
timeZone: d.varchar({ length: 100 }).notNull().default("America/New_York"),
|
||||
createdById: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("recurring_invoice_created_by_idx").on(t.createdById),
|
||||
@@ -722,7 +745,7 @@ export const recurringInvoiceItems = createTable(
|
||||
rate: d.real().notNull(),
|
||||
position: d.integer().notNull().default(0),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}),
|
||||
@@ -741,6 +764,61 @@ 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
export const backgroundJobs = createTable(
|
||||
"background_job",
|
||||
(d) => ({
|
||||
id: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
type: d.varchar({ length: 100 }).notNull(),
|
||||
payload: d.jsonb().$type<Record<string, unknown>>().notNull().default({}),
|
||||
status: d.varchar({ length: 20 }).notNull().default("pending"),
|
||||
idempotencyKey: d.varchar({ length: 500 }).notNull().unique(),
|
||||
runAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
attempts: d.integer().notNull().default(0),
|
||||
maxAttempts: d.integer().notNull().default(5),
|
||||
lockedAt: d.timestamp({ withTimezone: true }),
|
||||
lockedBy: d.varchar({ length: 255 }),
|
||||
lastError: d.text(),
|
||||
completedAt: d.timestamp({ withTimezone: true }),
|
||||
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
}),
|
||||
(t) => [
|
||||
index("background_job_status_run_at_idx").on(t.status, t.runAt),
|
||||
index("background_job_type_status_idx").on(t.type, t.status),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Time Entries ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const timeEntries = createTable(
|
||||
@@ -758,8 +836,8 @@ export const timeEntries = createTable(
|
||||
invoiceId: d
|
||||
.varchar({ length: 255 })
|
||||
.references(() => invoices.id, { onDelete: "set null" }),
|
||||
startedAt: d.timestamp().notNull(),
|
||||
endedAt: d.timestamp(), // null = currently running
|
||||
startedAt: d.timestamp({ withTimezone: true }).notNull(),
|
||||
endedAt: d.timestamp({ withTimezone: true }), // null = currently running
|
||||
hours: d.real(), // stored when stopped
|
||||
rate: d.real(),
|
||||
notes: d.varchar({ length: 500 }),
|
||||
@@ -768,10 +846,10 @@ export const timeEntries = createTable(
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { and, eq, lte } from "drizzle-orm";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { recurringInvoices } from "~/server/db/schema";
|
||||
import type { BackgroundJob } from "~/server/jobs/queue";
|
||||
import {
|
||||
generateInvoiceFromRecurring,
|
||||
nextDueDate,
|
||||
} from "~/server/services/recurring-invoices";
|
||||
|
||||
export async function generateRecurringInvoice(job: BackgroundJob) {
|
||||
const recurringInvoiceId = job.payload.recurringInvoiceId;
|
||||
const scheduledForValue = job.payload.scheduledFor;
|
||||
if (
|
||||
typeof recurringInvoiceId !== "string" ||
|
||||
typeof scheduledForValue !== "string"
|
||||
) {
|
||||
throw new Error("Invalid recurring invoice job payload");
|
||||
}
|
||||
const scheduledFor = new Date(scheduledForValue);
|
||||
if (Number.isNaN(scheduledFor.getTime()))
|
||||
throw new Error("Invalid recurring invoice job payload");
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const recurring = await tx.query.recurringInvoices.findFirst({
|
||||
where: and(
|
||||
eq(recurringInvoices.id, recurringInvoiceId),
|
||||
eq(recurringInvoices.status, "active"),
|
||||
lte(recurringInvoices.nextDueAt, scheduledFor),
|
||||
),
|
||||
with: { items: true },
|
||||
});
|
||||
if (!recurring) return;
|
||||
|
||||
await generateInvoiceFromRecurring(tx, recurring, scheduledFor);
|
||||
await tx
|
||||
.update(recurringInvoices)
|
||||
.set({
|
||||
lastGeneratedAt: new Date(),
|
||||
nextDueAt: nextDueDate(
|
||||
recurring.schedule,
|
||||
scheduledFor,
|
||||
recurring.timeZone,
|
||||
),
|
||||
})
|
||||
.where(eq(recurringInvoices.id, recurring.id));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, eq, inArray, lte, or } from "drizzle-orm";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import {
|
||||
backgroundJobs,
|
||||
invoices,
|
||||
recurringInvoices,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
export const jobTypes = {
|
||||
generateRecurringInvoice: "recurring_invoice.generate",
|
||||
sendInvoice: "invoice.send_scheduled",
|
||||
sendInvoiceReminder: "invoice.reminder.send",
|
||||
sendPushNotification: "push_notification.send",
|
||||
timeClockReminder: "time_clock.reminder",
|
||||
} as const;
|
||||
|
||||
export type JobType = (typeof jobTypes)[keyof typeof jobTypes];
|
||||
export type BackgroundJob = typeof backgroundJobs.$inferSelect;
|
||||
|
||||
export async function enqueueJob(input: {
|
||||
type: JobType;
|
||||
payload?: Record<string, unknown>;
|
||||
idempotencyKey: string;
|
||||
runAt?: Date;
|
||||
maxAttempts?: number;
|
||||
}) {
|
||||
const [job] = await db
|
||||
.insert(backgroundJobs)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
type: input.type,
|
||||
payload: input.payload ?? {},
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
runAt: input.runAt ?? new Date(),
|
||||
maxAttempts: input.maxAttempts ?? 5,
|
||||
})
|
||||
.onConflictDoNothing({ target: backgroundJobs.idempotencyKey })
|
||||
.returning();
|
||||
return job ?? null;
|
||||
}
|
||||
|
||||
export async function scheduleDueRecurringInvoiceJobs(now = new Date()) {
|
||||
const due = await db.query.recurringInvoices.findMany({
|
||||
where: and(
|
||||
eq(recurringInvoices.status, "active"),
|
||||
lte(recurringInvoices.nextDueAt, now),
|
||||
),
|
||||
});
|
||||
let enqueued = 0;
|
||||
for (const recurring of due) {
|
||||
const scheduledFor = recurring.nextDueAt.toISOString();
|
||||
const job = await enqueueJob({
|
||||
type: jobTypes.generateRecurringInvoice,
|
||||
idempotencyKey: `${jobTypes.generateRecurringInvoice}:${recurring.id}:${scheduledFor}`,
|
||||
payload: { recurringInvoiceId: recurring.id, scheduledFor },
|
||||
});
|
||||
if (job) enqueued++;
|
||||
}
|
||||
return { due: due.length, enqueued };
|
||||
}
|
||||
|
||||
export async function claimNextJob(workerId: string) {
|
||||
return db.transaction(async (tx) => {
|
||||
const staleBefore = new Date(Date.now() - 5 * 60_000);
|
||||
const [job] = await tx
|
||||
.select()
|
||||
.from(backgroundJobs)
|
||||
.where(
|
||||
and(
|
||||
lte(backgroundJobs.runAt, new Date()),
|
||||
or(
|
||||
eq(backgroundJobs.status, "pending"),
|
||||
and(
|
||||
eq(backgroundJobs.status, "processing"),
|
||||
lte(backgroundJobs.lockedAt, staleBefore),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(backgroundJobs.runAt), asc(backgroundJobs.createdAt))
|
||||
.limit(1)
|
||||
.for("update", { skipLocked: true });
|
||||
if (!job) return null;
|
||||
|
||||
const [claimed] = await tx
|
||||
.update(backgroundJobs)
|
||||
.set({
|
||||
status: "processing",
|
||||
attempts: job.attempts + 1,
|
||||
lockedAt: new Date(),
|
||||
lockedBy: workerId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backgroundJobs.id, job.id))
|
||||
.returning();
|
||||
return claimed ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeJob(id: string) {
|
||||
await db
|
||||
.update(backgroundJobs)
|
||||
.set({
|
||||
status: "completed",
|
||||
completedAt: new Date(),
|
||||
lockedAt: null,
|
||||
lockedBy: null,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backgroundJobs.id, id));
|
||||
}
|
||||
|
||||
export async function failJob(job: BackgroundJob, error: unknown) {
|
||||
const terminal = job.attempts >= job.maxAttempts;
|
||||
const retryDelayMs = Math.min(
|
||||
60 * 60_000,
|
||||
2 ** Math.max(0, job.attempts - 1) * 15_000,
|
||||
);
|
||||
await db
|
||||
.update(backgroundJobs)
|
||||
.set({
|
||||
status: terminal ? "failed" : "pending",
|
||||
runAt: terminal ? job.runAt : new Date(Date.now() + retryDelayMs),
|
||||
lockedAt: null,
|
||||
lockedBy: null,
|
||||
lastError: error instanceof Error ? error.message : "Unknown error",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backgroundJobs.id, job.id));
|
||||
return terminal;
|
||||
}
|
||||
|
||||
export async function markScheduledInvoiceJobFailed(job: BackgroundJob) {
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ scheduledSendStatus: "failed", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.scheduledSendJobId, job.id),
|
||||
inArray(invoices.scheduledSendStatus, ["pending", "processing"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { getEmailReadiness } from "@beenvoice/email";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||
|
||||
interface BusinessEmailSettings {
|
||||
name?: string | null;
|
||||
nickname?: string | null;
|
||||
email?: string | null;
|
||||
emailFromName?: string | null;
|
||||
resendApiKey?: string | null;
|
||||
resendDomain?: string | null;
|
||||
}
|
||||
|
||||
export function resolveEmailSender(
|
||||
business?: BusinessEmailSettings | null,
|
||||
fallbackName = "beenvoice",
|
||||
) {
|
||||
const readiness = getEmailReadiness({
|
||||
from: env.EMAIL_FROM ?? env.RESEND_FROM,
|
||||
resendApiKey: business?.resendApiKey ?? undefined,
|
||||
});
|
||||
|
||||
if (readiness.provider !== "resend") {
|
||||
return {
|
||||
from: env.EMAIL_FROM ?? `${fallbackName} <${NOREPLY_EMAIL}>`,
|
||||
resendApiKey: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (business?.resendApiKey && business.resendDomain) {
|
||||
const fromName =
|
||||
business.emailFromName ??
|
||||
(business.nickname
|
||||
? `${business.name ?? fallbackName} (${business.nickname})`
|
||||
: business.name) ??
|
||||
fallbackName;
|
||||
return {
|
||||
from: `${fromName} <noreply@${business.resendDomain}>`,
|
||||
resendApiKey: business.resendApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
from:
|
||||
env.RESEND_FROM ??
|
||||
env.EMAIL_FROM ??
|
||||
(env.RESEND_DOMAIN
|
||||
? `noreply@${env.RESEND_DOMAIN}`
|
||||
: (business?.email ?? NOREPLY_EMAIL)),
|
||||
resendApiKey: env.RESEND_API_KEY,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { db as DbType } from "~/server/db";
|
||||
import { invoiceItems, invoices } from "~/server/db/schema";
|
||||
import type {
|
||||
recurringInvoiceItems,
|
||||
recurringInvoices,
|
||||
} from "~/server/db/schema";
|
||||
import {
|
||||
addZonedCalendarInterval,
|
||||
getZonedDateTimeParts,
|
||||
} from "@beenvoice/domain/time-zone";
|
||||
|
||||
export function nextDueDate(
|
||||
schedule: string,
|
||||
from = new Date(),
|
||||
timeZone = "America/New_York",
|
||||
): Date {
|
||||
if (
|
||||
!(
|
||||
["weekly", "biweekly", "monthly", "quarterly", "yearly"] as string[]
|
||||
).includes(schedule)
|
||||
) {
|
||||
throw new RangeError("Invalid recurring schedule");
|
||||
}
|
||||
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 & {
|
||||
items: (typeof recurringInvoiceItems.$inferSelect)[];
|
||||
};
|
||||
|
||||
export async function generateInvoiceFromRecurring(
|
||||
db: Pick<typeof DbType, "insert">,
|
||||
recurring: RecurringWithItems,
|
||||
scheduledFor = new Date(),
|
||||
): Promise<{ id: string }> {
|
||||
const issueDate = calendarDateAt(scheduledFor, recurring.timeZone);
|
||||
const invoiceNumber = `REC-${Date.now()}`;
|
||||
const subtotal = recurring.items.reduce(
|
||||
(sum, item) => sum + item.hours * item.rate,
|
||||
0,
|
||||
);
|
||||
const taxAmount = (subtotal * recurring.taxRate) / 100;
|
||||
|
||||
const [newInvoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
invoiceNumber,
|
||||
invoicePrefix: recurring.invoicePrefix ?? "#",
|
||||
clientId: recurring.clientId,
|
||||
businessId: recurring.businessId ?? null,
|
||||
issueDate,
|
||||
dueDate: calendarDateAt(
|
||||
nextDueDate("monthly", scheduledFor, recurring.timeZone),
|
||||
recurring.timeZone,
|
||||
),
|
||||
status: "draft",
|
||||
totalAmount: subtotal + taxAmount,
|
||||
taxRate: recurring.taxRate,
|
||||
notes: recurring.notes ?? null,
|
||||
emailMessage: recurring.emailMessage ?? null,
|
||||
currency: recurring.currency,
|
||||
createdById: recurring.createdById,
|
||||
})
|
||||
.returning({ id: invoices.id });
|
||||
|
||||
if (!newInvoice) throw new Error("Failed to create invoice");
|
||||
|
||||
if (recurring.items.length > 0) {
|
||||
await db.insert(invoiceItems).values(
|
||||
recurring.items.map((item, index) => ({
|
||||
invoiceId: newInvoice.id,
|
||||
date: issueDate,
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.hours * item.rate,
|
||||
position: item.position ?? index,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return newInvoice;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { sendEmail } from "@beenvoice/email";
|
||||
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { db } from "~/server/db";
|
||||
import { backgroundJobs, invoices, platformSettings } from "~/server/db/schema";
|
||||
import { resolveEmailSender } from "~/server/services/email-sender";
|
||||
|
||||
export interface InvoiceEmailOptions {
|
||||
customSubject?: string;
|
||||
customContent?: string;
|
||||
customMessage?: string;
|
||||
useHtml?: boolean;
|
||||
ccEmails?: string;
|
||||
bccEmails?: string;
|
||||
}
|
||||
|
||||
export interface DeliverInvoiceEmailInput extends InvoiceEmailOptions {
|
||||
invoiceId: string;
|
||||
actorUserId: string;
|
||||
baseUrl: string;
|
||||
idempotencyKey?: string;
|
||||
scheduledJobId?: string;
|
||||
}
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function plainTextToHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function normalizeEmailNoteHtml(value: string) {
|
||||
const visibleText = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ |\u00a0/g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
|
||||
return visibleText ? value.trim() : "";
|
||||
}
|
||||
|
||||
function parseEmailList(value?: string): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => EMAIL_PATTERN.test(email));
|
||||
}
|
||||
|
||||
function deliveryError(message: string | undefined): Error {
|
||||
const errorMessage = message?.toLowerCase() ?? "";
|
||||
if (
|
||||
errorMessage.includes("invalid email") ||
|
||||
errorMessage.includes("invalid recipient")
|
||||
) {
|
||||
return new Error("Invalid recipient email address");
|
||||
}
|
||||
if (
|
||||
errorMessage.includes("domain") ||
|
||||
errorMessage.includes("not verified")
|
||||
) {
|
||||
return new Error(
|
||||
"Email domain not verified. Please configure your Resend domain in business settings.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
errorMessage.includes("rate limit") ||
|
||||
errorMessage.includes("too many")
|
||||
) {
|
||||
return new Error("Rate limit exceeded. Please try again later.");
|
||||
}
|
||||
if (
|
||||
errorMessage.includes("api key") ||
|
||||
errorMessage.includes("unauthorized")
|
||||
) {
|
||||
return new Error(
|
||||
"Email service configuration error. Please check your Resend API key.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
errorMessage.includes("attachment") ||
|
||||
errorMessage.includes("file size")
|
||||
) {
|
||||
return new Error("Invoice PDF is too large to send via email.");
|
||||
}
|
||||
return new Error(`Email delivery failed: ${message ?? "Unknown error"}`);
|
||||
}
|
||||
|
||||
export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) {
|
||||
const invoice = await db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.invoiceId),
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
createdBy: true,
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) throw new Error("Invoice not found");
|
||||
if (invoice.createdById !== input.actorUserId)
|
||||
throw new Error("Unauthorized");
|
||||
if (!invoice.client?.email) throw new Error("Client has no email address");
|
||||
if (!invoice.items.length) {
|
||||
throw new Error("Add at least one line item before sending this invoice");
|
||||
}
|
||||
if (!EMAIL_PATTERN.test(invoice.client.email)) {
|
||||
throw new Error("Invalid client email address format");
|
||||
}
|
||||
if (
|
||||
input.scheduledJobId &&
|
||||
(invoice.scheduledSendJobId !== input.scheduledJobId ||
|
||||
!["pending", "processing"].includes(invoice.scheduledSendStatus ?? ""))
|
||||
) {
|
||||
return {
|
||||
skipped: true as const,
|
||||
message: "Scheduled send is no longer active",
|
||||
};
|
||||
}
|
||||
if (
|
||||
!input.scheduledJobId &&
|
||||
invoice.scheduledSendJobId &&
|
||||
invoice.scheduledSendStatus === "pending"
|
||||
) {
|
||||
const cancelled = await db
|
||||
.update(backgroundJobs)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(backgroundJobs.id, invoice.scheduledSendJobId),
|
||||
eq(backgroundJobs.status, "pending"),
|
||||
),
|
||||
)
|
||||
.returning({ id: backgroundJobs.id });
|
||||
if (!cancelled.length) {
|
||||
throw new Error("The worker has already started sending this invoice");
|
||||
}
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ scheduledSendStatus: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invoice.id));
|
||||
} else if (
|
||||
!input.scheduledJobId &&
|
||||
invoice.scheduledSendStatus === "processing"
|
||||
) {
|
||||
throw new Error("The worker is already sending this invoice");
|
||||
}
|
||||
|
||||
const settings = await db.query.platformSettings.findFirst({
|
||||
where: eq(platformSettings.id, "global"),
|
||||
});
|
||||
let pdfBuffer: Buffer;
|
||||
try {
|
||||
const pdfBlob = await generateInvoicePDFBlob(
|
||||
invoice,
|
||||
{
|
||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
},
|
||||
{ logoBaseUrl: input.baseUrl },
|
||||
);
|
||||
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||
if (pdfBuffer.length === 0) throw new Error("Generated PDF is empty");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
throw new Error(
|
||||
`Failed to generate invoice PDF for attachment: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const subject =
|
||||
input.customSubject ??
|
||||
`Invoice ${invoice.invoiceNumber} from ${
|
||||
invoice.business
|
||||
? `${invoice.business.name}${invoice.business.nickname ? ` (${invoice.business.nickname})` : ""}`
|
||||
: "Your Business"
|
||||
}`;
|
||||
const userName =
|
||||
invoice.business?.emailFromName ??
|
||||
invoice.business?.name ??
|
||||
invoice.createdBy.name ??
|
||||
"Your Name";
|
||||
const userEmail = invoice.business?.email ?? invoice.createdBy.email ?? "";
|
||||
const customMessage =
|
||||
input.customMessage !== undefined
|
||||
? normalizeEmailNoteHtml(input.customMessage)
|
||||
: invoice.emailMessage
|
||||
? plainTextToHtml(invoice.emailMessage)
|
||||
: undefined;
|
||||
const emailTemplate = generateInvoiceEmailTemplate({
|
||||
invoice: {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issueDate: invoice.issueDate,
|
||||
dueDate: invoice.dueDate,
|
||||
status: invoice.status,
|
||||
totalAmount: invoice.totalAmount,
|
||||
taxRate: invoice.taxRate,
|
||||
currency: invoice.currency,
|
||||
client: { name: invoice.client.name, email: invoice.client.email },
|
||||
business: invoice.business,
|
||||
items: invoice.items,
|
||||
},
|
||||
customContent: input.customContent,
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl: input.baseUrl,
|
||||
timeZone: invoice.createdBy.timeZone,
|
||||
});
|
||||
|
||||
const sender = resolveEmailSender(invoice.business, userName);
|
||||
const fromEmail = sender.from;
|
||||
|
||||
const ccEmails = parseEmailList(input.ccEmails);
|
||||
const bccEmails = parseEmailList(input.bccEmails);
|
||||
if (
|
||||
invoice.business?.email &&
|
||||
invoice.business.email !== fromEmail &&
|
||||
EMAIL_PATTERN.test(invoice.business.email)
|
||||
) {
|
||||
ccEmails.push(invoice.business.email);
|
||||
}
|
||||
|
||||
let emailResult;
|
||||
try {
|
||||
emailResult = await sendEmail({
|
||||
...sender,
|
||||
to: [invoice.client.email],
|
||||
cc: ccEmails.length ? ccEmails : undefined,
|
||||
bcc: bccEmails.length ? bccEmails : undefined,
|
||||
subject,
|
||||
html: emailTemplate.html,
|
||||
text: emailTemplate.text,
|
||||
headers: {
|
||||
"X-Priority": "3",
|
||||
"X-MSMail-Priority": "Normal",
|
||||
"X-Mailer": "beenvoice",
|
||||
"MIME-Version": "1.0",
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
filename: `invoice-${invoice.invoiceNumber}.pdf`,
|
||||
content: pdfBuffer,
|
||||
},
|
||||
],
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
});
|
||||
} catch (error) {
|
||||
throw deliveryError(error instanceof Error ? error.message : undefined);
|
||||
}
|
||||
|
||||
const sentAt = new Date();
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
...(invoice.status === "draft" ? { status: "sent" } : {}),
|
||||
sentAt,
|
||||
...(input.scheduledJobId ? { scheduledSendStatus: "completed" } : {}),
|
||||
updatedAt: sentAt,
|
||||
})
|
||||
.where(eq(invoices.id, input.invoiceId));
|
||||
|
||||
return {
|
||||
skipped: false as const,
|
||||
success: true,
|
||||
emailId: emailResult.id,
|
||||
message: `Invoice sent successfully to ${invoice.client.email}${
|
||||
ccEmails.length ? ` (CC: ${ccEmails.join(", ")})` : ""
|
||||
}${bccEmails.length ? ` (BCC: ${bccEmails.join(", ")})` : ""}`,
|
||||
deliveryDetails: {
|
||||
to: invoice.client.email,
|
||||
cc: ccEmails,
|
||||
bcc: bccEmails,
|
||||
sentAt: sentAt.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user