feat: add recurring invoices, public links, time tracker, payments, reminders

- Recurring invoices: schedule-based auto-generation with CRUD UI at /dashboard/invoices/recurring and POST /api/cron/generate-recurring cron endpoint
- Public invoice link: generate/revoke shareable /i/[token] page for unauthenticated clients with PDF download
- Live time tracker: localStorage-persisted timer widget in invoice editor that appends a line item on stop (rounds to nearest 0.25h)
- Partial payment tracking: record payments per invoice, auto-mark paid when fully covered, balance due display
- Send reminder: email reminder via Resend with custom message dialog and last-sent indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 17:17:58 -04:00
co-authored by Claude Sonnet 4.6
parent 47a0ccc88c
commit f0c34160df
16 changed files with 2222 additions and 195 deletions
+2
View File
@@ -805,6 +805,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onAddItemWithValues={addItemWithValues}
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
defaultRate={formData.items[0]?.rate}
/>
</CardContent>
</Card>
+20 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { Plus, Trash2, Zap } from "lucide-react";
import { Plus, Timer, Trash2, Zap } from "lucide-react";
import * as React from "react";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
@@ -15,6 +15,7 @@ import {
useLineItemSuggestions,
type LineItemSuggestion,
} from "~/hooks/use-line-item-suggestions";
import { TimeTracker } from "~/components/invoice/time-tracker";
interface InvoiceItem {
id: string;
@@ -35,6 +36,8 @@ interface InvoiceLineItemsProps {
value: string | number | Date,
) => void;
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
invoiceId?: string;
defaultRate?: number;
className?: string;
}
@@ -344,6 +347,8 @@ export function InvoiceLineItems({
onRemoveItem,
onUpdateItem,
onAddItemWithValues,
invoiceId,
defaultRate,
className,
}: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1;
@@ -417,6 +422,20 @@ export function InvoiceLineItems({
/>
</React.Fragment>
))}
{onAddItemWithValues && invoiceId && (
<div className="border-t p-3 space-y-2">
<p className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<Timer className="h-3.5 w-3.5" /> Time tracker
</p>
<TimeTracker
invoiceId={invoiceId}
defaultRate={defaultRate}
onStop={(hours, description) => {
onAddItemWithValues({ description, hours, rate: defaultRate ?? 0 });
}}
/>
</div>
)}
{onAddItemWithValues && (
<NLQuickAdd onAdd={onAddItemWithValues} />
)}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Play, Square } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
interface TimeTrackerProps {
invoiceId: string;
onStop: (hours: number, description: string) => void;
defaultRate?: number;
}
interface PersistedState {
running: boolean;
startedAt: number | null;
description: string;
}
function storageKey(invoiceId: string) {
return `time-tracker-${invoiceId}`;
}
function formatElapsed(seconds: number) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
}
function readPersistedState(invoiceId: string): PersistedState {
if (typeof window === "undefined") return { running: false, startedAt: null, description: "" };
try {
const raw = localStorage.getItem(storageKey(invoiceId));
if (raw) return JSON.parse(raw) as PersistedState;
} catch {
// ignore
}
return { running: false, startedAt: null, description: "" };
}
export function TimeTracker({ invoiceId, onStop }: TimeTrackerProps) {
const [running, setRunning] = useState(() => readPersistedState(invoiceId).running);
const [startedAt, setStartedAt] = useState<number | null>(
() => readPersistedState(invoiceId).startedAt,
);
const [elapsed, setElapsed] = useState(() => {
const s = readPersistedState(invoiceId);
if (s.running && s.startedAt) {
return Math.max(0, Math.floor((Date.now() - s.startedAt) / 1000));
}
return 0;
});
const [description, setDescription] = useState(
() => readPersistedState(invoiceId).description,
);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (running && startedAt !== null) {
intervalRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [running, startedAt]);
function persist(state: PersistedState) {
localStorage.setItem(storageKey(invoiceId), JSON.stringify(state));
}
function handleStart() {
const now = Date.now();
setStartedAt(now);
setElapsed(0);
setRunning(true);
persist({ running: true, startedAt: now, description });
}
function handleStop() {
if (intervalRef.current) clearInterval(intervalRef.current);
const hours = Math.max(0.25, Math.ceil(elapsed / 900) * 0.25);
setRunning(false);
setStartedAt(null);
setElapsed(0);
localStorage.removeItem(storageKey(invoiceId));
onStop(hours, description);
setDescription("");
}
return (
<div className="bg-secondary flex flex-col gap-3 rounded-lg p-3 sm:flex-row sm:items-center">
{running ? (
<>
<span className="text-primary font-mono text-xl font-bold tabular-nums">
{formatElapsed(elapsed)}
</span>
<Input
value={description}
onChange={(e) => {
setDescription(e.target.value);
persist({ running: true, startedAt, description: e.target.value });
}}
placeholder="What are you working on?"
className="flex-1"
/>
<Button type="button" variant="default" size="sm" onClick={handleStop} className="shrink-0">
<Square className="mr-1 h-3.5 w-3.5" />
Stop & add
</Button>
</>
) : (
<>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What will you work on?"
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleStart();
}
}}
/>
<Button type="button" variant="secondary" size="sm" onClick={handleStart} className="shrink-0">
<Play className="mr-1 h-3.5 w-3.5" />
Start timer
</Button>
</>
)}
</div>
);
}