add receipts support

This commit is contained in:
2026-06-26 23:07:43 -04:00
parent 85df7c4627
commit b9a9b813d2
55 changed files with 3239 additions and 441 deletions
+25 -5
View File
@@ -108,19 +108,26 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
// Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } =
api.businesses.getById.useQuery(
{ id: businessId! },
{ enabled: mode === "edit" && !!businessId },
{
enabled: mode === "edit" && !!businessId,
refetchOnWindowFocus: false,
},
);
// Fetch email configuration if editing
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
api.businesses.getEmailConfig.useQuery(
{ id: businessId! },
{ enabled: mode === "edit" && !!businessId },
{
enabled: mode === "edit" && !!businessId,
refetchOnWindowFocus: false,
},
);
// Update email configuration mutation
@@ -142,9 +149,21 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
},
});
// Load business data when editing
useEffect(() => {
if (business && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
setInitialized(false);
setIsDirty(false);
setFormData(initialFormData);
}, [businessId]);
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
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,
@@ -164,8 +183,9 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
resendDomain: emailConfig?.resendDomain ?? "",
emailFromName: emailConfig?.emailFromName ?? "",
});
setInitialized(true);
}
}, [business, emailConfig, mode]);
}, [business, emailConfig, mode, initialized, isLoadingEmailConfig]);
const handleInputChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
+16 -4
View File
@@ -90,12 +90,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
// Fetch client data if editing
const { data: client, isLoading: isLoadingClient } =
api.clients.getById.useQuery(
{ id: clientId! },
{ enabled: mode === "edit" && !!clientId },
{
enabled: mode === "edit" && !!clientId,
refetchOnWindowFocus: false,
},
);
const createClient = api.clients.create.useMutation({
@@ -118,9 +122,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
},
});
// Load client data when editing
useEffect(() => {
if (client && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different client.
setInitialized(false);
setIsDirty(false);
setFormData(initialFormData);
}, [clientId]);
// Load client data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
if (client && mode === "edit" && !initialized) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
setFormData({
name: client.name,
@@ -135,8 +146,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
defaultHourlyRate: client.defaultHourlyRate ?? null,
currency: client.currency ?? "USD",
});
setInitialized(true);
}
}, [client, mode]);
}, [client, mode, initialized]);
const handleInputChange = (field: string, value: string | number | null) => {
setFormData((prev) => ({ ...prev, [field]: value }));
+3 -2
View File
@@ -2,6 +2,7 @@
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
interface EmailPreviewProps {
subject: string;
@@ -54,7 +55,7 @@ export function EmailPreview({
const calculateTotal = () => {
if (!invoice?.items) return 0;
const subtotal = invoice.items.reduce(
(sum, item) => sum + item.hours * item.rate,
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0,
);
const taxAmount = subtotal * (invoice.taxRate / 100);
@@ -83,7 +84,7 @@ export function EmailPreview({
description: item.description ?? "Service",
hours: item.hours,
rate: item.rate,
amount: item.amount ?? item.hours * item.rate,
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
})) ?? [],
},
customContent: content,
@@ -33,6 +33,7 @@ import {
ChevronRight,
} from "lucide-react";
import { cn } from "~/lib/utils";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
interface InvoiceItem {
id: string;
@@ -493,7 +494,7 @@ export function InvoiceCalendarView({
Total
</span>
<span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)}
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
</span>
</div>
</div>
+41 -15
View File
@@ -60,6 +60,11 @@ import {
import { STATUS_OPTIONS } from "./invoice/types";
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
import type { ParsedLineItem } from "~/lib/parse-line-item";
import {
applyBillingTypeChange,
calculateLineItemAmount,
getLineItemBillingType,
} from "~/lib/invoice-line-item";
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
import { CountUp } from "~/components/ui/count-up";
@@ -123,6 +128,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
hours: 1,
rate: 0,
amount: 0,
billingType: "hourly",
},
],
};
@@ -180,6 +186,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: item.hours,
rate: item.rate,
amount: item.amount,
billingType: getLineItemBillingType(item.hours),
})) || [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
setFormData({
@@ -206,6 +213,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: 1,
rate: 0,
amount: 0,
billingType: "hourly",
},
],
});
@@ -238,7 +246,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const totals = React.useMemo(() => {
const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate,
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0,
);
const taxAmount = (subtotal * formData.taxRate) / 100;
@@ -266,9 +274,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
items: formData.items.map((item) => ({
date: item.date,
description: item.description || "Service",
hours: item.hours,
rate: item.rate,
})),
hours: item.hours,
rate: item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
})),
}),
[formData],
);
@@ -298,6 +307,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: 1,
rate: prev.defaultHourlyRate ?? 0,
amount: prev.defaultHourlyRate ?? 0,
billingType: "hourly",
},
],
}));
@@ -313,7 +323,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: parsed.description,
hours: parsed.hours ?? 1,
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
amount: (parsed.hours ?? 1) * (parsed.rate ?? prev.defaultHourlyRate ?? 0),
amount: calculateLineItemAmount(
parsed.hours ?? 1,
parsed.rate ?? prev.defaultHourlyRate ?? 0,
),
billingType: "hourly",
},
],
}));
@@ -333,14 +347,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
setFormData((prev) => ({
...prev,
items: prev.items.map((item, i) => {
if (i === idx) {
const updated = { ...item, [field]: value };
if (field === "hours" || field === "rate") {
updated.amount = updated.hours * updated.rate;
}
return updated;
if (i !== idx) return item;
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
const next = applyBillingTypeChange(value, item);
return {
...item,
...next,
billingType: value,
};
}
return item;
const updated = { ...item, [field]: value };
if (field === "hours" || field === "rate") {
updated.amount = calculateLineItemAmount(updated.hours, updated.rate);
updated.billingType = getLineItemBillingType(updated.hours);
}
return updated;
}),
}));
};
@@ -435,7 +458,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: i.description,
hours: i.hours,
rate: i.rate,
amount: i.hours * i.rate,
amount: calculateLineItemAmount(i.hours, i.rate),
})),
};
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
@@ -771,7 +794,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<span className="text-muted-foreground">Hours</span>
<span className="font-mono text-xl font-semibold">
<CountUp
value={formData.items.reduce((s, i) => s + i.hours, 0)}
value={formData.items.reduce(
(s, i) => s + (i.hours > 0 ? i.hours : 0),
0,
)}
suffix="h"
/>
</span>
@@ -898,7 +924,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
})),
}}
/>
+86 -22
View File
@@ -10,11 +10,23 @@ import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { cn } from "~/lib/utils";
import {
calculateLineItemAmount,
getLineItemBillingType,
type LineItemBillingType,
} from "~/lib/invoice-line-item";
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
import {
useLineItemSuggestions,
type LineItemSuggestion,
} from "~/hooks/use-line-item-suggestions";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
interface InvoiceItem {
id: string;
@@ -23,6 +35,7 @@ interface InvoiceItem {
hours: number;
rate: number;
amount: number;
billingType?: LineItemBillingType;
}
interface InvoiceLineItemsProps {
@@ -149,13 +162,21 @@ function DescriptionAutocomplete({
);
}
const LINE_ITEM_GRID =
"grid-cols-[minmax(11.5rem,auto)_minmax(160px,1fr)_76px_96px_108px_88px_28px]";
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
const isFixed = billingType === "fixed";
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
return (
<div
ref={ref}
className={cn(
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
"group hover:bg-muted/30 hidden min-h-11 items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
LINE_ITEM_GRID,
)}
>
<DatePicker
@@ -177,16 +198,36 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
disabled={readOnly}
/>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
suffix="h"
<Select
value={billingType}
onValueChange={(value: LineItemBillingType) =>
onUpdate(index, "billingType", value)
}
disabled={readOnly}
/>
>
<SelectTrigger className="h-8 w-full px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
</SelectContent>
</Select>
{isFixed ? (
<span className="text-muted-foreground text-center text-xs"></span>
) : (
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
suffix="h"
disabled={readOnly}
/>
)}
<NumberInput
value={item.rate}
@@ -200,7 +241,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
/>
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)}
${lineTotal.toFixed(2)}
</div>
{!readOnly ? (
@@ -235,6 +276,10 @@ function MobileLineItem({
onDescriptionChange,
readOnly,
}: LineItemRowProps) {
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
const isFixed = billingType === "fixed";
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
return (
<div
id={`invoice-item-${index}-mobile`}
@@ -264,16 +309,33 @@ function MobileLineItem({
inputClassName="h-8 px-2 text-xs"
disabled={readOnly}
/>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
suffix="h"
<Select
value={billingType}
onValueChange={(value: LineItemBillingType) =>
onUpdate(index, "billingType", value)
}
disabled={readOnly}
/>
>
<SelectTrigger className="h-8 w-[76px] shrink-0 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
</SelectContent>
</Select>
{!isFixed ? (
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
suffix="h"
disabled={readOnly}
/>
) : null}
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
@@ -285,7 +347,7 @@ function MobileLineItem({
disabled={readOnly}
/>
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)}
${lineTotal.toFixed(2)}
</span>
{!readOnly ? (
<Button
@@ -357,6 +419,7 @@ export function InvoiceLineItems({
onUpdateItem(index, "description", s.description);
onUpdateItem(index, "hours", s.hours);
onUpdateItem(index, "rate", s.rate);
onUpdateItem(index, "billingType", "hourly");
setSuggestions([]);
setQueriedIndex(null);
}
@@ -374,9 +437,10 @@ export function InvoiceLineItems({
) : null}
<AnimatePresence>
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
<div className={cn("bg-muted/60 text-muted-foreground hidden gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid", LINE_ITEM_GRID)}>
<span>Date</span>
<span>Description</span>
<span className="text-center">Type</span>
<span className="text-center">Hours</span>
<span className="text-center">Rate</span>
<span className="text-right">Amount</span>
+3
View File
@@ -3,6 +3,8 @@ import { type RouterOutputs } from "~/trpc/react";
export type ClientType = RouterOutputs["clients"]["getAll"][number];
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
import type { LineItemBillingType } from "~/lib/invoice-line-item";
export interface InvoiceItem {
id: string;
date: Date;
@@ -10,6 +12,7 @@ export interface InvoiceItem {
hours: number;
rate: number;
amount: number;
billingType: LineItemBillingType;
}
export interface InvoiceFormData {
@@ -30,6 +30,8 @@ const SPECIAL_SEGMENTS: Record<string, string> = {
import: "Import",
export: "Export",
dashboard: "Dashboard",
entries: "All entries",
"time-clock": "Time clock",
};
import { cn } from "~/lib/utils";
+136 -92
View File
@@ -35,6 +35,8 @@ import {
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
} from "~/lib/time-clock";
import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
const FEATURED_CLIENT_COUNT = 4;
@@ -46,26 +48,6 @@ export type TimeClockPanelProps = {
compact?: boolean;
};
function invoiceLabel(inv: {
invoicePrefix: string | null;
invoiceNumber: string;
}) {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
function entryHref(entry: {
invoiceId: string | null;
clientId: string | null;
invoice?: { id: string } | null;
client?: { id: string } | null;
}): string | null {
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
const clientId = entry.clientId ?? entry.client?.id;
if (clientId) return `/dashboard/clients/${clientId}`;
return null;
}
function ClientChip({
label,
active,
@@ -145,6 +127,10 @@ export function TimeClockPanel({
const last = getLastTimeClockClientId();
if (last) ids.push(last);
if (running?.clientId && !ids.includes(running.clientId)) {
ids.unshift(running.clientId);
}
for (const entry of todayEntries ?? []) {
if (entry.clientId && !ids.includes(entry.clientId)) {
ids.push(entry.clientId);
@@ -157,7 +143,7 @@ export function TimeClockPanel({
}
return ids;
}, [clients, todayEntries]);
}, [clients, todayEntries, running]);
const visibleClients = useMemo(() => {
if (!clients?.length) return [];
@@ -191,6 +177,14 @@ export function TimeClockPanel({
onError: (e) => toast.error(e.message),
});
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: () => {
void utils.timeEntries.getRunning.invalidate();
void utils.invoices.getBillable.invalidate();
},
onError: (e) => toast.error(e.message),
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: (data) => {
const message = describeClockOutOutcome({
@@ -227,6 +221,11 @@ export function TimeClockPanel({
});
function handleClientChange(value: string) {
if (running) {
updateRunning.mutate({ clientId: value, invoiceId: "" });
return;
}
setClientId(value);
setInvoiceId("");
setLastTimeClockClientId(value);
@@ -234,6 +233,15 @@ export function TimeClockPanel({
setRate(client?.defaultHourlyRate ?? 0);
}
function handleInvoiceChange(value: string) {
const next = value === "__none__" ? "" : value;
if (running) {
updateRunning.mutate({ invoiceId: next });
return;
}
setInvoiceId(next);
}
function resolveStartedAt(): Date | undefined {
if (startMode === "now") return undefined;
if (startMode === "pick") {
@@ -293,6 +301,8 @@ export function TimeClockPanel({
const displayRate = running ? (running.rate ?? 0) : rate;
const runningTitle = formatRunningTimerLabel(running?.description);
const activeClientId = running ? (running.clientId ?? "") : clientId;
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
return (
<div className={compact ? "space-y-4" : "space-y-6"}>
@@ -347,7 +357,7 @@ export function TimeClockPanel({
<ClientChip
key={client.id}
label={client.name}
active={clientId === client.id}
active={activeClientId === client.id}
onClick={() => handleClientChange(client.id)}
/>
))}
@@ -383,7 +393,7 @@ export function TimeClockPanel({
<Label>Invoice</Label>
<Select
value={invoiceId || "__none__"}
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
onValueChange={handleInvoiceChange}
disabled={!clientId}
>
<SelectTrigger>
@@ -485,19 +495,91 @@ export function TimeClockPanel({
</Collapsible>
</>
) : (
<div className="space-y-2">
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
<Input
id="clock-stop-note"
value={stopNote}
onChange={(e) => setStopNote(e.target.value)}
placeholder={
running?.description?.trim()
? running.description
: "Update description when you stop"
}
/>
</div>
<>
<div className="space-y-2">
<Label>Client</Label>
<div className="flex flex-wrap gap-2">
{visibleClients.map((client) => (
<ClientChip
key={client.id}
label={client.name}
active={activeClientId === client.id}
onClick={() => handleClientChange(client.id)}
/>
))}
{!showAllClients && hiddenClientCount > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setShowAllClients(true)}
>
+{hiddenClientCount} more
</Button>
) : null}
</div>
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
<Select
value={activeClientId || undefined}
onValueChange={handleClientChange}
disabled={updateRunning.isPending}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{clients?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label>Invoice</Label>
<Select
value={activeInvoiceId || "__none__"}
onValueChange={handleInvoiceChange}
disabled={!activeClientId || updateRunning.isPending}
>
<SelectTrigger>
<SelectValue
placeholder={
activeClientId
? "Draft invoice (optional)"
: "Choose a client first"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No invoice save entry only</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
<Input
id="clock-stop-note"
value={stopNote}
onChange={(e) => setStopNote(e.target.value)}
placeholder={
running?.description?.trim()
? running.description
: "Update description when you stop"
}
/>
</div>
</>
)}
{running ? (
@@ -529,66 +611,28 @@ export function TimeClockPanel({
</CardContent>
</Card>
{!compact && todayEntries && todayEntries.length > 0 ? (
{!compact ? (
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Today&apos;s entries</CardTitle>
<Button variant="ghost" size="sm" className="h-8" asChild>
<Link href="/dashboard/time-clock/entries">View all entries</Link>
</Button>
</CardHeader>
<CardContent>
{todayEntries
.filter((e) => e.endedAt)
.map((entry, index, entries) => {
const href = entryHref(entry);
const isLast = index === entries.length - 1;
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
);
const content = (
<>
<div className="min-w-0">
<p className="font-medium">
{formatRunningTimerLabel(entry.description)}
</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: entry.hours
? " · not on invoice"
: ""}
</p>
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? (
<p className="text-muted-foreground">${entry.rate}/hr</p>
) : null}
</div>
</>
);
if (href) {
return (
<Link
key={entry.id}
href={href}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</Link>
);
}
return (
<div key={entry.id} className={rowClassName}>
{content}
</div>
);
})}
{todayEntries?.some((e) => e.endedAt) ? (
<TimeEntryList entries={todayEntries} />
) : (
<p className="text-muted-foreground py-4 text-center text-sm">
No entries today.{" "}
<Link
href="/dashboard/time-clock/entries"
className="text-primary hover:underline"
>
View history
</Link>
</p>
)}
</CardContent>
</Card>
) : null}
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { EmptyState } from "~/components/layout/page-layout";
import { Clock, Play } from "lucide-react";
import { groupEntriesByDate } from "~/lib/time-entry-display";
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
const completedEntries = useMemo(
() => (entries ?? []).filter((e) => e.endedAt),
[entries],
);
const grouped = useMemo(
() => groupEntriesByDate(completedEntries),
[completedEntries],
);
if (isLoading) {
return (
<Card>
<CardContent className="text-muted-foreground p-6 text-sm">
Loading entries
</CardContent>
</Card>
);
}
if (completedEntries.length === 0) {
return (
<Card>
<CardContent>
<EmptyState
icon={<Clock className="h-6 w-6" />}
title="No time entries yet"
description="Start the timer to track billable hours. Completed entries will show up here."
action={
<Button asChild>
<Link href="/dashboard/time-clock">
<Play className="mr-2 h-4 w-4" />
Start timer
</Link>
</Button>
}
className="py-16"
/>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{grouped.map((group) => (
<Card key={group.dateKey}>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
{group.label}
</CardTitle>
</CardHeader>
<CardContent>
{group.entries.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === group.entries.length - 1}
/>
))}
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,76 @@
import Link from "next/link";
import { cn } from "~/lib/utils";
import { formatRunningTimerLabel } from "~/lib/time-clock";
import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntryRow({
entry,
isLast,
}: {
entry: TimeEntryListItem;
isLast?: boolean;
}) {
const href = entryHref(entry);
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
);
const content = (
<>
<div className="min-w-0">
<p className="font-medium">{formatRunningTimerLabel(entry.description)}</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${invoiceLabel(entry.invoice)}`
: entry.hours
? " · not on invoice"
: ""}
</p>
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? <p className="text-muted-foreground">${entry.rate}/hr</p> : null}
</div>
</>
);
if (href) {
return (
<Link
href={href}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</Link>
);
}
return (
<div className={rowClassName}>
{content}
</div>
);
}
export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
const completed = entries.filter((e) => e.endedAt);
if (completed.length === 0) return null;
return (
<>
{completed.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === completed.length - 1}
/>
))}
</>
);
}