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:
+1
-1
@@ -5,7 +5,7 @@ import drizzle from "eslint-plugin-drizzle";
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [".next"],
|
ignores: [".next", "scripts/**"],
|
||||||
},
|
},
|
||||||
...nextCoreWebVitals,
|
...nextCoreWebVitals,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "~/lib/auth";
|
import { auth } from "~/lib/auth";
|
||||||
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
||||||
|
import { resolveNewUserRole } from "~/lib/first-admin";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { accounts, users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
@@ -119,12 +120,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
const role = await resolveNewUserRole(tx);
|
||||||
|
|
||||||
const [user] = await tx
|
const [user] = await tx
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
name: `${firstName} ${lastName}`,
|
name: `${firstName} ${lastName}`,
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
role,
|
||||||
})
|
})
|
||||||
.returning({ id: users.id });
|
.returning({ id: users.id });
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { Card, CardContent } from "~/components/ui/card";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Square, Clock } from "lucide-react";
|
import { Square, Clock } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
import {
|
||||||
|
describeClockOutOutcome,
|
||||||
|
formatElapsedSeconds,
|
||||||
|
formatRunningTimerLabel,
|
||||||
|
} from "~/lib/time-clock";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -86,10 +90,7 @@ export function ActiveTimerWidget({
|
|||||||
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const description =
|
const description = formatRunningTimerLabel(running.description);
|
||||||
running.description || (
|
|
||||||
<span className="text-muted-foreground italic">No description</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderStopButton = (className?: string) => (
|
const renderStopButton = (className?: string) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -106,20 +107,20 @@ export function ActiveTimerWidget({
|
|||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
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
|
<Link
|
||||||
href="/dashboard/time-clock"
|
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 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 className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
|
||||||
</span>
|
</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)}
|
{formatElapsedSeconds(elapsed)}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
{renderStopButton()}
|
{renderStopButton("shrink-0")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,7 +142,10 @@ export function ActiveTimerWidget({
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</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">
|
<p className="text-sm font-medium">
|
||||||
{description}
|
{description}
|
||||||
{running.client && (
|
{running.client && (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
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 { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -208,6 +208,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
data={searchableBusinesses}
|
data={searchableBusinesses}
|
||||||
searchKey="searchValue"
|
searchKey="searchValue"
|
||||||
searchPlaceholder="Search by name or nickname..."
|
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}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
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 { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -179,6 +179,17 @@ export function ClientsDataTable({
|
|||||||
data={clients}
|
data={clients}
|
||||||
searchKey="name"
|
searchKey="name"
|
||||||
searchPlaceholder="Search clients..."
|
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}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -214,12 +215,17 @@ export default function ExpensesPage() {
|
|||||||
Loading…
|
Loading…
|
||||||
</div>
|
</div>
|
||||||
) : expenses.length === 0 ? (
|
) : expenses.length === 0 ? (
|
||||||
<div className="p-8 text-center">
|
<EmptyState
|
||||||
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
icon={<Receipt className="h-6 w-6" />}
|
||||||
<p className="text-muted-foreground text-sm">
|
title="Create your first expense"
|
||||||
No expenses yet. Add your first expense.
|
description="Track billable costs, reimbursements, and tax-deductible spending."
|
||||||
</p>
|
action={
|
||||||
</div>
|
<Button onClick={handleOpen}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add expense
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{expenses.map((expense) => (
|
{expenses.map((expense) => (
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { ExternalLink } from "lucide-react";
|
|
||||||
|
|
||||||
interface InvoiceTimerCardProps {
|
interface InvoiceTimerCardProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
@@ -12,18 +9,10 @@ interface InvoiceTimerCardProps {
|
|||||||
|
|
||||||
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<TimeClockPanel
|
||||||
<TimeClockPanel
|
compact
|
||||||
compact
|
defaultClientId={clientId}
|
||||||
defaultClientId={clientId}
|
defaultInvoiceId={invoiceId}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import InvoiceForm from "~/components/forms/invoice-form";
|
import InvoiceForm from "~/components/forms/invoice-form";
|
||||||
|
import { api } from "~/trpc/server";
|
||||||
|
|
||||||
export default function InvoiceFormPage() {
|
interface EditInvoicePageProps {
|
||||||
const params = useParams();
|
params: Promise<{ id: string }>;
|
||||||
const id = params.id as 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} />;
|
return <InvoiceForm invoiceId={id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
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 { useState, useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
@@ -89,6 +89,7 @@ function daysSince(date: Date) {
|
|||||||
|
|
||||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
||||||
const [reminderOpen, setReminderOpen] = useState(false);
|
const [reminderOpen, setReminderOpen] = useState(false);
|
||||||
@@ -106,6 +107,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
api.payments.getByInvoice.useQuery({ invoiceId });
|
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||||
const utils = api.useUtils();
|
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 = () => {
|
const invalidate = () => {
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
||||||
@@ -236,12 +244,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
description="View and manage invoice information"
|
description="View and manage invoice information"
|
||||||
>
|
>
|
||||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
||||||
<Button asChild variant="default" className="hover-lift">
|
{storedStatus === "draft" ? (
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Button asChild variant="default" className="hover-lift">
|
||||||
<Edit className="mr-2 h-5 w-5" />
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
Edit
|
<Edit className="mr-2 h-5 w-5" />
|
||||||
</Link>
|
Edit
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</DashboardPageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
@@ -549,12 +559,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Button asChild variant="secondary" className="w-full">
|
{storedStatus === "draft" ? (
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Button asChild variant="secondary" className="w-full">
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
Edit Invoice
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
</Link>
|
Edit Invoice
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{invoice.items && invoice.client && (
|
{invoice.items && invoice.client && (
|
||||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Send,
|
Send,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -266,16 +267,30 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="hover-scale h-8 w-8 p-0"
|
className="hover-scale h-8 w-8 p-0"
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
|
disabled
|
||||||
|
title="Only draft invoices can be edited"
|
||||||
>
|
>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -322,6 +337,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
searchPlaceholder="Search invoices..."
|
searchPlaceholder="Search invoices..."
|
||||||
initialSorting={[{ id: "issueDate", desc: true }]}
|
initialSorting={[{ id: "issueDate", desc: true }]}
|
||||||
filterableColumns={filterableColumns}
|
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) =>
|
onRowClick={(invoice) =>
|
||||||
router.push(`/dashboard/invoices/${invoice.id}`)
|
router.push(`/dashboard/invoices/${invoice.id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { api, HydrateClient } from "~/trpc/server";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
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 { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
|
|
||||||
@@ -28,12 +28,6 @@ export default async function InvoicesPage() {
|
|||||||
<span>Import CSV</span>
|
<span>Import CSV</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
@@ -376,16 +377,18 @@ export default function RecurringInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (recurring ?? []).length === 0 ? (
|
) : (recurring ?? []).length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
<CardContent className="p-0">
|
||||||
<RefreshCw className="text-muted-foreground h-10 w-10" />
|
<EmptyState
|
||||||
<p className="text-muted-foreground text-sm">
|
icon={<RefreshCw className="h-6 w-6" />}
|
||||||
No recurring invoices yet. Create one to automatically generate draft invoices on a
|
title="Create your first recurring invoice"
|
||||||
schedule.
|
description="Automatically generate draft invoices on a schedule you choose."
|
||||||
</p>
|
action={
|
||||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create first recurring invoice
|
Create recurring invoice
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Sparkles,
|
FileText,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { marketingSurfaceClass } from "~/components/marketing/marketing-chrome";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import { api } from "~/trpc/react";
|
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() {
|
export function OnboardingWizard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -65,15 +104,18 @@ export function OnboardingWizard() {
|
|||||||
}
|
}
|
||||||
}, [status?.completed, router]);
|
}, [status?.completed, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
const displayStep = useMemo((): Step => {
|
||||||
if (!isLoading && status && !status.completed && step === "welcome") {
|
if (step !== "welcome" || !status || status.completed) {
|
||||||
if (status.businessCount > 0 && status.clientCount > 0) {
|
return step;
|
||||||
setStep("done");
|
|
||||||
} else if (status.businessCount > 0) {
|
|
||||||
setStep("client");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [isLoading, status, step]);
|
if (status.businessCount > 0 && status.clientCount > 0) {
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
if (status.businessCount > 0) {
|
||||||
|
return "client";
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}, [step, status]);
|
||||||
|
|
||||||
function handleSkip() {
|
function handleSkip() {
|
||||||
completeOnboarding.mutate();
|
completeOnboarding.mutate();
|
||||||
@@ -115,169 +157,208 @@ export function OnboardingWizard() {
|
|||||||
|
|
||||||
if (isLoading || status?.completed) {
|
if (isLoading || status?.completed) {
|
||||||
return (
|
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>
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-lg flex-col justify-center py-8">
|
<div className="w-full">
|
||||||
<div className="mb-6 flex items-center justify-center gap-2">
|
{displayStep !== "done" && <OnboardingStepIndicator step={displayStep} />}
|
||||||
<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>
|
|
||||||
|
|
||||||
{step === "welcome" && (
|
{displayStep === "welcome" && (
|
||||||
<Card>
|
<OnboardingPanel>
|
||||||
<CardHeader>
|
<div className="text-center">
|
||||||
<CardTitle>Welcome to BeenVoice</CardTitle>
|
<p className="text-primary mb-3 text-sm font-medium tracking-wide uppercase">
|
||||||
<CardDescription>
|
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.
|
Let's set up the basics so you can send your first invoice.
|
||||||
This only takes a minute.
|
This only takes a minute.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-3 text-sm">
|
<ul className="mt-8 space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
|
||||||
<Building2 className="text-primary mt-0.5 h-4 w-4 shrink-0" />
|
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
|
||||||
<p>Add the business you send invoices from</p>
|
<Building2 className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div>
|
||||||
<Users className="text-primary mt-0.5 h-4 w-4 shrink-0" />
|
<p className="text-sm font-medium">Add your business</p>
|
||||||
<p>Add your first client to bill</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>
|
</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>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Button className="flex-1" onClick={() => setStep("business")}>
|
<Button
|
||||||
Get started
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
disabled={createBusiness.isPending}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
className="h-11"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
disabled={completeOnboarding.isPending}
|
|
||||||
>
|
>
|
||||||
Skip for now
|
Skip for now
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</form>
|
||||||
</Card>
|
</OnboardingPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "business" && (
|
{displayStep === "client" && (
|
||||||
<Card>
|
<OnboardingPanel>
|
||||||
<CardHeader>
|
<div className="text-center">
|
||||||
<CardTitle>Your business</CardTitle>
|
<StepIcon icon={Users} />
|
||||||
<CardDescription>
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
This appears on invoices as the sender — name, logo, and contact
|
Your first client
|
||||||
details.
|
</h1>
|
||||||
</CardDescription>
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
</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>
|
|
||||||
Who are you billing? You can add more details later.
|
Who are you billing? You can add more details later.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleClientSubmit} className="space-y-4">
|
<form onSubmit={handleClientSubmit} className="mt-8 space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="client-name">Client name</Label>
|
<Label htmlFor="client-name">Client name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="client-name"
|
id="client-name"
|
||||||
value={clientName}
|
value={clientName}
|
||||||
onChange={(e) => setClientName(e.target.value)}
|
onChange={(e) => setClientName(e.target.value)}
|
||||||
placeholder="Acme Corp"
|
placeholder="Acme Corp"
|
||||||
autoFocus
|
className="h-11"
|
||||||
/>
|
autoFocus
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
</div>
|
||||||
<Button
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
type="submit"
|
<Button
|
||||||
className="flex-1"
|
type="submit"
|
||||||
disabled={createClient.isPending}
|
size="lg"
|
||||||
>
|
className="h-11 flex-1"
|
||||||
Continue
|
disabled={createClient.isPending}
|
||||||
</Button>
|
>
|
||||||
<Button type="button" variant="ghost" onClick={handleSkip}>
|
Continue
|
||||||
Skip for now
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
</form>
|
type="button"
|
||||||
</CardContent>
|
variant="ghost"
|
||||||
</Card>
|
className="h-11"
|
||||||
|
onClick={handleSkip}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</OnboardingPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "done" && (
|
{displayStep === "done" && (
|
||||||
<Card>
|
<OnboardingPanel className="text-center">
|
||||||
<CardHeader>
|
<div className="bg-primary/10 text-primary mx-auto mb-5 inline-flex rounded-full p-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CheckCircle2 className="h-7 w-7" />
|
||||||
<CheckCircle2 className="text-primary h-5 w-5" />
|
</div>
|
||||||
You're ready to go
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
</CardTitle>
|
You're ready to go
|
||||||
<CardDescription>
|
</h1>
|
||||||
Your workspace is set up. Create an invoice or explore the
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
dashboard.
|
Your workspace is set up. Create an invoice or explore the dashboard.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
|
||||||
<CardContent className="flex flex-col gap-2 sm:flex-row">
|
<Button size="lg" className="h-11 flex-1" onClick={handleFinish}>
|
||||||
<Button className="flex-1" onClick={handleFinish}>
|
|
||||||
Go to dashboard
|
Go to dashboard
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="flex-1" onClick={handleCreateInvoice}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
onClick={handleCreateInvoice}
|
||||||
|
>
|
||||||
Create first invoice
|
Create first invoice
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</OnboardingPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step !== "welcome" && step !== "done" && (
|
{step !== "welcome" && displayStep !== "done" && (
|
||||||
<Button
|
<div className="mt-6 text-center">
|
||||||
variant="link"
|
<Button
|
||||||
className="text-muted-foreground mt-4"
|
variant="link"
|
||||||
onClick={() =>
|
className="text-muted-foreground"
|
||||||
setStep(step === "client" ? "business" : "welcome")
|
onClick={() =>
|
||||||
}
|
setStep(displayStep === "client" ? "business" : "welcome")
|
||||||
>
|
}
|
||||||
Back
|
>
|
||||||
</Button>
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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";
|
import { OnboardingWizard } from "./_components/onboarding-wizard";
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
return (
|
return (
|
||||||
<DashboardPage>
|
<OnboardingShell>
|
||||||
<OnboardingWizard />
|
<OnboardingWizard />
|
||||||
</DashboardPage>
|
</OnboardingShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -4,14 +4,33 @@ import { type Metadata } from "next";
|
|||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
import { Toaster } from "~/components/ui/sonner";
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
import { brand } from "~/lib/branding";
|
import { brand } from "~/lib/branding";
|
||||||
|
|
||||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||||
import { BrandBackground } from "~/components/layout/brand-background";
|
import { BrandBackground } from "~/components/layout/brand-background";
|
||||||
|
|
||||||
|
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `${brand.name} - Invoicing Made Simple`,
|
metadataBase: new URL(getAppUrl()),
|
||||||
|
title: {
|
||||||
|
default: siteTitle,
|
||||||
|
template: `%s | ${brand.name}`,
|
||||||
|
},
|
||||||
description: brand.tagline,
|
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" }],
|
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";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { brand } from "~/lib/branding";
|
import { brand, splitLogoText } from "~/lib/branding";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
@@ -10,19 +10,6 @@ interface LogoProps {
|
|||||||
animated?: boolean;
|
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) {
|
export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||||
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ import {
|
|||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
Filter,
|
Filter,
|
||||||
Search,
|
Search,
|
||||||
|
SearchX,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card } from "~/components/ui/card";
|
import { Card } from "~/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +89,41 @@ interface DataTableProps<TData, TValue> {
|
|||||||
clearSelection: () => void,
|
clearSelection: () => void,
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
initialSorting?: SortingState;
|
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>({
|
export function DataTable<TData, TValue>({
|
||||||
@@ -106,6 +143,12 @@ export function DataTable<TData, TValue>({
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
selectionActions,
|
selectionActions,
|
||||||
initialSorting = [],
|
initialSorting = [],
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
emptyIcon,
|
||||||
|
emptyAction,
|
||||||
|
filteredEmptyTitle = "No matches for your search",
|
||||||
|
filteredEmptyDescription = "Try adjusting your search or filters.",
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||||
@@ -190,6 +233,9 @@ export function DataTable<TData, TValue>({
|
|||||||
}, [globalFilter]);
|
}, [globalFilter]);
|
||||||
|
|
||||||
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
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
|
// Handle row click
|
||||||
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
||||||
@@ -419,12 +465,26 @@ export function DataTable<TData, TValue>({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableCell
|
<TableCell colSpan={columns.length} className="p-0">
|
||||||
colSpan={columns.length}
|
{isDatasetEmpty && emptyTitle ? (
|
||||||
className="h-24 text-center"
|
<DataTableEmptyState
|
||||||
>
|
icon={emptyIcon}
|
||||||
<p className="text-muted-foreground">No results found</p>
|
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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -155,11 +155,22 @@ export function InvoiceList() {
|
|||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
{invoice.status === "draft" ? (
|
||||||
<Button variant="ghost" size="sm">
|
<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" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState, useEffect } 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 { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
@@ -11,11 +11,9 @@ import {
|
|||||||
PageTabsContent,
|
PageTabsContent,
|
||||||
PageTabsList,
|
PageTabsList,
|
||||||
PageTabsTrigger,
|
PageTabsTrigger,
|
||||||
|
pageTabsGridClass,
|
||||||
} from "~/components/layout/page-tabs";
|
} from "~/components/layout/page-tabs";
|
||||||
import {
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
DashboardPage,
|
|
||||||
dashboardGridClass,
|
|
||||||
} from "~/components/layout/dashboard-page";
|
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -72,13 +70,13 @@ interface InvoiceFormProps {
|
|||||||
|
|
||||||
function InvoiceFormSkeleton() {
|
function InvoiceFormSkeleton() {
|
||||||
return (
|
return (
|
||||||
<DashboardPage className="pb-8">
|
<DashboardPage>
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
title="Loading..."
|
title="Loading..."
|
||||||
description="Loading invoice form"
|
description="Loading invoice form"
|
||||||
/>
|
/>
|
||||||
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />
|
<div className="bg-muted h-10 w-full animate-pulse rounded-xl p-1" />
|
||||||
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
|
<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 className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +101,7 @@ function plainTextToHtml(value: string) {
|
|||||||
.replace(/\n/g, "<br>");
|
.replace(/\n/g, "<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||||
return {
|
return {
|
||||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||||
invoicePrefix: "#",
|
invoicePrefix: "#",
|
||||||
@@ -117,30 +115,26 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
|||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
items: blank
|
items: [
|
||||||
? []
|
{
|
||||||
: [
|
id: crypto.randomUUID(),
|
||||||
{
|
date: new Date(),
|
||||||
id: crypto.randomUUID(),
|
description: "",
|
||||||
date: new Date(),
|
hours: 1,
|
||||||
description: "",
|
rate: 0,
|
||||||
hours: 1,
|
amount: 0,
|
||||||
rate: 0,
|
},
|
||||||
amount: 0,
|
],
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const isBlank = searchParams.get("blank") === "1";
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
||||||
createDefaultInvoiceFormData(isBlank),
|
createDefaultInvoiceFormData(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -148,15 +142,6 @@ 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 } =
|
||||||
@@ -187,7 +172,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
}, [invoiceId]);
|
}, [invoiceId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||||
// ... (Mapping logic same as before)
|
|
||||||
const mappedItems: InvoiceItem[] =
|
const mappedItems: InvoiceItem[] =
|
||||||
existingInvoice.items?.map((item) => ({
|
existingInvoice.items?.map((item) => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -239,6 +223,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
}
|
}
|
||||||
}, [invoiceId, existingInvoice, businesses, initialized]);
|
}, [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 totals = React.useMemo(() => {
|
||||||
const subtotal = formData.items.reduce(
|
const subtotal = formData.items.reduce(
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
(sum, item) => sum + item.hours * item.rate,
|
||||||
@@ -464,26 +461,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
!initialized ||
|
!initialized ||
|
||||||
loadingClients ||
|
loadingClients ||
|
||||||
loadingBusinesses ||
|
loadingBusinesses ||
|
||||||
(invoiceId && invoiceId !== "new" && loadingInvoice)
|
(invoiceId && invoiceId !== "new" && loadingInvoice) ||
|
||||||
|
(invoiceId &&
|
||||||
|
invoiceId !== "new" &&
|
||||||
|
existingInvoice &&
|
||||||
|
existingInvoice.status !== "draft")
|
||||||
)
|
)
|
||||||
return <InvoiceFormSkeleton />;
|
return <InvoiceFormSkeleton />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardPage className="pb-8">
|
<DashboardPage>
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
title={
|
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
||||||
invoiceId !== "new"
|
description="Manage your invoice"
|
||||||
? "Edit Invoice"
|
|
||||||
: isBlank
|
|
||||||
? "Blank Invoice"
|
|
||||||
: "Create Invoice"
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
isBlank
|
|
||||||
? "Set up a draft to clock time into later"
|
|
||||||
: "Manage your invoice"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{invoiceId !== "new" && (
|
{invoiceId !== "new" && (
|
||||||
<Button
|
<Button
|
||||||
@@ -500,27 +491,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DashboardPageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<div
|
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||||
className={cn(
|
|
||||||
dashboardGridClass,
|
|
||||||
"lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
|
||||||
<PageTabsList>
|
<PageTabsList>
|
||||||
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
||||||
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
||||||
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
|
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
|
||||||
<PageTabsTrigger value="preview" className="lg:hidden">
|
<PageTabsTrigger value="preview">Preview</PageTabsTrigger>
|
||||||
Preview
|
|
||||||
</PageTabsTrigger>
|
|
||||||
</PageTabsList>
|
</PageTabsList>
|
||||||
|
|
||||||
{/* DETAILS TAB */}
|
{/* DETAILS TAB */}
|
||||||
<PageTabsContent
|
<PageTabsContent value="details">
|
||||||
value="details"
|
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||||
className={cn(dashboardGridClass, "lg:grid-cols-2")}
|
|
||||||
>
|
|
||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex gap-2 text-base">
|
<CardTitle className="flex gap-2 text-base">
|
||||||
@@ -763,11 +744,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
</PageTabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
{/* ITEMS TAB */}
|
{/* ITEMS TAB */}
|
||||||
<PageTabsContent value="items">
|
<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">
|
<Card className="bg-primary/5 border-primary/20">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
<span className="text-muted-foreground">Total</span>
|
<span className="text-muted-foreground">Total</span>
|
||||||
@@ -840,28 +822,48 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</PageTabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<PageTabsContent value="preview">
|
<PageTabsContent value="preview">
|
||||||
<PageTabs
|
<Card className="overflow-hidden">
|
||||||
value={previewTab}
|
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
|
||||||
onValueChange={setPreviewTab}
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
className="w-full"
|
<FileText className="h-4 w-4" />
|
||||||
>
|
Preview
|
||||||
<PageTabsList>
|
</CardTitle>
|
||||||
<PageTabsTrigger value="pdf">PDF</PageTabsTrigger>
|
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
||||||
<PageTabsTrigger value="email">Email</PageTabsTrigger>
|
<button
|
||||||
</PageTabsList>
|
type="button"
|
||||||
|
onClick={() => setPreviewTab("pdf")}
|
||||||
<PageTabsContent value="pdf">
|
className={cn(
|
||||||
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
|
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||||
</PageTabsContent>
|
previewTab === "pdf"
|
||||||
|
? "bg-background text-foreground shadow"
|
||||||
<PageTabsContent value="email">
|
: "text-muted-foreground hover:text-foreground",
|
||||||
<Card>
|
)}
|
||||||
<CardHeader>
|
>
|
||||||
<CardTitle className="flex gap-2">
|
PDF
|
||||||
<Mail className="h-5 w-5" /> Email Preview
|
</button>
|
||||||
</CardTitle>
|
<button
|
||||||
</CardHeader>
|
type="button"
|
||||||
<CardContent>
|
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
|
<EmailPreview
|
||||||
subject={`Invoice ${formData.invoiceNumber} from ${
|
subject={`Invoice ${formData.invoiceNumber} from ${
|
||||||
selectedBusiness?.name ?? "Your Business"
|
selectedBusiness?.name ?? "Your Business"
|
||||||
@@ -900,30 +902,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
</PageTabsContent>
|
</CardContent>
|
||||||
</PageTabs>
|
</Card>
|
||||||
</PageTabsContent>
|
</PageTabsContent>
|
||||||
</PageTabs>
|
</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>
|
</DashboardPage>
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
<DatePicker
|
||||||
@@ -260,7 +260,7 @@ function MobileLineItem({
|
|||||||
date={item.date}
|
date={item.date}
|
||||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-[92px] shrink-0"
|
className="w-auto shrink-0"
|
||||||
inputClassName="h-8 px-2 text-xs"
|
inputClassName="h-8 px-2 text-xs"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
@@ -374,7 +374,7 @@ export function InvoiceLineItems({
|
|||||||
) : null}
|
) : null}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="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-[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>Date</span>
|
||||||
<span>Description</span>
|
<span>Description</span>
|
||||||
<span className="text-center">Hours</span>
|
<span className="text-center">Hours</span>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type InvoicePdfPreviewPanelProps = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
heightClassName?: string;
|
heightClassName?: string;
|
||||||
|
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
|
||||||
|
embedded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvoicePdfPreviewPanel({
|
export function InvoicePdfPreviewPanel({
|
||||||
@@ -44,6 +46,7 @@ export function InvoicePdfPreviewPanel({
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
className,
|
className,
|
||||||
heightClassName = "h-[min(80vh,760px)]",
|
heightClassName = "h-[min(80vh,760px)]",
|
||||||
|
embedded = false,
|
||||||
}: InvoicePdfPreviewPanelProps) {
|
}: InvoicePdfPreviewPanelProps) {
|
||||||
const previewReady = canPreview(input);
|
const previewReady = canPreview(input);
|
||||||
|
|
||||||
@@ -54,6 +57,48 @@ export function InvoicePdfPreviewPanel({
|
|||||||
staleTime: 5_000,
|
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 (
|
return (
|
||||||
<Card className={cn("overflow-hidden", className)}>
|
<Card className={cn("overflow-hidden", className)}>
|
||||||
<CardHeader className="pb-3">
|
<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}
|
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">{previewBody}</CardContent>
|
||||||
<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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
isOnboarding && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -47,8 +47,8 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
<span className="sr-only">Toggle menu</span>
|
<span className="sr-only">Toggle menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<div className="ml-4 flex min-w-0 flex-1 items-center gap-2">
|
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
|
||||||
<Logo size="sm" />
|
<Logo size="sm" className="shrink-0" />
|
||||||
<ActiveTimerWidget compact />
|
<ActiveTimerWidget compact />
|
||||||
</div>
|
</div>
|
||||||
<SheetContent side="left" className="w-72 p-0">
|
<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"),
|
!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>
|
<OnboardingGuard>{children}</OnboardingGuard>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
|
||||||
|
<OnboardingGuard>{children}</OnboardingGuard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -123,13 +123,13 @@ export function EmptyState({
|
|||||||
return (
|
return (
|
||||||
<div className={cn("py-12 text-center", className)}>
|
<div className={cn("py-12 text-center", className)}>
|
||||||
{icon && (
|
{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}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
|
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
|
||||||
{description && (
|
{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}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export function AnimationPreferencesProviderSynced({
|
|||||||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
||||||
|
|
||||||
if (localIsDefault || differs) {
|
if (localIsDefault || differs) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time server hydration after local storage
|
||||||
performUpdate(
|
performUpdate(
|
||||||
{
|
{
|
||||||
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "~/components/ui/collapsible";
|
} 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 { toast } from "sonner";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
describeClockOutOutcome,
|
describeClockOutOutcome,
|
||||||
formatElapsedSeconds,
|
formatElapsedSeconds,
|
||||||
|
formatRunningTimerLabel,
|
||||||
resolveClockDescription,
|
resolveClockDescription,
|
||||||
resolveEffectiveHourlyRate,
|
resolveEffectiveHourlyRate,
|
||||||
startedAtFromMinutesAgo,
|
startedAtFromMinutesAgo,
|
||||||
@@ -52,6 +53,19 @@ function invoiceLabel(inv: {
|
|||||||
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
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({
|
function ClientChip({
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
@@ -278,8 +292,7 @@ export function TimeClockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||||
const runningTitle =
|
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||||
running?.description?.trim() ?? resolveClockDescription("");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||||
@@ -478,7 +491,11 @@ export function TimeClockPanel({
|
|||||||
id="clock-stop-note"
|
id="clock-stop-note"
|
||||||
value={stopNote}
|
value={stopNote}
|
||||||
onChange={(e) => setStopNote(e.target.value)}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -517,49 +534,65 @@ export function TimeClockPanel({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Today's entries</CardTitle>
|
<CardTitle className="text-base">Today's entries</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="divide-y">
|
<CardContent>
|
||||||
{todayEntries
|
{todayEntries
|
||||||
.filter((e) => e.endedAt)
|
.filter((e) => e.endedAt)
|
||||||
.map((entry) => (
|
.map((entry, index, entries) => {
|
||||||
<div
|
const href = entryHref(entry);
|
||||||
key={entry.id}
|
const isLast = index === entries.length - 1;
|
||||||
className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0"
|
const rowClassName = cn(
|
||||||
>
|
"flex items-start justify-between gap-4 py-3",
|
||||||
<div className="min-w-0">
|
!isLast && "border-border border-b",
|
||||||
<p className="font-medium">
|
);
|
||||||
{entry.description || (
|
const content = (
|
||||||
<span className="text-muted-foreground italic">No description</span>
|
<>
|
||||||
|
<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">
|
{content}
|
||||||
{entry.client?.name ?? "No client"}
|
</Link>
|
||||||
{entry.invoice
|
);
|
||||||
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
}
|
||||||
: entry.hours
|
|
||||||
? " · not on invoice"
|
return (
|
||||||
: ""}
|
<div key={entry.id} className={rowClassName}>
|
||||||
</p>
|
{content}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,18 +14,23 @@ import {
|
|||||||
} from "~/components/ui/popover";
|
} from "~/components/ui/popover";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
};
|
||||||
|
|
||||||
function formatDate(date: Date | undefined) {
|
function formatDate(date: Date | undefined) {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return date.toLocaleDateString("en-US", {
|
return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS);
|
||||||
day: "2-digit",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Longest month name in en-US long format (September 30, 2026).
|
||||||
|
const LONGEST_FORMATTED_DATE = formatDate(new Date(2026, 8, 30));
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
date?: Date;
|
date?: Date;
|
||||||
onDateChange: (date: Date | undefined) => void;
|
onDateChange: (date: Date | undefined) => void;
|
||||||
@@ -57,13 +62,7 @@ export function DatePicker({
|
|||||||
lg: "h-10 text-sm",
|
lg: "h-10 text-sm",
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputWidthClass = className?.includes("w-full")
|
const wantsFullWidth = 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";
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
|
// 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]);
|
}, [date]);
|
||||||
|
|
||||||
return (
|
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
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background pr-10",
|
"bg-background absolute inset-0 w-full pr-10 tabular-nums",
|
||||||
sizeClasses[size],
|
sizeClasses[size],
|
||||||
"w-full",
|
|
||||||
inputClassName,
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ function SheetContent({
|
|||||||
side === "left" &&
|
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",
|
"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" &&
|
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" &&
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|||||||
import { nextCookies } from "better-auth/next-js";
|
import { nextCookies } from "better-auth/next-js";
|
||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
import { envBoolean } from "~/lib/env-boolean";
|
import { envBoolean } from "~/lib/env-boolean";
|
||||||
|
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import * as schema from "~/server/db/schema";
|
import * as schema from "~/server/db/schema";
|
||||||
|
|
||||||
@@ -48,6 +49,18 @@ export const auth = betterAuth({
|
|||||||
verification: schema.verificationTokens,
|
verification: schema.verificationTokens,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
after: async (user) => {
|
||||||
|
if (isDemoUser(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await promoteFirstRealUserIfNeeded(user.id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
trustedOrigins: async (request) => {
|
trustedOrigins: async (request) => {
|
||||||
const origins = [...staticTrustedOrigins];
|
const origins = [...staticTrustedOrigins];
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,5 @@
|
|||||||
import { env } from "~/env";
|
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 type { ColorMode, PdfTemplate } from "~/lib/appearance";
|
||||||
export {
|
export {
|
||||||
@@ -39,3 +39,17 @@ export const brand = {
|
|||||||
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
|
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
|
||||||
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
|
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(
|
export function resolveEffectiveHourlyRate(
|
||||||
enteredRate: number,
|
enteredRate: number,
|
||||||
@@ -21,7 +22,21 @@ export function resolveClockDescription(
|
|||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
if (trimmed) return trimmed;
|
if (trimmed) return trimmed;
|
||||||
if (existingDescription?.trim()) return existingDescription.trim();
|
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 =
|
export type ClockOutOutcome =
|
||||||
|
|||||||
@@ -440,20 +440,10 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items && existingInvoice.status !== "draft") {
|
if (existingInvoice.status !== "draft") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Line items can only be edited on draft invoices",
|
message: "Only draft invoices can be edited",
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
cleanInvoiceData.sendReminderAt !== undefined &&
|
|
||||||
existingInvoice.status !== "draft"
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Send reminders can only be set on draft invoices",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
computeTrackedHours,
|
computeTrackedHours,
|
||||||
|
resolveBillingDescription,
|
||||||
type ClockOutOutcome,
|
type ClockOutOutcome,
|
||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
@@ -457,12 +458,13 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const endedAt = new Date();
|
const endedAt = new Date();
|
||||||
const hours = computeHours(entry.startedAt, endedAt);
|
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 rate = entry.rate ?? 0;
|
||||||
|
|
||||||
const [updated] = await ctx.db
|
const [updated] = await ctx.db
|
||||||
.update(timeEntries)
|
.update(timeEntries)
|
||||||
.set({ endedAt, hours, description, updatedAt: new Date() })
|
.set({ endedAt, hours, description: rawDescription, updatedAt: new Date() })
|
||||||
.where(eq(timeEntries.id, entry.id))
|
.where(eq(timeEntries.id, entry.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -478,7 +480,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
ctx.session.user.id,
|
ctx.session.user.id,
|
||||||
entry.invoiceId,
|
entry.invoiceId,
|
||||||
updated.id,
|
updated.id,
|
||||||
description,
|
billingDescription,
|
||||||
hours,
|
hours,
|
||||||
rate,
|
rate,
|
||||||
endedAt,
|
endedAt,
|
||||||
@@ -490,7 +492,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
ctx.session.user.id,
|
ctx.session.user.id,
|
||||||
entry.clientId,
|
entry.clientId,
|
||||||
updated.id,
|
updated.id,
|
||||||
description,
|
billingDescription,
|
||||||
hours,
|
hours,
|
||||||
rate,
|
rate,
|
||||||
endedAt,
|
endedAt,
|
||||||
|
|||||||
+13
-2
@@ -26,6 +26,8 @@
|
|||||||
--input: 240 5.9% 90%;
|
--input: 240 5.9% 90%;
|
||||||
--ring: 240 10% 3.9%;
|
--ring: 240 10% 3.9%;
|
||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
|
--dashboard-mobile-header-height: 4rem;
|
||||||
|
--dashboard-mobile-header-gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-color-mode="dark"],
|
:root[data-color-mode="dark"],
|
||||||
@@ -127,9 +129,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.dashboard-mobile-header {
|
||||||
|
min-height: var(--dashboard-mobile-header-height);
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-content-shell {
|
.dashboard-content-shell {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-top: 4rem;
|
padding-top: calc(
|
||||||
|
var(--dashboard-mobile-header-height) + var(--dashboard-mobile-header-gap)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -143,7 +151,10 @@
|
|||||||
padding: 1.25rem;
|
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);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user