Polish onboarding, invoices, and time clock while promoting the first registrant to admin.
Refresh onboarding wizard and shell, tighten invoice edit/detail flows, align timer widgets with the redesigned clock panel, and assign admin role on first signup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "~/lib/auth";
|
||||
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
||||
import { resolveNewUserRole } from "~/lib/first-admin";
|
||||
import { env } from "~/env";
|
||||
import { db } from "~/server/db";
|
||||
import { accounts, users } from "~/server/db/schema";
|
||||
@@ -119,12 +120,15 @@ export async function POST(request: NextRequest) {
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const role = await resolveNewUserRole(tx);
|
||||
|
||||
const [user] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
name: `${firstName} ${lastName}`,
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
role,
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Square, Clock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
||||
import {
|
||||
describeClockOutOutcome,
|
||||
formatElapsedSeconds,
|
||||
formatRunningTimerLabel,
|
||||
} from "~/lib/time-clock";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -86,10 +90,7 @@ export function ActiveTimerWidget({
|
||||
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||
: null;
|
||||
|
||||
const description =
|
||||
running.description || (
|
||||
<span className="text-muted-foreground italic">No description</span>
|
||||
);
|
||||
const description = formatRunningTimerLabel(running.description);
|
||||
|
||||
const renderStopButton = (className?: string) => (
|
||||
<Button
|
||||
@@ -106,20 +107,20 @@ export function ActiveTimerWidget({
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="ml-auto flex flex-col items-center gap-1">
|
||||
<div className="ml-auto flex min-w-0 items-center gap-1.5">
|
||||
<Link
|
||||
href="/dashboard/time-clock"
|
||||
className="border-primary/30 bg-primary/5 flex items-center gap-2 rounded-md border px-2.5 py-1.5"
|
||||
className="border-primary/30 bg-primary/5 flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1"
|
||||
>
|
||||
<span className="relative flex h-2 w-2 flex-shrink-0">
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
<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 w-2 rounded-full" />
|
||||
</span>
|
||||
<span className="text-primary font-mono text-sm font-bold tabular-nums">
|
||||
<span className="text-primary truncate font-mono text-sm font-bold tabular-nums">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</span>
|
||||
</Link>
|
||||
{renderStopButton()}
|
||||
{renderStopButton("shrink-0")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,7 +142,10 @@ export function ActiveTimerWidget({
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-56 space-y-2 p-3">
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="bg-popover text-popover-foreground border-border max-w-56 space-y-2 border p-3 text-sm [&>svg]:bg-popover [&>svg]:fill-popover"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{description}
|
||||
{running.client && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||
import { Building, Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||
import { Building, Pencil, Trash2, ExternalLink, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -208,6 +208,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
data={searchableBusinesses}
|
||||
searchKey="searchValue"
|
||||
searchPlaceholder="Search by name or nickname..."
|
||||
emptyTitle="Create your first business"
|
||||
emptyDescription="Set up a business profile for invoices, branding, and tax details."
|
||||
emptyIcon={<Building className="h-6 w-6" />}
|
||||
emptyAction={
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/businesses/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add business
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||
import { UserPlus, Pencil, Trash2 } from "lucide-react";
|
||||
import { UserPlus, Pencil, Trash2, Plus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -179,6 +179,17 @@ export function ClientsDataTable({
|
||||
data={clients}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search clients..."
|
||||
emptyTitle="Create your first client"
|
||||
emptyDescription="Add clients to bill them and keep contact details in one place."
|
||||
emptyIcon={<Users className="h-6 w-6" />}
|
||||
emptyAction={
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add client
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -214,12 +215,17 @@ export default function ExpensesPage() {
|
||||
Loading…
|
||||
</div>
|
||||
) : expenses.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No expenses yet. Add your first expense.
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<Receipt className="h-6 w-6" />}
|
||||
title="Create your first expense"
|
||||
description="Track billable costs, reimbursements, and tax-deductible spending."
|
||||
action={
|
||||
<Button onClick={handleOpen}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add expense
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{expenses.map((expense) => (
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
interface InvoiceTimerCardProps {
|
||||
invoiceId: string;
|
||||
@@ -12,18 +9,10 @@ interface InvoiceTimerCardProps {
|
||||
|
||||
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TimeClockPanel
|
||||
compact
|
||||
defaultClientId={clientId}
|
||||
defaultInvoiceId={invoiceId}
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="w-full" asChild>
|
||||
<Link href={`/dashboard/time-clock?clientId=${clientId}&invoiceId=${invoiceId}`}>
|
||||
Open full time clock
|
||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<TimeClockPanel
|
||||
compact
|
||||
defaultClientId={clientId}
|
||||
defaultInvoiceId={invoiceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
import InvoiceForm from "~/components/forms/invoice-form";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
export default function InvoiceFormPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
interface EditInvoicePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const invoice = await api.invoices.getById({ id });
|
||||
if (invoice.status !== "draft") {
|
||||
redirect(`/dashboard/invoices/${id}?editBlocked=1`);
|
||||
}
|
||||
} catch {
|
||||
redirect("/dashboard/invoices");
|
||||
}
|
||||
|
||||
// Pass the actual id, let the form component handle the logic
|
||||
return <InvoiceForm invoiceId={id} />;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, useParams, useRouter } from "next/navigation";
|
||||
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge } from "~/components/data/status-badge";
|
||||
@@ -89,6 +89,7 @@ function daysSince(date: Date) {
|
||||
|
||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
||||
const [reminderOpen, setReminderOpen] = useState(false);
|
||||
@@ -106,6 +107,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||
const utils = api.useUtils();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("editBlocked") === "1") {
|
||||
toast.error("Only draft invoices can be edited");
|
||||
router.replace(`/dashboard/invoices/${invoiceId}`);
|
||||
}
|
||||
}, [searchParams, invoiceId, router]);
|
||||
|
||||
const invalidate = () => {
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
||||
@@ -236,12 +244,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
description="View and manage invoice information"
|
||||
>
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
||||
<Button asChild variant="default" className="hover-lift">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-5 w-5" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
{storedStatus === "draft" ? (
|
||||
<Button asChild variant="default" className="hover-lift">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-5 w-5" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
@@ -549,12 +559,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
{storedStatus === "draft" ? (
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
CheckCircle,
|
||||
Send,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
@@ -266,16 +267,30 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
{invoice.status === "draft" ? (
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
title="Edit invoice"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
disabled
|
||||
title="Only draft invoices can be edited"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -322,6 +337,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
searchPlaceholder="Search invoices..."
|
||||
initialSorting={[{ id: "issueDate", desc: true }]}
|
||||
filterableColumns={filterableColumns}
|
||||
emptyTitle="Create your first invoice"
|
||||
emptyDescription="Send professional invoices and track payments from one place."
|
||||
emptyIcon={<FileText className="h-6 w-6" />}
|
||||
emptyAction={
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
onRowClick={(invoice) =>
|
||||
router.push(`/dashboard/invoices/${invoice.id}`)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { FileText, Plus, Upload } from "lucide-react";
|
||||
import { Plus, Upload } from "lucide-react";
|
||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
|
||||
@@ -28,12 +28,6 @@ export default async function InvoicesPage() {
|
||||
<span>Import CSV</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
||||
<Link href="/dashboard/invoices/new?blank=1">
|
||||
<FileText className="mr-2 h-5 w-5" />
|
||||
<span>Blank invoice</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
@@ -376,16 +377,18 @@ export default function RecurringInvoicesPage() {
|
||||
</div>
|
||||
) : (recurring ?? []).length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<RefreshCw className="text-muted-foreground h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No recurring invoices yet. Create one to automatically generate draft invoices on a
|
||||
schedule.
|
||||
</p>
|
||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create first recurring invoice
|
||||
</Button>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={<RefreshCw className="h-6 w-6" />}
|
||||
title="Create your first recurring invoice"
|
||||
description="Automatically generate draft invoices on a schedule you choose."
|
||||
action={
|
||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create recurring invoice
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { brand } from "~/lib/branding";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function OnboardingShell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-8 sm:px-6 sm:py-10">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-xl flex-1 flex-col justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-8 space-y-4 text-center">
|
||||
<div className="flex justify-center">
|
||||
<Logo size="lg" animated={false} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-6">{brand.tagline}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export const ONBOARDING_STEPS = [
|
||||
{ id: "welcome", label: "Welcome" },
|
||||
{ id: "business", label: "Business" },
|
||||
{ id: "client", label: "Client" },
|
||||
] as const;
|
||||
|
||||
export type OnboardingStepId = (typeof ONBOARDING_STEPS)[number]["id"] | "done";
|
||||
|
||||
function stepIndex(step: OnboardingStepId) {
|
||||
if (step === "done") return ONBOARDING_STEPS.length;
|
||||
return ONBOARDING_STEPS.findIndex((item) => item.id === step);
|
||||
}
|
||||
|
||||
export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
|
||||
const currentIndex = stepIndex(step);
|
||||
|
||||
return (
|
||||
<nav aria-label="Setup progress" className="mb-8">
|
||||
<ol className="mx-auto flex w-full max-w-md">
|
||||
{ONBOARDING_STEPS.map((item, index) => {
|
||||
const isComplete = currentIndex > index;
|
||||
const isCurrent = currentIndex === index;
|
||||
const isUpcoming = currentIndex < index;
|
||||
const connectorComplete = currentIndex > index;
|
||||
|
||||
return (
|
||||
<li key={item.id} className="flex flex-1 flex-col items-center">
|
||||
<div className="flex w-full items-center">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-0.5 flex-1 rounded-full transition-colors",
|
||||
connectorComplete || isCurrent
|
||||
? "bg-primary"
|
||||
: "bg-border/80",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
|
||||
isComplete &&
|
||||
"border-primary bg-primary text-primary-foreground",
|
||||
isCurrent &&
|
||||
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
|
||||
isUpcoming &&
|
||||
"border-border/80 bg-background/60 text-muted-foreground",
|
||||
)}
|
||||
aria-current={isCurrent ? "step" : undefined}
|
||||
>
|
||||
{isComplete ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
{index < ONBOARDING_STEPS.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-0.5 flex-1 rounded-full transition-colors",
|
||||
connectorComplete ? "bg-primary" : "bg-border/80",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-2 hidden text-xs font-medium sm:block",
|
||||
isCurrent ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<p className="text-muted-foreground mt-4 text-center text-sm sm:hidden">
|
||||
Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "}
|
||||
{ONBOARDING_STEPS.length}
|
||||
{step !== "done" && ONBOARDING_STEPS[currentIndex]
|
||||
? ` · ${ONBOARDING_STEPS[currentIndex].label}`
|
||||
: ""}
|
||||
</p>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { marketingSurfaceClass } from "~/components/marketing/marketing-chrome";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { brand } from "~/lib/branding";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
OnboardingStepIndicator,
|
||||
type OnboardingStepId,
|
||||
} from "./onboarding-step-indicator";
|
||||
|
||||
type Step = "welcome" | "business" | "client" | "done";
|
||||
type Step = OnboardingStepId;
|
||||
|
||||
function StepIcon({
|
||||
icon: Icon,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary/10 text-primary mb-5 inline-flex rounded-2xl p-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingPanel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
marketingSurfaceClass,
|
||||
"bg-card/80 px-6 py-8 sm:px-8 sm:py-10",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingWizard() {
|
||||
const router = useRouter();
|
||||
@@ -65,15 +104,18 @@ export function OnboardingWizard() {
|
||||
}
|
||||
}, [status?.completed, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && status && !status.completed && step === "welcome") {
|
||||
if (status.businessCount > 0 && status.clientCount > 0) {
|
||||
setStep("done");
|
||||
} else if (status.businessCount > 0) {
|
||||
setStep("client");
|
||||
}
|
||||
const displayStep = useMemo((): Step => {
|
||||
if (step !== "welcome" || !status || status.completed) {
|
||||
return step;
|
||||
}
|
||||
}, [isLoading, status, step]);
|
||||
if (status.businessCount > 0 && status.clientCount > 0) {
|
||||
return "done";
|
||||
}
|
||||
if (status.businessCount > 0) {
|
||||
return "client";
|
||||
}
|
||||
return step;
|
||||
}, [step, status]);
|
||||
|
||||
function handleSkip() {
|
||||
completeOnboarding.mutate();
|
||||
@@ -115,169 +157,208 @@ export function OnboardingWizard() {
|
||||
|
||||
if (isLoading || status?.completed) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] max-w-lg items-center justify-center">
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-lg flex-col justify-center py-8">
|
||||
<div className="mb-6 flex items-center justify-center gap-2">
|
||||
<Sparkles className="text-primary h-5 w-5" />
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
{step === "welcome" && "Step 1 of 3"}
|
||||
{step === "business" && "Step 2 of 3"}
|
||||
{step === "client" && "Step 3 of 3"}
|
||||
{step === "done" && "All set"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{displayStep !== "done" && <OnboardingStepIndicator step={displayStep} />}
|
||||
|
||||
{step === "welcome" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome to BeenVoice</CardTitle>
|
||||
<CardDescription>
|
||||
{displayStep === "welcome" && (
|
||||
<OnboardingPanel>
|
||||
<div className="text-center">
|
||||
<p className="text-primary mb-3 text-sm font-medium tracking-wide uppercase">
|
||||
Quick setup
|
||||
</p>
|
||||
<StepIcon icon={FileText} />
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Welcome to {brand.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||
Let's set up the basics so you can send your first invoice.
|
||||
This only takes a minute.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 className="text-primary mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>Add the business you send invoices from</p>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
|
||||
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Users className="text-primary mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>Add your first client to bill</p>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Add your business</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
|
||||
The name and details that appear on invoices you send.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
|
||||
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Add your first client</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
|
||||
Who you're billing — you can add more details later.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
|
||||
<Button className="h-11 flex-1" size="lg" onClick={() => setStep("business")}>
|
||||
Get started
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-11"
|
||||
onClick={handleSkip}
|
||||
disabled={completeOnboarding.isPending}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</OnboardingPanel>
|
||||
)}
|
||||
|
||||
{displayStep === "business" && (
|
||||
<OnboardingPanel>
|
||||
<div className="text-center">
|
||||
<StepIcon icon={Building2} />
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Your business
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||
This appears on invoices as the sender — name, logo, and contact
|
||||
details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleBusinessSubmit} className="mt-8 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business-name">Business name</Label>
|
||||
<Input
|
||||
id="business-name"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
placeholder="Acme Studio LLC"
|
||||
className="h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button className="flex-1" onClick={() => setStep("business")}>
|
||||
Get started
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="h-11 flex-1"
|
||||
disabled={createBusiness.isPending}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11"
|
||||
onClick={handleSkip}
|
||||
disabled={completeOnboarding.isPending}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</OnboardingPanel>
|
||||
)}
|
||||
|
||||
{step === "business" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your business</CardTitle>
|
||||
<CardDescription>
|
||||
This appears on invoices as the sender — name, logo, and contact
|
||||
details.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleBusinessSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business-name">Business name</Label>
|
||||
<Input
|
||||
id="business-name"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
placeholder="Acme Studio LLC"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={createBusiness.isPending}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === "client" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your first client</CardTitle>
|
||||
<CardDescription>
|
||||
{displayStep === "client" && (
|
||||
<OnboardingPanel>
|
||||
<div className="text-center">
|
||||
<StepIcon icon={Users} />
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Your first client
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||
Who are you billing? You can add more details later.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleClientSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client-name">Client name</Label>
|
||||
<Input
|
||||
id="client-name"
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
placeholder="Acme Corp"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={createClient.isPending}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleClientSubmit} className="mt-8 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client-name">Client name</Label>
|
||||
<Input
|
||||
id="client-name"
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
placeholder="Acme Corp"
|
||||
className="h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="h-11 flex-1"
|
||||
disabled={createClient.isPending}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11"
|
||||
onClick={handleSkip}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</OnboardingPanel>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="text-primary h-5 w-5" />
|
||||
You're ready to go
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your workspace is set up. Create an invoice or explore the
|
||||
dashboard.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button className="flex-1" onClick={handleFinish}>
|
||||
{displayStep === "done" && (
|
||||
<OnboardingPanel className="text-center">
|
||||
<div className="bg-primary/10 text-primary mx-auto mb-5 inline-flex rounded-full p-3">
|
||||
<CheckCircle2 className="h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
You're ready to go
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||
Your workspace is set up. Create an invoice or explore the dashboard.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
|
||||
<Button size="lg" className="h-11 flex-1" onClick={handleFinish}>
|
||||
Go to dashboard
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1" onClick={handleCreateInvoice}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-11 flex-1"
|
||||
onClick={handleCreateInvoice}
|
||||
>
|
||||
Create first invoice
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</OnboardingPanel>
|
||||
)}
|
||||
|
||||
{step !== "welcome" && step !== "done" && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground mt-4"
|
||||
onClick={() =>
|
||||
setStep(step === "client" ? "business" : "welcome")
|
||||
}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{step !== "welcome" && displayStep !== "done" && (
|
||||
<div className="mt-6 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground"
|
||||
onClick={() =>
|
||||
setStep(displayStep === "client" ? "business" : "welcome")
|
||||
}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { OnboardingShell } from "./_components/onboarding-shell";
|
||||
import { OnboardingWizard } from "./_components/onboarding-wizard";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<DashboardPage>
|
||||
<OnboardingShell>
|
||||
<OnboardingWizard />
|
||||
</DashboardPage>
|
||||
</OnboardingShell>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-1
@@ -4,14 +4,33 @@ import { type Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||
import { BrandBackground } from "~/components/layout/brand-background";
|
||||
|
||||
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${brand.name} - Invoicing Made Simple`,
|
||||
metadataBase: new URL(getAppUrl()),
|
||||
title: {
|
||||
default: siteTitle,
|
||||
template: `%s | ${brand.name}`,
|
||||
},
|
||||
description: brand.tagline,
|
||||
openGraph: {
|
||||
title: siteTitle,
|
||||
description: brand.tagline,
|
||||
siteName: brand.name,
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: siteTitle,
|
||||
description: brand.tagline,
|
||||
},
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
import { brand, splitLogoText } from "~/lib/branding";
|
||||
|
||||
export const alt = `${brand.name} - Invoicing Made Simple`;
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default async function Image() {
|
||||
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||
|
||||
const geistMono = await fetch(
|
||||
new URL(
|
||||
"../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||
import.meta.url,
|
||||
),
|
||||
).then((res) => res.arrayBuffer());
|
||||
|
||||
const playfair = await fetch(
|
||||
new URL(
|
||||
"../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
|
||||
import.meta.url,
|
||||
),
|
||||
).then((res) => res.arrayBuffer());
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#ffffff",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgba(128,128,128,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(128,128,128,0.07) 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 520,
|
||||
height: 520,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "rgba(163, 163, 163, 0.25)",
|
||||
filter: "blur(80px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
padding: "0 80px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontFamily: "Geist Mono",
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#18181b" }}>{brand.icon}</span>
|
||||
<span style={{ width: 16 }} />
|
||||
<span style={{ color: "#09090b" }}>{logoPrefix}</span>
|
||||
<span style={{ color: "rgba(9, 9, 11, 0.7)" }}>{logoSuffix}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 32,
|
||||
fontFamily: "Playfair Display",
|
||||
fontSize: 40,
|
||||
fontWeight: 600,
|
||||
color: "#09090b",
|
||||
textAlign: "center",
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
Invoicing Made Simple
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
fontFamily: "Geist Mono",
|
||||
fontSize: 22,
|
||||
fontWeight: 400,
|
||||
color: "#71717a",
|
||||
textAlign: "center",
|
||||
maxWidth: 900,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{brand.tagline}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{
|
||||
name: "Geist Mono",
|
||||
data: geistMono,
|
||||
style: "normal",
|
||||
weight: 700,
|
||||
},
|
||||
{
|
||||
name: "Playfair Display",
|
||||
data: playfair,
|
||||
style: "normal",
|
||||
weight: 600,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { brand } from "~/lib/branding";
|
||||
import { brand, splitLogoText } from "~/lib/branding";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
@@ -10,19 +10,6 @@ interface LogoProps {
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
function splitLogoText(logoText: string) {
|
||||
const voiceIndex = logoText.toLowerCase().indexOf("voice");
|
||||
|
||||
if (voiceIndex > 0) {
|
||||
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
|
||||
}
|
||||
|
||||
return [
|
||||
logoText.slice(0, Math.ceil(logoText.length / 2)),
|
||||
logoText.slice(Math.ceil(logoText.length / 2)),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||
const sizeClasses = {
|
||||
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
ChevronsRight,
|
||||
Filter,
|
||||
Search,
|
||||
SearchX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
@@ -87,6 +89,41 @@ interface DataTableProps<TData, TValue> {
|
||||
clearSelection: () => void,
|
||||
) => React.ReactNode;
|
||||
initialSorting?: SortingState;
|
||||
/** Shown when the dataset is empty (no rows in DB). */
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
emptyIcon?: React.ReactNode;
|
||||
emptyAction?: React.ReactNode;
|
||||
/** Shown when filters/search hide all rows but data exists. */
|
||||
filteredEmptyTitle?: string;
|
||||
filteredEmptyDescription?: string;
|
||||
}
|
||||
|
||||
export interface DataTableEmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Centered empty state for data tables (reuses page EmptyState). */
|
||||
export function DataTableEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: DataTableEmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
className={cn("py-16", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -106,6 +143,12 @@ export function DataTable<TData, TValue>({
|
||||
onRowClick,
|
||||
selectionActions,
|
||||
initialSorting = [],
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyIcon,
|
||||
emptyAction,
|
||||
filteredEmptyTitle = "No matches for your search",
|
||||
filteredEmptyDescription = "Try adjusting your search or filters.",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
@@ -190,6 +233,9 @@ export function DataTable<TData, TValue>({
|
||||
}, [globalFilter]);
|
||||
|
||||
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
||||
const filteredRowCount = table.getFilteredRowModel().rows.length;
|
||||
const isDatasetEmpty = data.length === 0;
|
||||
const isFilteredEmpty = !isDatasetEmpty && filteredRowCount === 0;
|
||||
|
||||
// Handle row click
|
||||
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
||||
@@ -419,12 +465,26 @@ export function DataTable<TData, TValue>({
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<p className="text-muted-foreground">No results found</p>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
{isDatasetEmpty && emptyTitle ? (
|
||||
<DataTableEmptyState
|
||||
icon={emptyIcon}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
/>
|
||||
) : isFilteredEmpty ? (
|
||||
<DataTableEmptyState
|
||||
icon={<SearchX className="h-6 w-6" />}
|
||||
title={filteredEmptyTitle}
|
||||
description={filteredEmptyDescription}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -155,11 +155,22 @@ export function InvoiceList() {
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
{invoice.status === "draft" ? (
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
title="Only draft invoices can be edited"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Label } from "~/components/ui/label";
|
||||
@@ -11,11 +11,9 @@ import {
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
pageTabsGridClass,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
@@ -72,13 +70,13 @@ interface InvoiceFormProps {
|
||||
|
||||
function InvoiceFormSkeleton() {
|
||||
return (
|
||||
<DashboardPage className="pb-8">
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="Loading invoice form"
|
||||
/>
|
||||
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
|
||||
<div className="bg-muted h-10 w-full animate-pulse rounded-xl p-1" />
|
||||
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||
</div>
|
||||
@@ -103,7 +101,7 @@ function plainTextToHtml(value: string) {
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
||||
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||
return {
|
||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||
invoicePrefix: "#",
|
||||
@@ -117,30 +115,26 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
||||
taxRate: 0,
|
||||
currency: "USD",
|
||||
defaultHourlyRate: null,
|
||||
items: blank
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isBlank = searchParams.get("blank") === "1";
|
||||
const utils = api.useUtils();
|
||||
|
||||
// State
|
||||
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
||||
createDefaultInvoiceFormData(isBlank),
|
||||
createDefaultInvoiceFormData(),
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -148,15 +142,6 @@ 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 } =
|
||||
@@ -187,7 +172,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
}, [invoiceId]);
|
||||
useEffect(() => {
|
||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||
// ... (Mapping logic same as before)
|
||||
const mappedItems: InvoiceItem[] =
|
||||
existingInvoice.items?.map((item) => ({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -239,6 +223,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
}
|
||||
}, [invoiceId, existingInvoice, businesses, initialized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
invoiceId &&
|
||||
invoiceId !== "new" &&
|
||||
existingInvoice &&
|
||||
!loadingInvoice &&
|
||||
existingInvoice.status !== "draft"
|
||||
) {
|
||||
toast.error("Only draft invoices can be edited");
|
||||
router.replace(`/dashboard/invoices/${invoiceId}`);
|
||||
}
|
||||
}, [invoiceId, existingInvoice, loadingInvoice, router]);
|
||||
|
||||
const totals = React.useMemo(() => {
|
||||
const subtotal = formData.items.reduce(
|
||||
(sum, item) => sum + item.hours * item.rate,
|
||||
@@ -464,26 +461,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
!initialized ||
|
||||
loadingClients ||
|
||||
loadingBusinesses ||
|
||||
(invoiceId && invoiceId !== "new" && loadingInvoice)
|
||||
(invoiceId && invoiceId !== "new" && loadingInvoice) ||
|
||||
(invoiceId &&
|
||||
invoiceId !== "new" &&
|
||||
existingInvoice &&
|
||||
existingInvoice.status !== "draft")
|
||||
)
|
||||
return <InvoiceFormSkeleton />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPage className="pb-8">
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title={
|
||||
invoiceId !== "new"
|
||||
? "Edit Invoice"
|
||||
: isBlank
|
||||
? "Blank Invoice"
|
||||
: "Create Invoice"
|
||||
}
|
||||
description={
|
||||
isBlank
|
||||
? "Set up a draft to clock time into later"
|
||||
: "Manage your invoice"
|
||||
}
|
||||
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
||||
description="Manage your invoice"
|
||||
>
|
||||
{invoiceId !== "new" && (
|
||||
<Button
|
||||
@@ -500,27 +491,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
dashboardGridClass,
|
||||
"lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]",
|
||||
)}
|
||||
>
|
||||
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
||||
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
||||
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
|
||||
<PageTabsTrigger value="preview" className="lg:hidden">
|
||||
Preview
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="preview">Preview</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
{/* DETAILS TAB */}
|
||||
<PageTabsContent
|
||||
value="details"
|
||||
className={cn(dashboardGridClass, "lg:grid-cols-2")}
|
||||
>
|
||||
<PageTabsContent value="details">
|
||||
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2 text-base">
|
||||
@@ -763,11 +744,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageTabsContent>
|
||||
|
||||
{/* ITEMS TAB */}
|
||||
<PageTabsContent value="items">
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className={cn(pageTabsGridClass, "md:grid-cols-3")}>
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
@@ -840,28 +822,48 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</PageTabsContent>
|
||||
|
||||
<PageTabsContent value="preview">
|
||||
<PageTabs
|
||||
value={previewTab}
|
||||
onValueChange={setPreviewTab}
|
||||
className="w-full"
|
||||
>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="pdf">PDF</PageTabsTrigger>
|
||||
<PageTabsTrigger value="email">Email</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<PageTabsContent value="pdf">
|
||||
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
|
||||
</PageTabsContent>
|
||||
|
||||
<PageTabsContent value="email">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2">
|
||||
<Mail className="h-5 w-5" /> Email Preview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
Preview
|
||||
</CardTitle>
|
||||
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewTab("pdf")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||
previewTab === "pdf"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewTab("email")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||
previewTab === "email"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{previewTab === "pdf" ? (
|
||||
<InvoicePdfPreviewPanel
|
||||
embedded
|
||||
input={pdfPreviewInput}
|
||||
enabled={activeTab === "preview" && previewTab === "pdf"}
|
||||
/>
|
||||
) : (
|
||||
<div className="border-t p-6">
|
||||
<EmailPreview
|
||||
subject={`Invoice ${formData.invoiceNumber} from ${
|
||||
selectedBusiness?.name ?? "Your Business"
|
||||
@@ -900,30 +902,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
|
||||
<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>
|
||||
</DashboardPage>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
|
||||
@@ -155,7 +155,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
||||
)}
|
||||
>
|
||||
<DatePicker
|
||||
@@ -260,7 +260,7 @@ function MobileLineItem({
|
||||
date={item.date}
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
size="sm"
|
||||
className="w-[92px] shrink-0"
|
||||
className="w-auto shrink-0"
|
||||
inputClassName="h-8 px-2 text-xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
@@ -374,7 +374,7 @@ export function InvoiceLineItems({
|
||||
) : null}
|
||||
<AnimatePresence>
|
||||
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[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">
|
||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
|
||||
<span>Date</span>
|
||||
<span>Description</span>
|
||||
<span className="text-center">Hours</span>
|
||||
|
||||
@@ -37,6 +37,8 @@ type InvoicePdfPreviewPanelProps = {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
heightClassName?: string;
|
||||
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function InvoicePdfPreviewPanel({
|
||||
@@ -44,6 +46,7 @@ export function InvoicePdfPreviewPanel({
|
||||
enabled = true,
|
||||
className,
|
||||
heightClassName = "h-[min(80vh,760px)]",
|
||||
embedded = false,
|
||||
}: InvoicePdfPreviewPanelProps) {
|
||||
const previewReady = canPreview(input);
|
||||
|
||||
@@ -54,6 +57,48 @@ export function InvoicePdfPreviewPanel({
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const previewBody = (
|
||||
<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>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -63,43 +108,7 @@ export function InvoicePdfPreviewPanel({
|
||||
{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>
|
||||
<CardContent className="p-0">{previewBody}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden",
|
||||
"dashboard-mobile-header bg-background/80 border-border fixed top-0 right-0 left-0 z-50 flex min-h-16 items-center border-b px-3 backdrop-blur-md sm:px-4 md:hidden",
|
||||
isOnboarding && "hidden",
|
||||
)}
|
||||
>
|
||||
@@ -47,8 +47,8 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<div className="ml-4 flex min-w-0 flex-1 items-center gap-2">
|
||||
<Logo size="sm" />
|
||||
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
|
||||
<Logo size="sm" className="shrink-0" />
|
||||
<ActiveTimerWidget compact />
|
||||
</div>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
@@ -67,9 +67,13 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
|
||||
)}
|
||||
>
|
||||
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
|
||||
{isOnboarding ? (
|
||||
<OnboardingGuard>{children}</OnboardingGuard>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
|
||||
<OnboardingGuard>{children}</OnboardingGuard>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -123,13 +123,13 @@ export function EmptyState({
|
||||
return (
|
||||
<div className={cn("py-12 text-center", className)}>
|
||||
{icon && (
|
||||
<div className="bg-muted/50 mx-auto mb-4 flex h-16 w-16 items-center justify-center">
|
||||
<div className="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl p-3 [&_svg]:text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mx-auto mb-4 max-w-sm">
|
||||
<p className="text-muted-foreground mx-auto mb-4 max-w-sm text-sm">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -177,6 +177,7 @@ export function AnimationPreferencesProviderSynced({
|
||||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
||||
|
||||
if (localIsDefault || differs) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time server hydration after local storage
|
||||
performUpdate(
|
||||
{
|
||||
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import { ChevronDown, Clock, ExternalLink, Play, Square } from "lucide-react";
|
||||
import { ChevronDown, Clock, Play, Square } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import {
|
||||
describeClockOutOutcome,
|
||||
formatElapsedSeconds,
|
||||
formatRunningTimerLabel,
|
||||
resolveClockDescription,
|
||||
resolveEffectiveHourlyRate,
|
||||
startedAtFromMinutesAgo,
|
||||
@@ -52,6 +53,19 @@ function invoiceLabel(inv: {
|
||||
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
||||
}
|
||||
|
||||
function entryHref(entry: {
|
||||
invoiceId: string | null;
|
||||
clientId: string | null;
|
||||
invoice?: { id: string } | null;
|
||||
client?: { id: string } | null;
|
||||
}): string | null {
|
||||
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
|
||||
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
|
||||
const clientId = entry.clientId ?? entry.client?.id;
|
||||
if (clientId) return `/dashboard/clients/${clientId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function ClientChip({
|
||||
label,
|
||||
active,
|
||||
@@ -278,8 +292,7 @@ export function TimeClockPanel({
|
||||
}
|
||||
|
||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||
const runningTitle =
|
||||
running?.description?.trim() ?? resolveClockDescription("");
|
||||
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||
@@ -478,7 +491,11 @@ export function TimeClockPanel({
|
||||
id="clock-stop-note"
|
||||
value={stopNote}
|
||||
onChange={(e) => setStopNote(e.target.value)}
|
||||
placeholder={running?.description || "Update description when you stop"}
|
||||
placeholder={
|
||||
running?.description?.trim()
|
||||
? running.description
|
||||
: "Update description when you stop"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -517,49 +534,65 @@ export function TimeClockPanel({
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Today's entries</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
<CardContent>
|
||||
{todayEntries
|
||||
.filter((e) => e.endedAt)
|
||||
.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{entry.description || (
|
||||
<span className="text-muted-foreground italic">No description</span>
|
||||
.map((entry, index, entries) => {
|
||||
const href = entryHref(entry);
|
||||
const isLast = index === entries.length - 1;
|
||||
const rowClassName = cn(
|
||||
"flex items-start justify-between gap-4 py-3",
|
||||
!isLast && "border-border border-b",
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{formatRunningTimerLabel(entry.description)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{entry.client?.name ?? "No client"}
|
||||
{entry.invoice
|
||||
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||
: entry.hours
|
||||
? " · not on invoice"
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
|
||||
{entry.rate ? (
|
||||
<p className="text-muted-foreground">${entry.rate}/hr</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
key={entry.id}
|
||||
href={href}
|
||||
className={cn(
|
||||
rowClassName,
|
||||
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{entry.client?.name ?? "No client"}
|
||||
{entry.invoice
|
||||
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||
: entry.hours
|
||||
? " · not on invoice"
|
||||
: ""}
|
||||
</p>
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={rowClassName}>
|
||||
{content}
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
|
||||
{entry.rate ? (
|
||||
<p className="text-muted-foreground">${entry.rate}/hr</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{compact ? (
|
||||
<Button variant="link" className="h-auto p-0" asChild>
|
||||
<Link href="/dashboard/time-clock">
|
||||
Open full time clock
|
||||
<ExternalLink className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,18 +14,23 @@ import {
|
||||
} from "~/components/ui/popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
function formatDate(date: Date | undefined) {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS);
|
||||
}
|
||||
|
||||
// Longest month name in en-US long format (September 30, 2026).
|
||||
const LONGEST_FORMATTED_DATE = formatDate(new Date(2026, 8, 30));
|
||||
|
||||
interface DatePickerProps {
|
||||
date?: Date;
|
||||
onDateChange: (date: Date | undefined) => void;
|
||||
@@ -57,13 +62,7 @@ export function DatePicker({
|
||||
lg: "h-10 text-sm",
|
||||
};
|
||||
|
||||
const inputWidthClass = className?.includes("w-full")
|
||||
? "w-full"
|
||||
: className?.includes("w-32") ||
|
||||
className?.includes("w-28") ||
|
||||
className?.includes("w-36")
|
||||
? className
|
||||
: "w-full md:w-32 md:min-w-32";
|
||||
const wantsFullWidth = className?.includes("w-full");
|
||||
|
||||
React.useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
|
||||
@@ -72,16 +71,31 @@ export function DatePicker({
|
||||
}, [date]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex gap-2", inputWidthClass, className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-w-max",
|
||||
wantsFullWidth ? "w-full" : "w-auto shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"invisible block whitespace-nowrap px-3 pr-10",
|
||||
sizeClasses[size],
|
||||
inputClassName,
|
||||
)}
|
||||
>
|
||||
{LONGEST_FORMATTED_DATE}
|
||||
</span>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"bg-background pr-10",
|
||||
"bg-background absolute inset-0 w-full pr-10 tabular-nums",
|
||||
sizeClasses[size],
|
||||
"w-full",
|
||||
inputClassName,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -64,9 +64,9 @@ function SheetContent({
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto rounded-b-xl border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto rounded-t-xl border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { envBoolean } from "~/lib/env-boolean";
|
||||
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
|
||||
import { db } from "~/server/db";
|
||||
import * as schema from "~/server/db/schema";
|
||||
|
||||
@@ -48,6 +49,18 @@ export const auth = betterAuth({
|
||||
verification: schema.verificationTokens,
|
||||
},
|
||||
}),
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
if (isDemoUser(user)) {
|
||||
return;
|
||||
}
|
||||
await promoteFirstRealUserIfNeeded(user.id);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
trustedOrigins: async (request) => {
|
||||
const origins = [...staticTrustedOrigins];
|
||||
|
||||
|
||||
+15
-1
@@ -1,5 +1,5 @@
|
||||
import { env } from "~/env";
|
||||
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
|
||||
import { type ColorMode } from "~/lib/appearance";
|
||||
|
||||
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
|
||||
export {
|
||||
@@ -39,3 +39,17 @@ export const brand = {
|
||||
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
|
||||
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
|
||||
};
|
||||
|
||||
/** Split logo text for the `$ been` / `voice` styling used in Logo and OG images. */
|
||||
export function splitLogoText(logoText: string) {
|
||||
const voiceIndex = logoText.toLowerCase().indexOf("voice");
|
||||
|
||||
if (voiceIndex > 0) {
|
||||
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
|
||||
}
|
||||
|
||||
return [
|
||||
logoText.slice(0, Math.ceil(logoText.length / 2)),
|
||||
logoText.slice(Math.ceil(logoText.length / 2)),
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { and, asc, eq, ne, sql } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
/** Seeded in drizzle/0014_seed_demo_account.sql for App Store review. */
|
||||
export const DEMO_USER_EMAIL = "demo@example.com";
|
||||
export const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
|
||||
|
||||
const FIRST_USER_ADMIN_LOCK_KEY = 0x62656e76;
|
||||
|
||||
type DbTx = Pick<typeof db, "execute" | "select" | "query" | "update">;
|
||||
|
||||
export function isDemoUser(user: {
|
||||
email?: string | null;
|
||||
id?: string | null;
|
||||
}): boolean {
|
||||
const email = user.email?.toLowerCase();
|
||||
return email === DEMO_USER_EMAIL || user.id === DEMO_USER_ID;
|
||||
}
|
||||
|
||||
function nonDemoUserConditions() {
|
||||
return and(ne(users.email, DEMO_USER_EMAIL), ne(users.id, DEMO_USER_ID));
|
||||
}
|
||||
|
||||
async function acquireFirstUserAdminLock(tx: DbTx): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(${FIRST_USER_ADMIN_LOCK_KEY})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Role for a user about to be inserted. Call inside a transaction before insert.
|
||||
*/
|
||||
export async function resolveNewUserRole(tx: DbTx): Promise<"admin" | "user"> {
|
||||
await acquireFirstUserAdminLock(tx);
|
||||
|
||||
const [result] = await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(nonDemoUserConditions());
|
||||
|
||||
return (result?.count ?? 0) === 0 ? "admin" : "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote the first non-demo user to admin after Better Auth creates them (OAuth, etc.).
|
||||
* Safe under concurrent sign-ups: only one non-demo admin is ever assigned.
|
||||
*/
|
||||
export async function promoteFirstRealUserIfNeeded(userId: string): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await acquireFirstUserAdminLock(tx);
|
||||
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { id: true, email: true, role: true },
|
||||
});
|
||||
|
||||
if (!user || isDemoUser(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [adminResult] = await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(and(nonDemoUserConditions(), eq(users.role, "admin")));
|
||||
|
||||
if ((adminResult?.count ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstRealUser] = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(nonDemoUserConditions())
|
||||
.orderBy(asc(users.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (firstRealUser?.id === userId) {
|
||||
await tx.update(users).set({ role: "admin" }).where(eq(users.id, userId));
|
||||
}
|
||||
});
|
||||
}
|
||||
+17
-2
@@ -1,4 +1,5 @@
|
||||
export const DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||
/** Stored on entries clocked in before empty descriptions were allowed. */
|
||||
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||
|
||||
export function resolveEffectiveHourlyRate(
|
||||
enteredRate: number,
|
||||
@@ -21,7 +22,21 @@ export function resolveClockDescription(
|
||||
const trimmed = title.trim();
|
||||
if (trimmed) return trimmed;
|
||||
if (existingDescription?.trim()) return existingDescription.trim();
|
||||
return DEFAULT_CLOCK_DESCRIPTION;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function formatRunningTimerLabel(description?: string | null): string {
|
||||
const trimmed = description?.trim() ?? "";
|
||||
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) return "Clocked in";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveBillingDescription(description?: string | null): string {
|
||||
const trimmed = description?.trim() ?? "";
|
||||
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) {
|
||||
return LEGACY_DEFAULT_CLOCK_DESCRIPTION;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export type ClockOutOutcome =
|
||||
|
||||
@@ -440,20 +440,10 @@ export const invoicesRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (items && existingInvoice.status !== "draft") {
|
||||
if (existingInvoice.status !== "draft") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Line items can only be edited on draft invoices",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
cleanInvoiceData.sendReminderAt !== undefined &&
|
||||
existingInvoice.status !== "draft"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Send reminders can only be set on draft invoices",
|
||||
message: "Only draft invoices can be edited",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
computeTrackedHours,
|
||||
resolveBillingDescription,
|
||||
type ClockOutOutcome,
|
||||
} from "~/lib/time-clock";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||
@@ -457,12 +458,13 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
|
||||
const endedAt = new Date();
|
||||
const hours = computeHours(entry.startedAt, endedAt);
|
||||
const description = input?.description?.trim() ?? entry.description;
|
||||
const rawDescription = input?.description?.trim() ?? entry.description?.trim() ?? "";
|
||||
const billingDescription = resolveBillingDescription(rawDescription);
|
||||
const rate = entry.rate ?? 0;
|
||||
|
||||
const [updated] = await ctx.db
|
||||
.update(timeEntries)
|
||||
.set({ endedAt, hours, description, updatedAt: new Date() })
|
||||
.set({ endedAt, hours, description: rawDescription, updatedAt: new Date() })
|
||||
.where(eq(timeEntries.id, entry.id))
|
||||
.returning();
|
||||
|
||||
@@ -478,7 +480,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
ctx.session.user.id,
|
||||
entry.invoiceId,
|
||||
updated.id,
|
||||
description,
|
||||
billingDescription,
|
||||
hours,
|
||||
rate,
|
||||
endedAt,
|
||||
@@ -490,7 +492,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
ctx.session.user.id,
|
||||
entry.clientId,
|
||||
updated.id,
|
||||
description,
|
||||
billingDescription,
|
||||
hours,
|
||||
rate,
|
||||
endedAt,
|
||||
|
||||
+13
-2
@@ -26,6 +26,8 @@
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--radius: 1rem;
|
||||
--dashboard-mobile-header-height: 4rem;
|
||||
--dashboard-mobile-header-gap: 0.75rem;
|
||||
}
|
||||
|
||||
:root[data-color-mode="dark"],
|
||||
@@ -127,9 +129,15 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.dashboard-mobile-header {
|
||||
min-height: var(--dashboard-mobile-header-height);
|
||||
}
|
||||
|
||||
.dashboard-content-shell {
|
||||
padding: 1rem;
|
||||
padding-top: 4rem;
|
||||
padding-top: calc(
|
||||
var(--dashboard-mobile-header-height) + var(--dashboard-mobile-header-gap)
|
||||
);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -143,7 +151,10 @@
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
[data-slot="card"] {
|
||||
[data-slot="card"],
|
||||
[data-slot="dialog-content"],
|
||||
[data-slot="alert-dialog-content"],
|
||||
[data-slot="popover-content"] {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user