Archived
add receipts support
This commit is contained in:
@@ -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 }));
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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'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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user