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 { Textarea } from "~/components/ui/textarea";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import {
|
import {
|
||||||
getEffectiveInvoiceStatus,
|
getEffectiveInvoiceStatus,
|
||||||
isInvoiceOverdue,
|
isInvoiceOverdue,
|
||||||
@@ -166,6 +167,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
onError: (e) => toast.error(e.message ?? "Failed to send reminder"),
|
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 (isLoading) return <InvoiceDetailsSkeleton />;
|
||||||
if (!invoice) notFound();
|
if (!invoice) notFound();
|
||||||
|
|
||||||
@@ -522,7 +532,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{effectiveStatus !== "paid" && (
|
{storedStatus === "draft" && (
|
||||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
<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") && (
|
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||||
<EnhancedSendInvoiceButton
|
<EnhancedSendInvoiceButton
|
||||||
invoiceId={invoice.id}
|
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() {
|
export default function InvoiceViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ interface InvoiceCalendarViewProps {
|
|||||||
onRemoveItem: (index: number) => void;
|
onRemoveItem: (index: number) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
defaultHourlyRate: number | null;
|
defaultHourlyRate: number | null;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceCalendarView({
|
export function InvoiceCalendarView({
|
||||||
@@ -63,6 +64,7 @@ export function InvoiceCalendarView({
|
|||||||
onRemoveItem,
|
onRemoveItem,
|
||||||
className,
|
className,
|
||||||
defaultHourlyRate: _defaultHourlyRate,
|
defaultHourlyRate: _defaultHourlyRate,
|
||||||
|
readOnly = false,
|
||||||
}: InvoiceCalendarViewProps) {
|
}: InvoiceCalendarViewProps) {
|
||||||
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
||||||
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
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.
|
There are no time entries recorded for this day yet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Log Time
|
Log Time
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -428,6 +432,7 @@ export function InvoiceCalendarView({
|
|||||||
}
|
}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Describe the work performed..."
|
||||||
className="pl-3 text-sm"
|
className="pl-3 text-sm"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -443,6 +448,7 @@ export function InvoiceCalendarView({
|
|||||||
step={0.25}
|
step={0.25}
|
||||||
min={0}
|
min={0}
|
||||||
width="full"
|
width="full"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -456,6 +462,7 @@ export function InvoiceCalendarView({
|
|||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
width="full"
|
width="full"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,6 +471,7 @@ export function InvoiceCalendarView({
|
|||||||
{/* Bottom section with controls, item name, and total */}
|
{/* 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="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -473,6 +481,7 @@ export function InvoiceCalendarView({
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 px-3 text-center">
|
<div className="flex-1 px-3 text-center">
|
||||||
<span className="text-muted-foreground block text-sm font-medium">
|
<span className="text-muted-foreground block text-sm font-medium">
|
||||||
@@ -490,6 +499,7 @@ export function InvoiceCalendarView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleAddNewItem}
|
onClick={handleAddNewItem}
|
||||||
@@ -500,6 +510,7 @@ export function InvoiceCalendarView({
|
|||||||
</div>
|
</div>
|
||||||
<span>Add Another Entry</span>
|
<span>Add Another Entry</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -808,6 +808,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
||||||
clientId={formData.clientId || undefined}
|
clientId={formData.clientId || undefined}
|
||||||
defaultRate={formData.items[0]?.rate}
|
defaultRate={formData.items[0]?.rate}
|
||||||
|
readOnly={formData.status !== "draft"}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -831,6 +832,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
onRemoveItem={removeItem}
|
onRemoveItem={removeItem}
|
||||||
onUpdateItem={updateItem}
|
onUpdateItem={updateItem}
|
||||||
defaultHourlyRate={formData.defaultHourlyRate}
|
defaultHourlyRate={formData.defaultHourlyRate}
|
||||||
|
readOnly={formData.status !== "draft"}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface InvoiceLineItemsProps {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
defaultRate?: number;
|
defaultRate?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LineItemRowProps {
|
interface LineItemRowProps {
|
||||||
@@ -55,6 +56,7 @@ interface LineItemRowProps {
|
|||||||
suggestions: LineItemSuggestion[];
|
suggestions: LineItemSuggestion[];
|
||||||
onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void;
|
onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void;
|
||||||
onDescriptionChange: (index: number, value: string) => void;
|
onDescriptionChange: (index: number, value: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DescriptionAutocompleteProps {
|
interface DescriptionAutocompleteProps {
|
||||||
@@ -64,6 +66,7 @@ interface DescriptionAutocompleteProps {
|
|||||||
suggestions: LineItemSuggestion[];
|
suggestions: LineItemSuggestion[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DescriptionAutocomplete({
|
function DescriptionAutocomplete({
|
||||||
@@ -73,6 +76,7 @@ function DescriptionAutocomplete({
|
|||||||
suggestions,
|
suggestions,
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
|
disabled,
|
||||||
}: DescriptionAutocompleteProps) {
|
}: DescriptionAutocompleteProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
@@ -116,6 +120,7 @@ function DescriptionAutocomplete({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={className}
|
className={className}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
{showDropdown && (
|
{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">
|
<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>(
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -160,6 +165,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
inputClassName="h-9"
|
inputClassName="h-9"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DescriptionAutocomplete
|
<DescriptionAutocomplete
|
||||||
@@ -169,6 +175,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Describe the work performed..."
|
||||||
className="h-9 w-full text-sm font-medium"
|
className="h-9 w-full text-sm font-medium"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -179,6 +186,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
width="full"
|
width="full"
|
||||||
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
|
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
|
||||||
suffix="h"
|
suffix="h"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -189,12 +197,14 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
prefix="$"
|
prefix="$"
|
||||||
width="full"
|
width="full"
|
||||||
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
|
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-primary text-right font-mono font-semibold">
|
<div className="text-primary text-right font-mono font-semibold">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -206,6 +216,9 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -221,6 +234,7 @@ function MobileLineItem({
|
|||||||
suggestions,
|
suggestions,
|
||||||
onSelectSuggestion,
|
onSelectSuggestion,
|
||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
|
readOnly,
|
||||||
}: LineItemRowProps) {
|
}: LineItemRowProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -243,6 +257,7 @@ function MobileLineItem({
|
|||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Describe the work performed..."
|
||||||
className="pl-3 text-sm"
|
className="pl-3 text-sm"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -254,6 +269,7 @@ function MobileLineItem({
|
|||||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||||
size="sm"
|
size="sm"
|
||||||
inputClassName="h-9"
|
inputClassName="h-9"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,6 +283,7 @@ function MobileLineItem({
|
|||||||
min={0}
|
min={0}
|
||||||
step={0.25}
|
step={0.25}
|
||||||
width="full"
|
width="full"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -279,6 +296,7 @@ function MobileLineItem({
|
|||||||
prefix="$"
|
prefix="$"
|
||||||
width="full"
|
width="full"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,6 +305,7 @@ function MobileLineItem({
|
|||||||
{/* Bottom section with controls, item name, and total */}
|
{/* 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="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -298,6 +317,7 @@ function MobileLineItem({
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 px-3 text-center">
|
<div className="flex-1 px-3 text-center">
|
||||||
<span className="text-muted-foreground block text-sm font-medium">
|
<span className="text-muted-foreground block text-sm font-medium">
|
||||||
@@ -352,6 +372,7 @@ export function InvoiceLineItems({
|
|||||||
clientId,
|
clientId,
|
||||||
defaultRate: _defaultRate,
|
defaultRate: _defaultRate,
|
||||||
className,
|
className,
|
||||||
|
readOnly = false,
|
||||||
}: InvoiceLineItemsProps) {
|
}: InvoiceLineItemsProps) {
|
||||||
const canRemoveItems = items.length > 1;
|
const canRemoveItems = items.length > 1;
|
||||||
const { search } = useLineItemSuggestions();
|
const { search } = useLineItemSuggestions();
|
||||||
@@ -378,6 +399,11 @@ export function InvoiceLineItems({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", className)}>
|
<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>
|
<AnimatePresence>
|
||||||
<div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
<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">
|
<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)}
|
suggestions={getSuggestionsForIndex(index)}
|
||||||
onSelectSuggestion={handleSelectSuggestion}
|
onSelectSuggestion={handleSelectSuggestion}
|
||||||
onDescriptionChange={handleDescriptionChange}
|
onDescriptionChange={handleDescriptionChange}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -421,6 +448,7 @@ export function InvoiceLineItems({
|
|||||||
suggestions={getSuggestionsForIndex(index)}
|
suggestions={getSuggestionsForIndex(index)}
|
||||||
onSelectSuggestion={handleSelectSuggestion}
|
onSelectSuggestion={handleSelectSuggestion}
|
||||||
onDescriptionChange={handleDescriptionChange}
|
onDescriptionChange={handleDescriptionChange}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
@@ -444,13 +472,13 @@ export function InvoiceLineItems({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onAddItemWithValues && (
|
{onAddItemWithValues && !readOnly ? (
|
||||||
<NLQuickAdd onAdd={onAddItemWithValues} />
|
<NLQuickAdd onAdd={onAddItemWithValues} />
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Add Item Button */}
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -460,6 +488,7 @@ export function InvoiceLineItems({
|
|||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Line Item
|
Add Line Item
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function TimeClockPanel({
|
|||||||
invoicePrefix: string | null;
|
invoicePrefix: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
status: string;
|
status: string;
|
||||||
}) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.status})`;
|
}) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
||||||
|
|
||||||
const displayDescription = running ? running.description : description;
|
const displayDescription = running ? running.description : description;
|
||||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
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`;
|
return `Added ${input.hours}h to invoice`;
|
||||||
case "saved_no_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":
|
case "saved_no_client":
|
||||||
return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
|
return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
|
||||||
case "zero_hours":
|
case "zero_hours":
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import { invoices, clients } from "~/server/db/schema";
|
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({
|
export const dashboardRouter = createTRPCRouter({
|
||||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
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 {
|
return {
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
pendingAmount,
|
pendingAmount,
|
||||||
@@ -129,6 +149,7 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
: 0,
|
: 0,
|
||||||
revenueChartData,
|
revenueChartData,
|
||||||
recentInvoices,
|
recentInvoices,
|
||||||
|
sendReminderDue,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const createInvoiceSchema = z.object({
|
|||||||
emailMessage: z.string().optional().or(z.literal("")),
|
emailMessage: z.string().optional().or(z.literal("")),
|
||||||
taxRate: z.number().min(0).max(100).default(0),
|
taxRate: z.number().min(0).max(100).default(0),
|
||||||
currency: z.string().length(3).default("USD"),
|
currency: z.string().length(3).default("USD"),
|
||||||
|
sendReminderAt: z.date().nullable().optional(),
|
||||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
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
|
getBillable: protectedProcedure
|
||||||
.input(z.object({ clientId: z.string().optional() }).optional())
|
.input(z.object({ clientId: z.string().optional() }).optional())
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(invoices.createdById, ctx.session.user.id),
|
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));
|
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 business is being updated, verify it belongs to user
|
||||||
if (
|
if (
|
||||||
cleanInvoiceData.businessId &&
|
cleanInvoiceData.businessId &&
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { z } from "zod";
|
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 { 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 { TRPCError } from "@trpc/server";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
computeTrackedHours,
|
computeTrackedHours,
|
||||||
type ClockOutOutcome,
|
type ClockOutOutcome,
|
||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
|
|
||||||
type Db = typeof db;
|
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(
|
async function addEntryToLatestInvoice(
|
||||||
database: Db,
|
database: Db,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -96,20 +151,7 @@ async function addEntryToLatestInvoice(
|
|||||||
rate: number,
|
rate: number,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
|
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
|
||||||
const invoice = await database.query.invoices.findFirst({
|
const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
|
||||||
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),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!invoice) return null;
|
if (!invoice) return null;
|
||||||
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
|
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
|
||||||
}
|
}
|
||||||
@@ -128,7 +170,7 @@ async function addEntryToSpecificInvoice(
|
|||||||
where: and(
|
where: and(
|
||||||
eq(invoices.id, invoiceId),
|
eq(invoices.id, invoiceId),
|
||||||
eq(invoices.createdById, userId),
|
eq(invoices.createdById, userId),
|
||||||
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
eq(invoices.status, "draft"),
|
||||||
),
|
),
|
||||||
with: { items: true },
|
with: { items: true },
|
||||||
});
|
});
|
||||||
@@ -231,14 +273,14 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
where: and(
|
where: and(
|
||||||
eq(invoices.id, invoiceId),
|
eq(invoices.id, invoiceId),
|
||||||
eq(invoices.createdById, ctx.session.user.id),
|
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 },
|
columns: { id: true, clientId: true },
|
||||||
});
|
});
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
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) {
|
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
|
||||||
@@ -345,14 +387,14 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
where: and(
|
where: and(
|
||||||
eq(invoices.id, invoiceId),
|
eq(invoices.id, invoiceId),
|
||||||
eq(invoices.createdById, ctx.session.user.id),
|
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 },
|
columns: { id: true, clientId: true },
|
||||||
});
|
});
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
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) {
|
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ export const invoices = createTable(
|
|||||||
publicToken: d.varchar({ length: 255 }).unique(),
|
publicToken: d.varchar({ length: 255 }).unique(),
|
||||||
publicTokenExpiresAt: d.timestamp(),
|
publicTokenExpiresAt: d.timestamp(),
|
||||||
lastReminderSentAt: d.timestamp(),
|
lastReminderSentAt: d.timestamp(),
|
||||||
|
sendReminderAt: d.timestamp(),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp()
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
|||||||
Reference in New Issue
Block a user