diff --git a/drizzle/0015_invoice_send_reminder_at.sql b/drizzle/0015_invoice_send_reminder_at.sql
new file mode 100644
index 0000000..a8125e0
--- /dev/null
+++ b/drizzle/0015_invoice_send_reminder_at.sql
@@ -0,0 +1 @@
+ALTER TABLE "beenvoice_invoice" ADD COLUMN "send_reminder_at" timestamp;
diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx
index 1abb732..d7134be 100644
--- a/src/app/dashboard/invoices/[id]/page.tsx
+++ b/src/app/dashboard/invoices/[id]/page.tsx
@@ -52,6 +52,7 @@ 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,
@@ -166,6 +167,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
onError: (e) => toast.error(e.message ?? "Failed to send reminder"),
});
+ const updateInvoice = api.invoices.update.useMutation({
+ onSuccess: () => {
+ toast.success("Reminder saved");
+ void utils.invoices.getById.invalidate({ id: invoiceId });
+ void utils.dashboard.getStats.invalidate();
+ },
+ onError: (e) => toast.error(e.message ?? "Failed to save reminder"),
+ });
+
if (isLoading) return ;
if (!invoice) notFound();
@@ -522,7 +532,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */}
- {effectiveStatus !== "paid" && (
+ {storedStatus === "draft" && (
)}
@@ -553,6 +563,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
/>
)}
+ {effectiveStatus === "draft" && (
+
+ updateInvoice.mutate({
+ id: invoiceId,
+ sendReminderAt,
+ })
+ }
+ onClear={() =>
+ updateInvoice.mutate({ id: invoiceId, sendReminderAt: null })
+ }
+ />
+ )}
+
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
string;
+ isSaving: boolean;
+ onSave: (sendReminderAt: Date | null) => void;
+ onClear: () => void;
+}) {
+ const [sendReminderAt, setSendReminderAt] = useState(() =>
+ savedReminderAt ? new Date(savedReminderAt) : undefined,
+ );
+
+ return (
+
+
+
+
+
+ {sendReminderAt ? (
+
+ ) : null}
+
+ {savedReminderAt ? (
+
+ {new Date(savedReminderAt) <= new Date()
+ ? "Reminder is due — time to send this invoice."
+ : `Scheduled for ${formatDate(savedReminderAt)}`}
+
+ ) : null}
+
+ );
+}
+
export default function InvoiceViewPage() {
const params = useParams();
const router = useRouter();
diff --git a/src/components/forms/invoice-calendar-view.tsx b/src/components/forms/invoice-calendar-view.tsx
index 1b80889..e9d9dac 100644
--- a/src/components/forms/invoice-calendar-view.tsx
+++ b/src/components/forms/invoice-calendar-view.tsx
@@ -54,6 +54,7 @@ interface InvoiceCalendarViewProps {
onRemoveItem: (index: number) => void;
className?: string;
defaultHourlyRate: number | null;
+ readOnly?: boolean;
}
export function InvoiceCalendarView({
@@ -63,6 +64,7 @@ export function InvoiceCalendarView({
onRemoveItem,
className,
defaultHourlyRate: _defaultHourlyRate,
+ readOnly = false,
}: InvoiceCalendarViewProps) {
const [date, setDate] = React.useState(undefined); // Start unselected
const [viewDate, setViewDate] = React.useState(new Date()); // Controls the view (month/week)
@@ -403,10 +405,12 @@ export function InvoiceCalendarView({
There are no time entries recorded for this day yet.
-
+ {!readOnly ? (
+
+ ) : null}
) : (
@@ -428,6 +432,7 @@ export function InvoiceCalendarView({
}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
+ disabled={readOnly}
/>
@@ -443,6 +448,7 @@ export function InvoiceCalendarView({
step={0.25}
min={0}
width="full"
+ disabled={readOnly}
/>
@@ -456,6 +462,7 @@ export function InvoiceCalendarView({
min={0}
step={1}
width="full"
+ disabled={readOnly}
/>
@@ -464,15 +471,17 @@ export function InvoiceCalendarView({
{/* Bottom section with controls, item name, and total */}
-
+ {!readOnly ? (
+
+ ) : null}
@@ -490,16 +499,18 @@ export function InvoiceCalendarView({
))}
-
+ {!readOnly ? (
+
+ ) : null}
)}
diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx
index f71ea4e..2dc6551 100644
--- a/src/components/forms/invoice-form.tsx
+++ b/src/components/forms/invoice-form.tsx
@@ -808,6 +808,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
clientId={formData.clientId || undefined}
defaultRate={formData.items[0]?.rate}
+ readOnly={formData.status !== "draft"}
/>
@@ -831,6 +832,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onRemoveItem={removeItem}
onUpdateItem={updateItem}
defaultHourlyRate={formData.defaultHourlyRate}
+ readOnly={formData.status !== "draft"}
/>
diff --git a/src/components/forms/invoice-line-items.tsx b/src/components/forms/invoice-line-items.tsx
index 48d9b46..6d6af0e 100644
--- a/src/components/forms/invoice-line-items.tsx
+++ b/src/components/forms/invoice-line-items.tsx
@@ -40,6 +40,7 @@ interface InvoiceLineItemsProps {
clientId?: string;
defaultRate?: number;
className?: string;
+ readOnly?: boolean;
}
interface LineItemRowProps {
@@ -55,6 +56,7 @@ interface LineItemRowProps {
suggestions: LineItemSuggestion[];
onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void;
onDescriptionChange: (index: number, value: string) => void;
+ readOnly?: boolean;
}
interface DescriptionAutocompleteProps {
@@ -64,6 +66,7 @@ interface DescriptionAutocompleteProps {
suggestions: LineItemSuggestion[];
placeholder?: string;
className?: string;
+ disabled?: boolean;
}
function DescriptionAutocomplete({
@@ -73,6 +76,7 @@ function DescriptionAutocomplete({
suggestions,
placeholder,
className,
+ disabled,
}: DescriptionAutocompleteProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
@@ -116,6 +120,7 @@ function DescriptionAutocomplete({
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={className}
+ disabled={disabled}
/>
{showDropdown && (
@@ -146,7 +151,7 @@ function DescriptionAutocomplete({
}
const LineItemCard = React.forwardRef
(
- ({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange }, ref) => {
+ ({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
return (
(
size="sm"
className="w-full"
inputClassName="h-9"
+ disabled={readOnly}
/>
(
suggestions={suggestions}
placeholder="Describe the work performed..."
className="h-9 w-full text-sm font-medium"
+ disabled={readOnly}
/>
(
width="full"
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
suffix="h"
+ disabled={readOnly}
/>
(
prefix="$"
width="full"
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
+ disabled={readOnly}
/>
${(item.hours * item.rate).toFixed(2)}
-
+ {!readOnly ? (
+
+ ) : (
+
+ )}
);
},
@@ -221,6 +234,7 @@ function MobileLineItem({
suggestions,
onSelectSuggestion,
onDescriptionChange,
+ readOnly,
}: LineItemRowProps) {
return (
@@ -254,6 +269,7 @@ function MobileLineItem({
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
inputClassName="h-9"
+ disabled={readOnly}
/>
@@ -267,6 +283,7 @@ function MobileLineItem({
min={0}
step={0.25}
width="full"
+ disabled={readOnly}
/>
@@ -279,6 +296,7 @@ function MobileLineItem({
prefix="$"
width="full"
className="font-mono"
+ disabled={readOnly}
/>
@@ -287,17 +305,19 @@ function MobileLineItem({
{/* Bottom section with controls, item name, and total */}
-
+ {!readOnly ? (
+
+ ) : null}
@@ -352,6 +372,7 @@ export function InvoiceLineItems({
clientId,
defaultRate: _defaultRate,
className,
+ readOnly = false,
}: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1;
const { search } = useLineItemSuggestions();
@@ -378,6 +399,11 @@ export function InvoiceLineItems({
return (
+ {readOnly ? (
+
+ Line items are locked after an invoice is sent. Revert to draft to edit entries.
+
+ ) : null}
@@ -408,6 +434,7 @@ export function InvoiceLineItems({
suggestions={getSuggestionsForIndex(index)}
onSelectSuggestion={handleSelectSuggestion}
onDescriptionChange={handleDescriptionChange}
+ readOnly={readOnly}
/>
@@ -421,6 +448,7 @@ export function InvoiceLineItems({
suggestions={getSuggestionsForIndex(index)}
onSelectSuggestion={handleSelectSuggestion}
onDescriptionChange={handleDescriptionChange}
+ readOnly={readOnly}
/>
))}
@@ -444,22 +472,23 @@ export function InvoiceLineItems({
)}
- {onAddItemWithValues && (
+ {onAddItemWithValues && !readOnly ? (
- )}
+ ) : null}
- {/* Add Item Button */}
-
+ {!readOnly ? (
+
+ ) : null}
);
}
diff --git a/src/components/time-clock/time-clock-panel.tsx b/src/components/time-clock/time-clock-panel.tsx
index 72ba706..6482764 100644
--- a/src/components/time-clock/time-clock-panel.tsx
+++ b/src/components/time-clock/time-clock-panel.tsx
@@ -134,7 +134,7 @@ export function TimeClockPanel({
invoicePrefix: string | null;
invoiceNumber: string;
status: string;
- }) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.status})`;
+ }) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
const displayDescription = running ? running.description : description;
const displayRate = running ? (running.rate ?? 0) : rate;
diff --git a/src/lib/draft-invoice.ts b/src/lib/draft-invoice.ts
new file mode 100644
index 0000000..db83980
--- /dev/null
+++ b/src/lib/draft-invoice.ts
@@ -0,0 +1,11 @@
+/** Default invoice number format (matches web/mobile create forms). */
+export function generateInvoiceNumber(now = new Date()): string {
+ const date = now.toISOString().slice(0, 10).replace(/-/g, "");
+ return `INV-${date}-${String(now.getTime()).slice(-6)}`;
+}
+
+export function defaultDueDate(issueDate: Date): Date {
+ const due = new Date(issueDate);
+ due.setDate(due.getDate() + 30);
+ return due;
+}
diff --git a/src/lib/time-clock.ts b/src/lib/time-clock.ts
index 0ef6094..c04ffe3 100644
--- a/src/lib/time-clock.ts
+++ b/src/lib/time-clock.ts
@@ -32,7 +32,7 @@ export function describeClockOutOutcome(input: {
}
return `Added ${input.hours}h to invoice`;
case "saved_no_invoice":
- return `Saved ${input.hours}h — no open invoice found for this client. Pick an invoice on the time clock or create one.`;
+ return `Saved ${input.hours}h — could not create or find a draft invoice for this client.`;
case "saved_no_client":
return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
case "zero_hours":
diff --git a/src/server/api/routers/dashboard.ts b/src/server/api/routers/dashboard.ts
index 474f2aa..49453c2 100644
--- a/src/server/api/routers/dashboard.ts
+++ b/src/server/api/routers/dashboard.ts
@@ -1,6 +1,6 @@
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, clients } from "~/server/db/schema";
-import { eq, desc } from "drizzle-orm";
+import { and, desc, eq, isNotNull, lte } from "drizzle-orm";
export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
@@ -118,6 +118,26 @@ export const dashboardRouter = createTRPCRouter({
},
});
+ const sendReminderDue = await ctx.db.query.invoices.findMany({
+ where: and(
+ eq(invoices.createdById, userId),
+ eq(invoices.status, "draft"),
+ isNotNull(invoices.sendReminderAt),
+ lte(invoices.sendReminderAt, now),
+ ),
+ columns: {
+ id: true,
+ invoiceNumber: true,
+ invoicePrefix: true,
+ sendReminderAt: true,
+ },
+ with: {
+ client: { columns: { name: true } },
+ },
+ orderBy: [desc(invoices.sendReminderAt)],
+ limit: 10,
+ });
+
return {
totalRevenue,
pendingAmount,
@@ -129,6 +149,7 @@ export const dashboardRouter = createTRPCRouter({
: 0,
revenueChartData,
recentInvoices,
+ sendReminderDue,
};
}),
});
diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts
index 41335ba..2ef4428 100644
--- a/src/server/api/routers/invoices.ts
+++ b/src/server/api/routers/invoices.ts
@@ -43,6 +43,7 @@ const createInvoiceSchema = z.object({
emailMessage: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"),
+ sendReminderAt: z.date().nullable().optional(),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
});
@@ -155,13 +156,13 @@ export const invoicesRouter = createTRPCRouter({
}
}),
- /** Draft and sent invoices available for time-clock billing. */
+ /** Draft invoices available for time-clock billing. */
getBillable: protectedProcedure
.input(z.object({ clientId: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const conditions = [
eq(invoices.createdById, ctx.session.user.id),
- inArray(invoices.status, ["draft", "sent"]),
+ eq(invoices.status, "draft"),
];
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId));
@@ -418,6 +419,23 @@ export const invoicesRouter = createTRPCRouter({
});
}
+ if (items && existingInvoice.status !== "draft") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Line items can only be edited on draft invoices",
+ });
+ }
+
+ if (
+ cleanInvoiceData.sendReminderAt !== undefined &&
+ existingInvoice.status !== "draft"
+ ) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Send reminders can only be set on draft invoices",
+ });
+ }
+
// If business is being updated, verify it belongs to user
if (
cleanInvoiceData.businessId &&
diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts
index c486f6f..6340cb8 100644
--- a/src/server/api/routers/time-entries.ts
+++ b/src/server/api/routers/time-entries.ts
@@ -1,13 +1,14 @@
import { z } from "zod";
-import { eq, and, desc, isNull, isNotNull, gte, lte, or } from "drizzle-orm";
+import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
-import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema";
+import { timeEntries, clients, invoices, invoiceItems, businesses } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db";
import {
computeTrackedHours,
type ClockOutOutcome,
} from "~/lib/time-clock";
+import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
type Db = typeof db;
@@ -86,6 +87,60 @@ async function addEntryToInvoice(
};
}
+async function findOrCreateDraftInvoice(
+ database: Db,
+ userId: string,
+ clientId: string,
+) {
+ const existing = await database.query.invoices.findFirst({
+ where: and(
+ eq(invoices.clientId, clientId),
+ eq(invoices.createdById, userId),
+ eq(invoices.status, "draft"),
+ ),
+ with: { items: true },
+ orderBy: [
+ desc(invoices.updatedAt),
+ desc(invoices.issueDate),
+ desc(invoices.invoiceNumber),
+ ],
+ });
+
+ if (existing) return existing;
+
+ const client = await database.query.clients.findFirst({
+ where: and(eq(clients.id, clientId), eq(clients.createdById, userId)),
+ columns: { currency: true },
+ });
+ if (!client) return null;
+
+ const defaultBusiness = await database.query.businesses.findFirst({
+ where: and(eq(businesses.createdById, userId), eq(businesses.isDefault, true)),
+ columns: { id: true },
+ });
+
+ const issueDate = new Date();
+ const [created] = await database
+ .insert(invoices)
+ .values({
+ invoiceNumber: generateInvoiceNumber(issueDate),
+ clientId,
+ businessId: defaultBusiness?.id ?? null,
+ issueDate,
+ dueDate: defaultDueDate(issueDate),
+ status: "draft",
+ totalAmount: 0,
+ taxRate: 0,
+ currency: client.currency,
+ createdById: userId,
+ })
+ .returning();
+
+ if (!created) return null;
+
+ return { ...created, items: [] as { amount: number; position: number }[] };
+}
+
async function addEntryToLatestInvoice(
database: Db,
userId: string,
@@ -96,20 +151,7 @@ async function addEntryToLatestInvoice(
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
- const invoice = await database.query.invoices.findFirst({
- where: and(
- eq(invoices.clientId, clientId),
- eq(invoices.createdById, userId),
- or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
- ),
- with: { items: true },
- orderBy: [
- desc(invoices.issueDate),
- desc(invoices.dueDate),
- desc(invoices.invoiceNumber),
- ],
- });
-
+ const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
if (!invoice) return null;
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
}
@@ -128,7 +170,7 @@ async function addEntryToSpecificInvoice(
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, userId),
- or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
+ eq(invoices.status, "draft"),
),
with: { items: true },
});
@@ -231,14 +273,14 @@ export const timeEntriesRouter = createTRPCRouter({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id),
- or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
+ eq(invoices.status, "draft"),
),
columns: { id: true, clientId: true },
});
if (!invoice) {
throw new TRPCError({
code: "FORBIDDEN",
- message: "Invoice not found or not open for time tracking",
+ message: "Only draft invoices accept new time entries",
});
}
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
@@ -345,14 +387,14 @@ export const timeEntriesRouter = createTRPCRouter({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id),
- or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
+ eq(invoices.status, "draft"),
),
columns: { id: true, clientId: true },
});
if (!invoice) {
throw new TRPCError({
code: "FORBIDDEN",
- message: "Invoice not found or not open for time tracking",
+ message: "Only draft invoices accept new time entries",
});
}
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 657e6e8..ade6c24 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -369,6 +369,7 @@ export const invoices = createTable(
publicToken: d.varchar({ length: 255 }).unique(),
publicTokenExpiresAt: d.timestamp(),
lastReminderSentAt: d.timestamp(),
+ sendReminderAt: d.timestamp(),
createdAt: d
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)