Unify entities navigation, redesign time clock, and add invoice PDF preview.
Combine clients and businesses under entities, polish the web time clock, and show live invoice PDF preview with tighter line-item editing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,7 +50,7 @@ export default async function BusinessDetailPage({
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Link href="/dashboard/businesses">
|
||||
<Link href="/dashboard/entities?tab=businesses">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
<span>Back to Businesses</span>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { BusinessesDataTable } from "./businesses-data-table";
|
||||
|
||||
export function BusinessesTable() {
|
||||
const { data: businesses, isLoading } = api.businesses.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <DataTableSkeleton columns={7} rows={5} />;
|
||||
}
|
||||
|
||||
if (!businesses) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <BusinessesDataTable businesses={businesses} />;
|
||||
}
|
||||
@@ -1,40 +1,5 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { BusinessesDataTable } from "./_components/businesses-data-table";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Businesses Table Component
|
||||
async function BusinessesTable() {
|
||||
const businesses = await api.businesses.getAll();
|
||||
|
||||
return <BusinessesDataTable businesses={businesses} />;
|
||||
}
|
||||
|
||||
export default async function BusinessesPage() {
|
||||
return (
|
||||
<div className="page-enter space-y-8">
|
||||
<PageHeader
|
||||
title="Businesses"
|
||||
description="Manage your businesses and their information"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href="/dashboard/businesses/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Add Business</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||
<BusinessesTable />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
export default function BusinessesPage() {
|
||||
redirect("/dashboard/entities?tab=businesses");
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default async function ClientDetailPage({
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Link href="/dashboard/clients">
|
||||
<Link href="/dashboard/entities?tab=clients">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
<span>Back to Clients</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { ClientsTable } from "./_components/clients-table";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader
|
||||
title="Clients"
|
||||
description="Manage your clients and their information."
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Add Client</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<ClientsTable />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
export default function ClientsPage() {
|
||||
redirect("/dashboard/entities?tab=clients");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { ClientsTable } from "../../clients/_components/clients-table";
|
||||
import { BusinessesTable } from "../../businesses/_components/businesses-table";
|
||||
|
||||
type EntityTab = "clients" | "businesses";
|
||||
|
||||
export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const tab: EntityTab =
|
||||
searchParams.get("tab") === "businesses" ? "businesses" : initialTab;
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
const next = value === "businesses" ? "businesses" : "clients";
|
||||
router.replace(`/dashboard/entities?tab=${next}`, { scroll: false });
|
||||
}
|
||||
|
||||
const addHref =
|
||||
tab === "clients" ? "/dashboard/clients/new" : "/dashboard/businesses/new";
|
||||
const addLabel = tab === "clients" ? "Add client" : "Add business";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Entities"
|
||||
description="Clients you bill and businesses you send from"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href={addHref}>
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>{addLabel}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="clients">Clients</TabsTrigger>
|
||||
<TabsTrigger value="businesses">Businesses</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clients" className="mt-6">
|
||||
<ClientsTable />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="businesses" className="mt-6">
|
||||
<BusinessesTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Suspense } from "react";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { EntitiesView } from "./_components/entities-view";
|
||||
|
||||
export default async function EntitiesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const initialTab = params.tab === "businesses" ? "businesses" : "clients";
|
||||
|
||||
void api.clients.getAll.prefetch();
|
||||
void api.businesses.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}>
|
||||
<EntitiesView initialTab={initialTab} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export default async function TimeClockPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-enter mx-auto max-w-3xl space-y-6">
|
||||
<div className="page-enter space-y-6">
|
||||
<DashboardPageHeader
|
||||
title="Time clock"
|
||||
description="Track billable hours and save them directly to an invoice"
|
||||
|
||||
@@ -338,7 +338,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
}
|
||||
|
||||
toast.success("Business created successfully");
|
||||
router.push("/dashboard/businesses");
|
||||
router.push("/dashboard/entities?tab=businesses");
|
||||
} else {
|
||||
// Update business data (excluding email config fields)
|
||||
const businessData = {
|
||||
@@ -386,7 +386,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
}
|
||||
|
||||
toast.success("Business updated successfully");
|
||||
router.push("/dashboard/businesses");
|
||||
router.push("/dashboard/entities?tab=businesses");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -400,7 +400,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
router.push("/dashboard/businesses");
|
||||
router.push("/dashboard/entities?tab=businesses");
|
||||
};
|
||||
|
||||
if (
|
||||
|
||||
@@ -99,7 +99,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
const createClient = api.clients.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client created successfully");
|
||||
router.push("/dashboard/clients");
|
||||
router.push("/dashboard/entities?tab=clients");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to create client");
|
||||
@@ -109,7 +109,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
const updateClient = api.clients.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client updated successfully");
|
||||
router.push("/dashboard/clients");
|
||||
router.push("/dashboard/entities?tab=clients");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to update client");
|
||||
@@ -232,7 +232,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
router.push("/dashboard/clients");
|
||||
router.push("/dashboard/entities?tab=clients");
|
||||
};
|
||||
|
||||
if (mode === "edit" && isLoadingClient) {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { STATUS_OPTIONS } from "./invoice/types";
|
||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
||||
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
|
||||
|
||||
import { CountUp } from "~/components/ui/count-up";
|
||||
|
||||
@@ -135,6 +136,15 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("details");
|
||||
const [previewTab, setPreviewTab] = useState("pdf");
|
||||
const [previewPinned, setPreviewPinned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(min-width: 1024px)");
|
||||
const update = () => setPreviewPinned(media.matches);
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
// Queries (Same as before)
|
||||
const { data: clients, isLoading: loadingClients } =
|
||||
@@ -254,17 +264,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
[formData],
|
||||
);
|
||||
|
||||
const { data: pdfPreview, isFetching: pdfPreviewLoading } =
|
||||
api.invoices.previewPdf.useQuery(pdfPreviewInput, {
|
||||
enabled:
|
||||
activeTab === "preview" &&
|
||||
previewTab === "pdf" &&
|
||||
Boolean(formData.clientId) &&
|
||||
formData.items.length > 0 &&
|
||||
formData.items.every((item) => item.description.trim() !== ""),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
const selectedClient = React.useMemo(
|
||||
() => clients?.find((client) => client.id === formData.clientId),
|
||||
[clients, formData.clientId],
|
||||
@@ -480,9 +479,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
|
||||
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
{/* TAB SELECTOR: w-full, p-1, visible background */}
|
||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1">
|
||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
|
||||
<TabsTrigger
|
||||
value="details"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
@@ -503,7 +503,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="preview"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
|
||||
>
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
@@ -863,43 +863,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pdf" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2">
|
||||
<FileText className="h-5 w-5" /> PDF Preview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="bg-muted/20 h-[760px] overflow-hidden border-t">
|
||||
{!formData.clientId ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||
Select a client to generate the PDF preview.
|
||||
</div>
|
||||
) : formData.items.some(
|
||||
(item) => item.description.trim() === "",
|
||||
) ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||
Add descriptions for all line items to generate the
|
||||
PDF preview.
|
||||
</div>
|
||||
) : pdfPreviewLoading && !pdfPreview ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||
Generating server PDF preview...
|
||||
</div>
|
||||
) : pdfPreview ? (
|
||||
<iframe
|
||||
title="Server-generated PDF preview"
|
||||
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
|
||||
className="h-full w-full border-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||
PDF preview will appear here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="email" className="mt-6">
|
||||
@@ -954,6 +918,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-4 space-y-4">
|
||||
<InvoicePdfPreviewPanel
|
||||
input={pdfPreviewInput}
|
||||
enabled={previewPinned || activeTab === "preview"}
|
||||
/>
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<span className="text-muted-foreground text-sm">Invoice total</span>
|
||||
<span className="font-mono text-2xl font-bold">
|
||||
<CountUp value={totals.total} prefix="$" />
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
|
||||
@@ -8,7 +8,6 @@ import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
||||
@@ -156,7 +155,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group hover:bg-muted/40 hidden min-h-16 grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] items-center gap-2 border-b px-3 py-2 transition-colors md:grid",
|
||||
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
||||
)}
|
||||
>
|
||||
<DatePicker
|
||||
@@ -164,7 +163,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
inputClassName="h-9"
|
||||
inputClassName="h-8 text-xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
@@ -173,8 +172,8 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
onChange={(v) => onDescriptionChange(index, v)}
|
||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||
suggestions={suggestions}
|
||||
placeholder="Describe the work performed..."
|
||||
className="h-9 w-full text-sm font-medium"
|
||||
placeholder="Description"
|
||||
className="h-8 w-full text-sm"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
@@ -184,7 +183,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
min={0}
|
||||
step={0.25}
|
||||
width="full"
|
||||
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
|
||||
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
||||
suffix="h"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
@@ -196,11 +195,11 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="full"
|
||||
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
|
||||
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-12 [&_input]:text-xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<div className="text-primary text-right font-mono font-semibold">
|
||||
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
</div>
|
||||
|
||||
@@ -210,11 +209,11 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
className="text-muted-foreground hover:text-destructive h-7 w-7 p-0"
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
@@ -237,103 +236,72 @@ function MobileLineItem({
|
||||
readOnly,
|
||||
}: LineItemRowProps) {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
<div
|
||||
id={`invoice-item-${index}-mobile`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="border-border bg-card overflow-hidden rounded-lg border md:hidden"
|
||||
className="border-border space-y-1.5 border-b px-3 py-2 md:hidden"
|
||||
>
|
||||
<div className="space-y-3 p-4">
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Description</Label>
|
||||
<DescriptionAutocomplete
|
||||
value={item.description}
|
||||
onChange={(v) => onDescriptionChange(index, v)}
|
||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||
suggestions={suggestions}
|
||||
placeholder="Describe the work performed..."
|
||||
className="pl-3 text-sm"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-5 shrink-0 text-center text-xs font-semibold">
|
||||
{index + 1}
|
||||
</span>
|
||||
<DescriptionAutocomplete
|
||||
value={item.description}
|
||||
onChange={(v) => onDescriptionChange(index, v)}
|
||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||
suggestions={suggestions}
|
||||
placeholder="Description"
|
||||
className="h-8 flex-1 text-sm"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Date</Label>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
<div className="flex items-center gap-1.5 pl-7">
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
size="sm"
|
||||
className="w-[92px] shrink-0"
|
||||
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"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => onUpdate(index, "rate", value)}
|
||||
min={0}
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="full"
|
||||
className="h-8 w-[84px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
</span>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
inputClassName="h-9"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours and Rate in a row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => onUpdate(index, "hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
width="full"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Rate</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => onUpdate(index, "rate", value)}
|
||||
min={0}
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="full"
|
||||
className="font-mono"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
onClick={() => onRemove(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bottom section with controls, item name, and total */}
|
||||
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 px-3 text-center">
|
||||
<span className="text-muted-foreground block text-sm font-medium">
|
||||
<span className="hidden sm:inline">Item </span>
|
||||
<span className="sm:hidden">#</span>
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs">Total</span>
|
||||
<span className="text-primary text-lg font-bold">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -405,12 +373,12 @@ export function InvoiceLineItems({
|
||||
</p>
|
||||
) : null}
|
||||
<AnimatePresence>
|
||||
<div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid">
|
||||
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[108px_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">
|
||||
<span>Date</span>
|
||||
<span>Description</span>
|
||||
<span className="text-right">Hours</span>
|
||||
<span className="text-right">Rate</span>
|
||||
<span className="text-center">Hours</span>
|
||||
<span className="text-center">Rate</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span />
|
||||
</div>
|
||||
@@ -483,7 +451,7 @@ export function InvoiceLineItems({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onAddItem}
|
||||
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-3 w-full border-dashed py-6 transition-all"
|
||||
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-2 w-full border-dashed py-3 transition-all"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Line Item
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type InvoicePdfPreviewInput = {
|
||||
invoiceNumber: string;
|
||||
invoicePrefix: string;
|
||||
businessId: string;
|
||||
clientId: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: "draft" | "sent" | "paid";
|
||||
notes: string;
|
||||
emailMessage: string;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
items: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
function canPreview(input: InvoicePdfPreviewInput | null): input is InvoicePdfPreviewInput {
|
||||
if (!input?.clientId) return false;
|
||||
if (input.items.length === 0) return false;
|
||||
return input.items.every((item) => item.description.trim().length > 0);
|
||||
}
|
||||
|
||||
type InvoicePdfPreviewPanelProps = {
|
||||
input: InvoicePdfPreviewInput | null;
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
heightClassName?: string;
|
||||
};
|
||||
|
||||
export function InvoicePdfPreviewPanel({
|
||||
input,
|
||||
enabled = true,
|
||||
className,
|
||||
heightClassName = "h-[min(80vh,760px)]",
|
||||
}: InvoicePdfPreviewPanelProps) {
|
||||
const previewReady = canPreview(input);
|
||||
|
||||
const { data: pdfPreview, isFetching, error, refetch } =
|
||||
api.invoices.previewPdf.useQuery(input!, {
|
||||
enabled: enabled && previewReady,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF preview
|
||||
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/20 overflow-hidden border-t",
|
||||
heightClassName,
|
||||
)}
|
||||
>
|
||||
{!previewReady ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||
Select a client and add descriptions for all line items to generate the
|
||||
PDF preview.
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<p className="text-destructive text-sm">{error.message}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : isFetching && !pdfPreview ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Generating preview…
|
||||
</div>
|
||||
) : pdfPreview ? (
|
||||
<iframe
|
||||
title="Invoice PDF preview"
|
||||
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
|
||||
className="h-full w-full border-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||
PDF preview will appear here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { authClient } from "~/lib/auth-client";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { navigationConfig } from "~/lib/navigation";
|
||||
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
||||
import { useSidebar } from "./sidebar-provider";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
@@ -83,7 +83,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||
<div className="flex flex-col gap-1">
|
||||
{section.links.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const isActive = pathname === link.href;
|
||||
const isActive = isNavLinkActive(pathname, link.href);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||
import { navigationConfig } from "~/lib/navigation";
|
||||
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
||||
|
||||
interface SidebarTriggerProps {
|
||||
isOpen: boolean;
|
||||
@@ -68,10 +68,10 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
aria-current={
|
||||
pathname === link.href ? "page" : undefined
|
||||
isNavLinkActive(pathname, link.href) ? "page" : undefined
|
||||
}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
pathname === link.href
|
||||
isNavLinkActive(pathname, link.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted"
|
||||
}`}
|
||||
|
||||
@@ -15,9 +15,29 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Clock, Play, Square, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import { ChevronDown, Clock, ExternalLink, Play, Square } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
getLastTimeClockClientId,
|
||||
setLastTimeClockClientId,
|
||||
} from "~/lib/time-clock-prefs";
|
||||
import {
|
||||
describeClockOutOutcome,
|
||||
formatElapsedSeconds,
|
||||
resolveClockDescription,
|
||||
resolveEffectiveHourlyRate,
|
||||
startedAtFromMinutesAgo,
|
||||
} from "~/lib/time-clock";
|
||||
|
||||
const FEATURED_CLIENT_COUNT = 4;
|
||||
|
||||
type StartMode = "now" | "pick" | "ago";
|
||||
|
||||
export type TimeClockPanelProps = {
|
||||
defaultClientId?: string;
|
||||
@@ -25,6 +45,38 @@ export type TimeClockPanelProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function invoiceLabel(inv: {
|
||||
invoicePrefix: string | null;
|
||||
invoiceNumber: string;
|
||||
}) {
|
||||
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
||||
}
|
||||
|
||||
function ClientChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeClockPanel({
|
||||
defaultClientId = "",
|
||||
defaultInvoiceId = "",
|
||||
@@ -37,19 +89,6 @@ export function TimeClockPanel({
|
||||
);
|
||||
const { data: clients } = api.clients.getAll.useQuery();
|
||||
|
||||
const [clientId, setClientId] = useState(defaultClientId);
|
||||
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
||||
const [description, setDescription] = useState("");
|
||||
const [rate, setRate] = useState(0);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
||||
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
|
||||
draftClientId ? { clientId: draftClientId } : undefined,
|
||||
{ enabled: Boolean(draftClientId) },
|
||||
);
|
||||
|
||||
const todayStart = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
@@ -60,6 +99,63 @@ export function TimeClockPanel({
|
||||
from: todayStart,
|
||||
});
|
||||
|
||||
const [clientId, setClientId] = useState(() => {
|
||||
if (defaultClientId) return defaultClientId;
|
||||
return getLastTimeClockClientId() ?? "";
|
||||
});
|
||||
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
||||
const [title, setTitle] = useState("");
|
||||
const [stopNote, setStopNote] = useState("");
|
||||
const [rate, setRate] = useState(0);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [showAllClients, setShowAllClients] = useState(false);
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||
const [pickedStart, setPickedStart] = useState("");
|
||||
const [minutesAgo, setMinutesAgo] = useState("30");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
||||
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
|
||||
draftClientId ? { clientId: draftClientId } : undefined,
|
||||
{ enabled: Boolean(draftClientId) },
|
||||
);
|
||||
|
||||
const selectedClient = useMemo(
|
||||
() => clients?.find((c) => c.id === clientId),
|
||||
[clients, clientId],
|
||||
);
|
||||
|
||||
const featuredClientIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
const last = getLastTimeClockClientId();
|
||||
if (last) ids.push(last);
|
||||
|
||||
for (const entry of todayEntries ?? []) {
|
||||
if (entry.clientId && !ids.includes(entry.clientId)) {
|
||||
ids.push(entry.clientId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const client of clients ?? []) {
|
||||
if (!ids.includes(client.id)) ids.push(client.id);
|
||||
if (ids.length >= FEATURED_CLIENT_COUNT) break;
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [clients, todayEntries]);
|
||||
|
||||
const visibleClients = useMemo(() => {
|
||||
if (!clients?.length) return [];
|
||||
if (showAllClients) return clients;
|
||||
const featured = featuredClientIds
|
||||
.map((id) => clients.find((c) => c.id === id))
|
||||
.filter((c): c is NonNullable<typeof c> => Boolean(c));
|
||||
return featured.length > 0 ? featured : clients.slice(0, FEATURED_CLIENT_COUNT);
|
||||
}, [clients, featuredClientIds, showAllClients]);
|
||||
|
||||
const hiddenClientCount = Math.max(0, (clients?.length ?? 0) - visibleClients.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (!running) return;
|
||||
@@ -110,7 +206,8 @@ export function TimeClockPanel({
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.invoices.getBillable.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
setDescription("");
|
||||
setTitle("");
|
||||
setStopNote("");
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
@@ -118,10 +215,60 @@ export function TimeClockPanel({
|
||||
function handleClientChange(value: string) {
|
||||
setClientId(value);
|
||||
setInvoiceId("");
|
||||
setLastTimeClockClientId(value);
|
||||
const client = clients?.find((c) => c.id === value);
|
||||
setRate(client?.defaultHourlyRate ?? 0);
|
||||
}
|
||||
|
||||
function resolveStartedAt(): Date | undefined {
|
||||
if (startMode === "now") return undefined;
|
||||
if (startMode === "pick") {
|
||||
if (!pickedStart) {
|
||||
toast.error("Choose a start date and time");
|
||||
return undefined;
|
||||
}
|
||||
const parsed = new Date(pickedStart);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
toast.error("Invalid start time");
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
const minutes = Number(minutesAgo);
|
||||
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 24 * 60) {
|
||||
toast.error("Enter minutes between 1 and 1440");
|
||||
return undefined;
|
||||
}
|
||||
return startedAtFromMinutesAgo(minutes);
|
||||
}
|
||||
|
||||
function selectStartMode(mode: StartMode) {
|
||||
setStartMode(mode);
|
||||
if (mode === "pick" && !pickedStart) {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
setPickedStart(now.toISOString().slice(0, 16));
|
||||
}
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
const startedAt = resolveStartedAt();
|
||||
if (startMode !== "now" && !startedAt) return;
|
||||
|
||||
const description = resolveClockDescription(title);
|
||||
const effectiveRate = resolveEffectiveHourlyRate(rate, selectedClient);
|
||||
|
||||
if (clientId) setLastTimeClockClientId(clientId);
|
||||
|
||||
clockIn.mutate({
|
||||
description,
|
||||
clientId: clientId || "",
|
||||
invoiceId: invoiceId || undefined,
|
||||
rate: effectiveRate > 0 ? effectiveRate : undefined,
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (runningLoading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -130,61 +277,82 @@ export function TimeClockPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const invoiceLabel = (inv: {
|
||||
invoicePrefix: string | null;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
}) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
||||
|
||||
const displayDescription = running ? running.description : description;
|
||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||
const runningTitle =
|
||||
running?.description?.trim() ?? resolveClockDescription("");
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||
<Card className={running ? "border-primary/30 bg-primary/5" : undefined}>
|
||||
<CardHeader>
|
||||
{running ? (
|
||||
<div className="border-primary/20 bg-primary/5 rounded-2xl border p-6 text-center shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
|
||||
</span>
|
||||
<span className="text-primary text-sm font-medium">Timer running</span>
|
||||
</div>
|
||||
<p className="text-primary font-mono text-5xl font-bold tracking-tight tabular-nums sm:text-6xl">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-medium">{runningTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{running.client?.name ?? "No client"}
|
||||
{running.invoice ? ` · ${invoiceLabel(running.invoice)}` : ""}
|
||||
{displayRate ? ` · $${displayRate}/hr` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{running ? (
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex h-3 w-3 rounded-full" />
|
||||
</span>
|
||||
) : (
|
||||
<Clock className="h-4 w-4" />
|
||||
)}
|
||||
{running ? "Timer running" : "Time clock"}
|
||||
{!running ? <Clock className="h-4 w-4" /> : null}
|
||||
{running ? "Update & stop" : "Clock in"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{running ? (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium">
|
||||
{displayDescription || (
|
||||
<span className="text-muted-foreground italic">No description</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{running.client?.name ?? "No client"}
|
||||
{running.invoice
|
||||
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||
: ""}
|
||||
{displayRate ? ` · $${displayRate}/hr` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-primary font-mono text-4xl font-bold tabular-nums">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{!running ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Client</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-title" className="sr-only">
|
||||
What are you working on?
|
||||
</Label>
|
||||
<Input
|
||||
id="clock-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="What are you working on?"
|
||||
className="h-12 border-0 bg-transparent px-0 text-lg font-medium shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</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={clientId === 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={clientId || undefined} onValueChange={handleClientChange}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -195,66 +363,122 @@ export function TimeClockPanel({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={invoiceId || "__none__"}
|
||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
||||
disabled={!clientId}
|
||||
<div className="space-y-2">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={invoiceId || "__none__"}
|
||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
clientId ? "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>
|
||||
|
||||
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-auto w-full justify-between px-0 py-1 font-normal hover:bg-transparent"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
clientId ? "Select 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>
|
||||
Rate & start time
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 transition-transform",
|
||||
optionsOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Hourly rate</Label>
|
||||
<NumberInput
|
||||
value={rate}
|
||||
onChange={setRate}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Client default: ${selectedClient.defaultHourlyRate}/hr (used when left at zero).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>When to start</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
["now", "Now"],
|
||||
["pick", "Pick time"],
|
||||
["ago", "Time ago"],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={startMode === mode ? "default" : "outline"}
|
||||
className="rounded-full"
|
||||
onClick={() => selectStartMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Description</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What are you working on?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Hourly rate</Label>
|
||||
<NumberInput
|
||||
value={rate}
|
||||
onChange={setRate}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{clientId && rate === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Set a rate or add a default on the client record.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{startMode === "pick" ? (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={pickedStart}
|
||||
onChange={(e) => setPickedStart(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : null}
|
||||
{startMode === "ago" ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={minutesAgo}
|
||||
onChange={(e) => setMinutesAgo(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">minutes ago</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Update description on stop (optional)</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={running.description || "What did you work on?"}
|
||||
id="clock-stop-note"
|
||||
value={stopNote}
|
||||
onChange={(e) => setStopNote(e.target.value)}
|
||||
placeholder={running?.description || "Update description when you stop"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -262,8 +486,13 @@ export function TimeClockPanel({
|
||||
{running ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => clockOut.mutate({ description: description || undefined })}
|
||||
onClick={() =>
|
||||
clockOut.mutate({
|
||||
description: stopNote.trim() || undefined,
|
||||
})
|
||||
}
|
||||
disabled={clockOut.isPending}
|
||||
>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
@@ -271,15 +500,9 @@ export function TimeClockPanel({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
clockIn.mutate({
|
||||
description,
|
||||
clientId: clientId || "",
|
||||
invoiceId: invoiceId || undefined,
|
||||
rate: rate || undefined,
|
||||
})
|
||||
}
|
||||
onClick={handleStart}
|
||||
disabled={clockIn.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
|
||||
+12
-3
@@ -3,7 +3,6 @@ import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
FileText,
|
||||
Building,
|
||||
Receipt,
|
||||
BarChart2,
|
||||
Shield,
|
||||
@@ -22,14 +21,24 @@ export interface NavSection {
|
||||
links: NavLink[];
|
||||
}
|
||||
|
||||
export function isNavLinkActive(pathname: string, href: string): boolean {
|
||||
if (href === "/dashboard/entities") {
|
||||
return (
|
||||
pathname === href ||
|
||||
pathname.startsWith("/dashboard/clients") ||
|
||||
pathname.startsWith("/dashboard/businesses")
|
||||
);
|
||||
}
|
||||
return pathname === href;
|
||||
}
|
||||
|
||||
export const navigationConfig: NavSection[] = [
|
||||
{
|
||||
title: "Main",
|
||||
links: [
|
||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
||||
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
||||
{ name: "Entities", href: "/dashboard/entities", icon: Users },
|
||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
||||
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const STORAGE_KEY = "beenvoice:time-clock:last-client";
|
||||
|
||||
export function getLastTimeClockClientId(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function setLastTimeClockClientId(clientId: string): void {
|
||||
if (!clientId || typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEY, clientId);
|
||||
}
|
||||
@@ -1,3 +1,29 @@
|
||||
export const DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||
|
||||
export function resolveEffectiveHourlyRate(
|
||||
enteredRate: number,
|
||||
client?: { defaultHourlyRate?: number | null } | null,
|
||||
): number {
|
||||
if (Number.isFinite(enteredRate) && enteredRate > 0) return enteredRate;
|
||||
const clientRate = client?.defaultHourlyRate ?? 0;
|
||||
if (Number.isFinite(clientRate) && clientRate > 0) return clientRate;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function startedAtFromMinutesAgo(minutes: number): Date {
|
||||
return new Date(Date.now() - minutes * 60 * 1000);
|
||||
}
|
||||
|
||||
export function resolveClockDescription(
|
||||
title: string,
|
||||
existingDescription?: string | null,
|
||||
): string {
|
||||
const trimmed = title.trim();
|
||||
if (trimmed) return trimmed;
|
||||
if (existingDescription?.trim()) return existingDescription.trim();
|
||||
return DEFAULT_CLOCK_DESCRIPTION;
|
||||
}
|
||||
|
||||
export type ClockOutOutcome =
|
||||
| "linked_to_invoice"
|
||||
| "saved_no_invoice"
|
||||
|
||||
Reference in New Issue
Block a user