Add draft-only invoicing rules, send reminders, and time clock billing.
Restrict line item edits to draft invoices, auto-create drafts on clock-out, and add sendReminderAt scheduling with dashboard due reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "beenvoice_invoice" ADD COLUMN "send_reminder_at" timestamp;
|
||||
@@ -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 <InvoiceDetailsSkeleton />;
|
||||
if (!invoice) notFound();
|
||||
|
||||
@@ -522,7 +532,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
{effectiveStatus !== "paid" && (
|
||||
{storedStatus === "draft" && (
|
||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||
)}
|
||||
|
||||
@@ -553,6 +563,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveStatus === "draft" && (
|
||||
<SendReminderEditor
|
||||
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
|
||||
invoiceId={invoiceId}
|
||||
savedReminderAt={invoice.sendReminderAt}
|
||||
formatDate={formatDate}
|
||||
isSaving={updateInvoice.isPending}
|
||||
onSave={(sendReminderAt) =>
|
||||
updateInvoice.mutate({
|
||||
id: invoiceId,
|
||||
sendReminderAt,
|
||||
})
|
||||
}
|
||||
onClear={() =>
|
||||
updateInvoice.mutate({ id: invoiceId, sendReminderAt: null })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||
<EnhancedSendInvoiceButton
|
||||
invoiceId={invoice.id}
|
||||
@@ -808,6 +837,67 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SendReminderEditor({
|
||||
invoiceId,
|
||||
savedReminderAt,
|
||||
formatDate,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClear,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
savedReminderAt: Date | null | undefined;
|
||||
formatDate: (date: Date) => string;
|
||||
isSaving: boolean;
|
||||
onSave: (sendReminderAt: Date | null) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() =>
|
||||
savedReminderAt ? new Date(savedReminderAt) : undefined,
|
||||
);
|
||||
|
||||
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}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onSave(sendReminderAt ?? null)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save reminder
|
||||
</Button>
|
||||
{sendReminderAt ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSendReminderAt(undefined);
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{savedReminderAt ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{new Date(savedReminderAt) <= new Date()
|
||||
? "Reminder is due — time to send this invoice."
|
||||
: `Scheduled for ${formatDate(savedReminderAt)}`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoiceViewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -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<Date | undefined>(undefined); // Start unselected
|
||||
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
||||
@@ -403,10 +405,12 @@ export function InvoiceCalendarView({
|
||||
There are no time entries recorded for this day yet.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Log Time
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Log Time
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -428,6 +432,7 @@ export function InvoiceCalendarView({
|
||||
}
|
||||
placeholder="Describe the work performed..."
|
||||
className="pl-3 text-sm"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -443,6 +448,7 @@ export function InvoiceCalendarView({
|
||||
step={0.25}
|
||||
min={0}
|
||||
width="full"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -456,6 +462,7 @@ export function InvoiceCalendarView({
|
||||
min={0}
|
||||
step={1}
|
||||
width="full"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -464,15 +471,17 @@ export function InvoiceCalendarView({
|
||||
{/* Bottom section with controls, item name, and total */}
|
||||
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveItem(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveItem(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 px-3 text-center">
|
||||
<span className="text-muted-foreground block text-sm font-medium">
|
||||
@@ -490,16 +499,18 @@ export function InvoiceCalendarView({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddNewItem}
|
||||
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
|
||||
>
|
||||
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
<span>Add Another Entry</span>
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddNewItem}
|
||||
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
|
||||
>
|
||||
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
<span>Add Another Entry</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -831,6 +832,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
onRemoveItem={removeItem}
|
||||
onUpdateItem={updateItem}
|
||||
defaultHourlyRate={formData.defaultHourlyRate}
|
||||
readOnly={formData.status !== "draft"}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border shadow-md">
|
||||
@@ -146,7 +151,7 @@ function DescriptionAutocomplete({
|
||||
}
|
||||
|
||||
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange }, ref) => {
|
||||
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -160,6 +165,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
size="sm"
|
||||
className="w-full"
|
||||
inputClassName="h-9"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<DescriptionAutocomplete
|
||||
@@ -169,6 +175,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
suggestions={suggestions}
|
||||
placeholder="Describe the work performed..."
|
||||
className="h-9 w-full text-sm font-medium"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
@@ -179,6 +186,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
width="full"
|
||||
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
|
||||
suffix="h"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
@@ -189,23 +197,28 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
prefix="$"
|
||||
width="full"
|
||||
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<div className="text-primary text-right font-mono font-semibold">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -221,6 +234,7 @@ function MobileLineItem({
|
||||
suggestions,
|
||||
onSelectSuggestion,
|
||||
onDescriptionChange,
|
||||
readOnly,
|
||||
}: LineItemRowProps) {
|
||||
return (
|
||||
<motion.div
|
||||
@@ -243,6 +257,7 @@ function MobileLineItem({
|
||||
suggestions={suggestions}
|
||||
placeholder="Describe the work performed..."
|
||||
className="pl-3 text-sm"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -254,6 +269,7 @@ function MobileLineItem({
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
size="sm"
|
||||
inputClassName="h-9"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -267,6 +283,7 @@ function MobileLineItem({
|
||||
min={0}
|
||||
step={0.25}
|
||||
width="full"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -279,6 +296,7 @@ function MobileLineItem({
|
||||
prefix="$"
|
||||
width="full"
|
||||
className="font-mono"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,17 +305,19 @@ function MobileLineItem({
|
||||
{/* Bottom section with controls, item name, and total */}
|
||||
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 px-3 text-center">
|
||||
<span className="text-muted-foreground block text-sm font-medium">
|
||||
@@ -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 (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{readOnly ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Line items are locked after an invoice is sent. Revert to draft to edit entries.
|
||||
</p>
|
||||
) : null}
|
||||
<AnimatePresence>
|
||||
<div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid">
|
||||
@@ -408,6 +434,7 @@ export function InvoiceLineItems({
|
||||
suggestions={getSuggestionsForIndex(index)}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -421,6 +448,7 @@ export function InvoiceLineItems({
|
||||
suggestions={getSuggestionsForIndex(index)}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -444,22 +472,23 @@ export function InvoiceLineItems({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{onAddItemWithValues && (
|
||||
{onAddItemWithValues && !readOnly ? (
|
||||
<NLQuickAdd onAdd={onAddItemWithValues} />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Add Item Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onAddItem}
|
||||
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-3 w-full border-dashed py-6 transition-all"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Line Item
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onAddItem}
|
||||
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-3 w-full border-dashed py-6 transition-all"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Line Item
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user