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"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<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" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<span>Back to Businesses</span>
|
<span>Back to Businesses</span>
|
||||||
</Link>
|
</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 { redirect } from "next/navigation";
|
||||||
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";
|
|
||||||
|
|
||||||
// Businesses Table Component
|
export default function BusinessesPage() {
|
||||||
async function BusinessesTable() {
|
redirect("/dashboard/entities?tab=businesses");
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default async function ClientDetailPage({
|
|||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<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" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<span>Back to Clients</span>
|
<span>Back to Clients</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,29 +1,5 @@
|
|||||||
import { Plus } from "lucide-react";
|
import { redirect } from "next/navigation";
|
||||||
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";
|
|
||||||
|
|
||||||
export default async function ClientsPage() {
|
export default function ClientsPage() {
|
||||||
return (
|
redirect("/dashboard/entities?tab=clients");
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="page-enter mx-auto max-w-3xl space-y-6">
|
<div className="page-enter space-y-6">
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
title="Time clock"
|
title="Time clock"
|
||||||
description="Track billable hours and save them directly to an invoice"
|
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");
|
toast.success("Business created successfully");
|
||||||
router.push("/dashboard/businesses");
|
router.push("/dashboard/entities?tab=businesses");
|
||||||
} else {
|
} else {
|
||||||
// Update business data (excluding email config fields)
|
// Update business data (excluding email config fields)
|
||||||
const businessData = {
|
const businessData = {
|
||||||
@@ -386,7 +386,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Business updated successfully");
|
toast.success("Business updated successfully");
|
||||||
router.push("/dashboard/businesses");
|
router.push("/dashboard/entities?tab=businesses");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -400,7 +400,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
router.push("/dashboard/businesses");
|
router.push("/dashboard/entities?tab=businesses");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
const createClient = api.clients.create.useMutation({
|
const createClient = api.clients.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Client created successfully");
|
toast.success("Client created successfully");
|
||||||
router.push("/dashboard/clients");
|
router.push("/dashboard/entities?tab=clients");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || "Failed to create client");
|
toast.error(error.message || "Failed to create client");
|
||||||
@@ -109,7 +109,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
const updateClient = api.clients.update.useMutation({
|
const updateClient = api.clients.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Client updated successfully");
|
toast.success("Client updated successfully");
|
||||||
router.push("/dashboard/clients");
|
router.push("/dashboard/entities?tab=clients");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || "Failed to update client");
|
toast.error(error.message || "Failed to update client");
|
||||||
@@ -232,7 +232,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
router.push("/dashboard/clients");
|
router.push("/dashboard/entities?tab=clients");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === "edit" && isLoadingClient) {
|
if (mode === "edit" && isLoadingClient) {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
import { STATUS_OPTIONS } from "./invoice/types";
|
import { STATUS_OPTIONS } from "./invoice/types";
|
||||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||||
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
||||||
|
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
|
||||||
|
|
||||||
import { CountUp } from "~/components/ui/count-up";
|
import { CountUp } from "~/components/ui/count-up";
|
||||||
|
|
||||||
@@ -135,6 +136,15 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("details");
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
const [previewTab, setPreviewTab] = useState("pdf");
|
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)
|
// Queries (Same as before)
|
||||||
const { data: clients, isLoading: loadingClients } =
|
const { data: clients, isLoading: loadingClients } =
|
||||||
@@ -254,17 +264,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
[formData],
|
[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(
|
const selectedClient = React.useMemo(
|
||||||
() => clients?.find((client) => client.id === formData.clientId),
|
() => clients?.find((client) => client.id === formData.clientId),
|
||||||
[clients, formData.clientId],
|
[clients, formData.clientId],
|
||||||
@@ -480,9 +479,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</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 */}
|
{/* 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
|
<TabsTrigger
|
||||||
value="details"
|
value="details"
|
||||||
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"
|
||||||
@@ -503,7 +503,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="preview"
|
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
|
Preview
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -863,43 +863,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="pdf" className="mt-6">
|
<TabsContent value="pdf" className="mt-6">
|
||||||
<Card>
|
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
|
||||||
<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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="email" className="mt-6">
|
<TabsContent value="email" className="mt-6">
|
||||||
@@ -954,6 +918,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Link from "next/link";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
||||||
@@ -156,7 +155,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
<DatePicker
|
||||||
@@ -164,7 +163,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
inputClassName="h-9"
|
inputClassName="h-8 text-xs"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -173,8 +172,8 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
onChange={(v) => onDescriptionChange(index, v)}
|
onChange={(v) => onDescriptionChange(index, v)}
|
||||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Description"
|
||||||
className="h-9 w-full text-sm font-medium"
|
className="h-8 w-full text-sm"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -184,7 +183,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
min={0}
|
min={0}
|
||||||
step={0.25}
|
step={0.25}
|
||||||
width="full"
|
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"
|
suffix="h"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
@@ -196,11 +195,11 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
step={1}
|
step={1}
|
||||||
prefix="$"
|
prefix="$"
|
||||||
width="full"
|
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}
|
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)}
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -210,11 +209,11 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onRemove(index)}
|
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}
|
disabled={!canRemove}
|
||||||
aria-label="Remove item"
|
aria-label="Remove item"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
@@ -237,103 +236,72 @@ function MobileLineItem({
|
|||||||
readOnly,
|
readOnly,
|
||||||
}: LineItemRowProps) {
|
}: LineItemRowProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div
|
||||||
layout
|
|
||||||
id={`invoice-item-${index}-mobile`}
|
id={`invoice-item-${index}-mobile`}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
className="border-border space-y-1.5 border-b px-3 py-2 md:hidden"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-3 p-4">
|
<div className="flex items-center gap-2">
|
||||||
{/* Description */}
|
<span className="text-muted-foreground w-5 shrink-0 text-center text-xs font-semibold">
|
||||||
<div className="space-y-1">
|
{index + 1}
|
||||||
<Label className="text-muted-foreground text-xs">Description</Label>
|
</span>
|
||||||
<DescriptionAutocomplete
|
<DescriptionAutocomplete
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(v) => onDescriptionChange(index, v)}
|
onChange={(v) => onDescriptionChange(index, v)}
|
||||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Description"
|
||||||
className="pl-3 text-sm"
|
className="h-8 flex-1 text-sm"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date */}
|
<div className="flex items-center gap-1.5 pl-7">
|
||||||
<div className="space-y-1">
|
<DatePicker
|
||||||
<Label className="text-muted-foreground text-xs">Date</Label>
|
date={item.date}
|
||||||
<DatePicker
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||||
date={item.date}
|
size="sm"
|
||||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
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"
|
size="sm"
|
||||||
inputClassName="h-9"
|
onClick={() => onRemove(index)}
|
||||||
disabled={readOnly}
|
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
||||||
/>
|
disabled={!canRemove}
|
||||||
</div>
|
aria-label="Remove item"
|
||||||
|
>
|
||||||
{/* Hours and Rate in a row */}
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<div className="grid grid-cols-2 gap-3">
|
</Button>
|
||||||
<div className="space-y-1">
|
) : null}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,12 +373,12 @@ export function InvoiceLineItems({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid">
|
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[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>Date</span>
|
||||||
<span>Description</span>
|
<span>Description</span>
|
||||||
<span className="text-right">Hours</span>
|
<span className="text-center">Hours</span>
|
||||||
<span className="text-right">Rate</span>
|
<span className="text-center">Rate</span>
|
||||||
<span className="text-right">Amount</span>
|
<span className="text-right">Amount</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
@@ -483,7 +451,7 @@ export function InvoiceLineItems({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onAddItem}
|
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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Line Item
|
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 { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import { navigationConfig } from "~/lib/navigation";
|
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
||||||
import { useSidebar } from "./sidebar-provider";
|
import { useSidebar } from "./sidebar-provider";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
@@ -83,7 +83,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{section.links.map((link) => {
|
{section.links.map((link) => {
|
||||||
const Icon = link.icon;
|
const Icon = link.icon;
|
||||||
const isActive = pathname === link.href;
|
const isActive = isNavLinkActive(pathname, link.href);
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||||
import { navigationConfig } from "~/lib/navigation";
|
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
||||||
|
|
||||||
interface SidebarTriggerProps {
|
interface SidebarTriggerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -68,10 +68,10 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
|||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={
|
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 ${
|
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"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-foreground hover:bg-muted"
|
: "text-foreground hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -15,9 +15,29 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} 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 { 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 = {
|
export type TimeClockPanelProps = {
|
||||||
defaultClientId?: string;
|
defaultClientId?: string;
|
||||||
@@ -25,6 +45,38 @@ export type TimeClockPanelProps = {
|
|||||||
compact?: boolean;
|
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({
|
export function TimeClockPanel({
|
||||||
defaultClientId = "",
|
defaultClientId = "",
|
||||||
defaultInvoiceId = "",
|
defaultInvoiceId = "",
|
||||||
@@ -37,19 +89,6 @@ export function TimeClockPanel({
|
|||||||
);
|
);
|
||||||
const { data: clients } = api.clients.getAll.useQuery();
|
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 todayStart = useMemo(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(0, 0, 0, 0);
|
d.setHours(0, 0, 0, 0);
|
||||||
@@ -60,6 +99,63 @@ export function TimeClockPanel({
|
|||||||
from: todayStart,
|
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(() => {
|
useEffect(() => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
@@ -110,7 +206,8 @@ export function TimeClockPanel({
|
|||||||
void utils.invoices.getAll.invalidate();
|
void utils.invoices.getAll.invalidate();
|
||||||
void utils.invoices.getBillable.invalidate();
|
void utils.invoices.getBillable.invalidate();
|
||||||
void utils.dashboard.getStats.invalidate();
|
void utils.dashboard.getStats.invalidate();
|
||||||
setDescription("");
|
setTitle("");
|
||||||
|
setStopNote("");
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -118,10 +215,60 @@ export function TimeClockPanel({
|
|||||||
function handleClientChange(value: string) {
|
function handleClientChange(value: string) {
|
||||||
setClientId(value);
|
setClientId(value);
|
||||||
setInvoiceId("");
|
setInvoiceId("");
|
||||||
|
setLastTimeClockClientId(value);
|
||||||
const client = clients?.find((c) => c.id === value);
|
const client = clients?.find((c) => c.id === value);
|
||||||
setRate(client?.defaultHourlyRate ?? 0);
|
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) {
|
if (runningLoading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<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 displayRate = running ? (running.rate ?? 0) : rate;
|
||||||
|
const runningTitle =
|
||||||
|
running?.description?.trim() ?? resolveClockDescription("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||||
<Card className={running ? "border-primary/30 bg-primary/5" : undefined}>
|
{running ? (
|
||||||
<CardHeader>
|
<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">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
{running ? (
|
{!running ? <Clock className="h-4 w-4" /> : null}
|
||||||
<span className="relative flex h-3 w-3">
|
{running ? "Update & stop" : "Clock in"}
|
||||||
<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"}
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-5">
|
||||||
{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}
|
|
||||||
|
|
||||||
{!running ? (
|
{!running ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1.5">
|
<Label htmlFor="clock-title" className="sr-only">
|
||||||
<Label>Client</Label>
|
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}>
|
<Select value={clientId || undefined} onValueChange={handleClientChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="mt-1">
|
||||||
<SelectValue placeholder="Select client" />
|
<SelectValue placeholder="Select client" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -195,66 +363,122 @@ export function TimeClockPanel({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label>Invoice</Label>
|
<Label>Invoice</Label>
|
||||||
<Select
|
<Select
|
||||||
value={invoiceId || "__none__"}
|
value={invoiceId || "__none__"}
|
||||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
||||||
disabled={!clientId}
|
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>
|
Rate & start time
|
||||||
<SelectValue
|
<ChevronDown
|
||||||
placeholder={
|
className={cn(
|
||||||
clientId ? "Select invoice (optional)" : "Choose a client first"
|
"h-4 w-4 shrink-0 transition-transform",
|
||||||
}
|
optionsOpen && "rotate-180",
|
||||||
/>
|
)}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
</Button>
|
||||||
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
</CollapsibleTrigger>
|
||||||
{billableInvoices?.map((inv) => (
|
<CollapsibleContent className="space-y-4 pt-2">
|
||||||
<SelectItem key={inv.id} value={inv.id}>
|
<div className="space-y-2">
|
||||||
{invoiceLabel(inv)}
|
<Label>Hourly rate</Label>
|
||||||
</SelectItem>
|
<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>
|
</div>
|
||||||
</Select>
|
{startMode === "pick" ? (
|
||||||
</div>
|
<Input
|
||||||
</div>
|
type="datetime-local"
|
||||||
|
value={pickedStart}
|
||||||
<div className="space-y-1.5">
|
onChange={(e) => setPickedStart(e.target.value)}
|
||||||
<Label>Description</Label>
|
className="mt-2"
|
||||||
<Input
|
/>
|
||||||
value={description}
|
) : null}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
{startMode === "ago" ? (
|
||||||
placeholder="What are you working on?"
|
<div className="mt-2 flex items-center gap-2">
|
||||||
/>
|
<Input
|
||||||
</div>
|
type="number"
|
||||||
|
min={1}
|
||||||
<div className="space-y-1.5">
|
max={1440}
|
||||||
<Label>Hourly rate</Label>
|
value={minutesAgo}
|
||||||
<NumberInput
|
onChange={(e) => setMinutesAgo(e.target.value)}
|
||||||
value={rate}
|
className="w-24"
|
||||||
onChange={setRate}
|
/>
|
||||||
min={0}
|
<span className="text-muted-foreground text-sm">minutes ago</span>
|
||||||
step={0.01}
|
</div>
|
||||||
placeholder="0.00"
|
) : null}
|
||||||
/>
|
</div>
|
||||||
{clientId && rate === 0 ? (
|
</CollapsibleContent>
|
||||||
<p className="text-muted-foreground text-xs">
|
</Collapsible>
|
||||||
Set a rate or add a default on the client record.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label>Update description on stop (optional)</Label>
|
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
value={description}
|
id="clock-stop-note"
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
value={stopNote}
|
||||||
placeholder={running.description || "What did you work on?"}
|
onChange={(e) => setStopNote(e.target.value)}
|
||||||
|
placeholder={running?.description || "Update description when you stop"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -262,8 +486,13 @@ export function TimeClockPanel({
|
|||||||
{running ? (
|
{running ? (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => clockOut.mutate({ description: description || undefined })}
|
onClick={() =>
|
||||||
|
clockOut.mutate({
|
||||||
|
description: stopNote.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={clockOut.isPending}
|
disabled={clockOut.isPending}
|
||||||
>
|
>
|
||||||
<Square className="mr-2 h-4 w-4" />
|
<Square className="mr-2 h-4 w-4" />
|
||||||
@@ -271,15 +500,9 @@ export function TimeClockPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() =>
|
onClick={handleStart}
|
||||||
clockIn.mutate({
|
|
||||||
description,
|
|
||||||
clientId: clientId || "",
|
|
||||||
invoiceId: invoiceId || undefined,
|
|
||||||
rate: rate || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={clockIn.isPending}
|
disabled={clockIn.isPending}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
+12
-3
@@ -3,7 +3,6 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Building,
|
|
||||||
Receipt,
|
Receipt,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -22,14 +21,24 @@ export interface NavSection {
|
|||||||
links: NavLink[];
|
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[] = [
|
export const navigationConfig: NavSection[] = [
|
||||||
{
|
{
|
||||||
title: "Main",
|
title: "Main",
|
||||||
links: [
|
links: [
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||||
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
||||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
{ name: "Entities", href: "/dashboard/entities", icon: Users },
|
||||||
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
|
||||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||||
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
||||||
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
{ 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 =
|
export type ClockOutOutcome =
|
||||||
| "linked_to_invoice"
|
| "linked_to_invoice"
|
||||||
| "saved_no_invoice"
|
| "saved_no_invoice"
|
||||||
|
|||||||
Reference in New Issue
Block a user