From ddc2b42672771db428421252b7a06e77cd3aee81 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Wed, 29 Apr 2026 22:49:07 -0400 Subject: [PATCH] Refactor invoice data table and templates page for improved readability and functionality - Cleaned up imports and formatted code for better readability in invoices-data-table.tsx. - Enhanced invoice interface definitions for clarity. - Improved toast messages for bulk delete and update actions. - Refactored date formatting and status type retrieval for better readability. - Simplified template management in templates page, extracting TemplateList component. - Added registration toggle based on environment variable DISABLE_SIGNUPS. - Updated navbar to conditionally render registration link based on allowRegistration prop. - Enhanced error handling and validation in expenses and settings routers. - Improved PDF export footer handling. - Updated TRPC react integration for cleaner type imports. --- README.md | 42 ++- docker-compose.dev.yml | 21 ++ docker-compose.yml | 4 +- package.json | 2 + src/app/api/auth/register/route.ts | 72 ++++- src/app/api/auth/reset-password/route.ts | 45 ++- src/app/auth/register/page.tsx | 3 +- src/app/auth/signin/page.tsx | 285 +--------------- src/app/auth/signin/signin-form.tsx | 303 ++++++++++++++++++ .../_components/invoices-data-table.tsx | 195 ++++++++--- src/app/dashboard/invoices/templates/page.tsx | 259 ++++++++++----- src/app/page.tsx | 54 ++-- src/components/layout/navbar.tsx | 26 +- src/env.js | 6 +- src/lib/auth.ts | 2 + src/lib/pdf-export.tsx | 1 + src/proxy.ts | 6 + src/server/api/routers/expenses.ts | 20 +- src/server/api/routers/settings.ts | 45 ++- src/trpc/react.tsx | 8 +- 20 files changed, 916 insertions(+), 483 deletions(-) create mode 100644 docker-compose.dev.yml create mode 100644 src/app/auth/signin/signin-form.tsx diff --git a/README.md b/README.md index 44b76ef..e8b2fa1 100644 --- a/README.md +++ b/README.md @@ -44,22 +44,26 @@ A modern, professional invoicing application built for freelancers and small bus ### Quick Start 1. **Clone the repository** + ```bash git clone https://github.com/yourusername/beenvoice.git cd beenvoice ``` 2. **Install dependencies** + ```bash bun install ``` 3. **Set up environment variables** + ```bash cp .env.example .env.local ``` Edit `.env.local` and add your configuration: + ```env # Database DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice" @@ -78,17 +82,20 @@ A modern, professional invoicing application built for freelancers and small bus RESEND_DOMAIN="yourdomain.com" ``` -4. **Start the database** +4. **Start the development database** + ```bash - docker-compose up -d + docker compose -f docker-compose.dev.yml up -d db ``` 5. **Push the database schema** + ```bash bun run db:push ``` 6. **Start the development server** + ```bash bun run dev ``` @@ -123,7 +130,8 @@ beenvoice/ ├── drizzle/ # Database migrations ├── public/ # Static assets ├── docs/ # Documentation -└── docker-compose.yml # Local PostgreSQL setup +├── docker-compose.yml # Deployment compose stack +└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL ``` ## 🎯 Usage @@ -155,12 +163,14 @@ beenvoice/ ### Features Overview #### Client Management + - Create and edit client profiles - Store contact information and addresses - Set default hourly rates per client - Search and filter client list #### Invoice Creation + - Select from existing clients and business profiles - Add multiple line items with drag-and-drop reordering - Set custom rates per item @@ -169,12 +179,14 @@ beenvoice/ - Professional invoice formatting #### Invoice Delivery + - Send invoices via email directly from the app - Rich text email composer with preview - Resend and re-deliver sent invoices - Track invoice status: Draft → Sent → Paid (+ Overdue) #### User Interface + - Clean, modern design - Fully responsive — desktop, tablet, and mobile - Intuitive navigation with breadcrumbs @@ -198,7 +210,8 @@ bun run db:studio # Open Drizzle Studio bun run db:generate # Generate new migration # Docker -bun run docker:up # Start local PostgreSQL via Docker +bun run docker:up # Start deployment compose stack +bun run docker:dev:up # Start development compose stack with exposed PostgreSQL bun run docker:down # Stop Docker services # Code Quality @@ -208,6 +221,24 @@ bun run format:write # Format code with Prettier bun run typecheck # Run TypeScript type checking ``` +### Docker Compose + +Use the base compose file for deployment. It keeps PostgreSQL internal to the +compose network: + +```bash +docker compose up -d +``` + +For local development, use the dev compose file to expose PostgreSQL on +`${POSTGRES_PORT:-5432}`: + +```bash +docker compose -f docker-compose.dev.yml up -d +``` + +Set `DISABLE_SIGNUPS=true` to block new email/password account registration. + ### Database Schema The application uses the following core tables: @@ -243,6 +274,7 @@ The app uses Tailwind CSS v4 with a custom design system: ### Branding Update the logo and colors in: + - `src/components/logo.tsx` - Main logo component - `src/styles/globals.css` - Color variables - `src/app/layout.tsx` - Font configuration @@ -252,6 +284,7 @@ Update the logo and colors in: You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.). 1. **Build the application:** + ```bash bun run build ``` @@ -259,6 +292,7 @@ You can deploy this application to any platform that supports Next.js and Postgr 2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production) 3. **Run database migrations:** + ```bash bun run db:push ``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..6d5c0dd --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,21 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-postgres} + volumes: + - beenvoice_dev_pg_data:/var/lib/postgresql/data + healthcheck: + test: + ["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + interval: 5s + timeout: 5s + retries: 10 + ports: + - "${POSTGRES_PORT:-5432}:5432" + restart: unless-stopped + +volumes: + beenvoice_dev_pg_data: diff --git a/docker-compose.yml b/docker-compose.yml index a0ad700..5a95ebb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} + DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} @@ -35,7 +36,8 @@ services: volumes: - beenvoice_pg_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + test: + ["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] interval: 5s timeout: 5s retries: 10 diff --git a/package.json b/package.json index 66c5964..74daf61 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "db:studio": "drizzle-kit studio", "db:clone": "./scripts/clone-local.sh", "docker:up": "colima start && docker compose up -d", + "docker:dev:up": "colima start && docker compose -f docker-compose.dev.yml up -d", "docker:down": "docker compose down && colima stop", + "docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop", "deploy": "drizzle-kit push && next build", "dev": "next dev --turbo", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index bdadcba..0d872c3 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -2,59 +2,97 @@ import bcrypt from "bcryptjs"; import { eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { env } from "~/env"; import { db } from "~/server/db"; -import { users } from "~/server/db/schema"; +import { accounts, users } from "~/server/db/schema"; const registerSchema = z.object({ - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), + firstName: z.string().trim().min(1, "First name is required"), + lastName: z.string().trim().min(1, "Last name is required"), email: z.string().email("Invalid email address"), - password: z.string().min(6, "Password must be at least 6 characters"), + password: z.string().min(8, "Password must be at least 8 characters"), }); +const fieldLabels: Record = { + firstName: "First name", + lastName: "Last name", + email: "Email address", + password: "Password", +}; + export async function POST(request: NextRequest) { try { - const body = await request.json() as z.infer; + if (env.DISABLE_SIGNUPS === true) { + return NextResponse.json( + { error: "New account registration is currently disabled" }, + { status: 403 }, + ); + } + + const body = (await request.json()) as unknown; const { firstName, lastName, email, password } = registerSchema.parse(body); + const normalizedEmail = email.toLowerCase(); // Check if user already exists const existingUser = await db.query.users.findFirst({ - where: eq(users.email, email), + where: eq(users.email, normalizedEmail), }); if (existingUser) { return NextResponse.json( { error: "User with this email already exists" }, - { status: 400 } + { status: 400 }, ); } // Hash password const hashedPassword = await bcrypt.hash(password, 12); - // Create user - await db.insert(users).values({ - name: `${firstName} ${lastName}`, - email, - password: hashedPassword, + await db.transaction(async (tx) => { + const [user] = await tx + .insert(users) + .values({ + name: `${firstName} ${lastName}`, + email: normalizedEmail, + password: hashedPassword, + }) + .returning({ id: users.id }); + + if (!user) { + throw new Error("Failed to create user"); + } + + await tx.insert(accounts).values({ + userId: user.id, + accountId: user.id, + providerId: "credential", + password: hashedPassword, + }); }); return NextResponse.json( { message: "User created successfully" }, - { status: 201 } + { status: 201 }, ); } catch (error) { if (error instanceof z.ZodError) { + const issue = error.errors[0]; + const field = issue?.path[0]; + const fallback = + typeof field === "string" + ? `${fieldLabels[field] ?? field} is required` + : "Please check the registration form"; + return NextResponse.json( - { error: error.errors[0]?.message ?? "Validation error" }, - { status: 400 } + { error: issue?.message === "Required" ? fallback : issue?.message }, + { status: 400 }, ); } console.error("Registration error:", error); return NextResponse.json( { error: "Internal server error" }, - { status: 500 } + { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index a966cc1..7d1f08b 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { eq, and, gt } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { db } from "~/server/db"; -import { users } from "~/server/db/schema"; +import { accounts, users } from "~/server/db/schema"; export async function POST(request: NextRequest) { try { @@ -47,15 +47,40 @@ export async function POST(request: NextRequest) { // Hash the new password const hashedPassword = await bcrypt.hash(password, 12); - // Update user with new password and clear reset token - await db - .update(users) - .set({ - password: hashedPassword, - resetToken: null, - resetTokenExpiry: null, - }) - .where(eq(users.id, user.id)); + await db.transaction(async (tx) => { + await tx + .update(users) + .set({ + password: hashedPassword, + resetToken: null, + resetTokenExpiry: null, + }) + .where(eq(users.id, user.id)); + + const credentialAccount = await tx.query.accounts.findFirst({ + where: and( + eq(accounts.userId, user.id), + eq(accounts.providerId, "credential"), + ), + }); + + if (credentialAccount) { + await tx + .update(accounts) + .set({ + password: hashedPassword, + updatedAt: new Date(), + }) + .where(eq(accounts.id, credentialAccount.id)); + } else { + await tx.insert(accounts).values({ + userId: user.id, + accountId: user.id, + providerId: "credential", + password: hashedPassword, + }); + } + }); return NextResponse.json( { diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 964a740..6cdf8ae 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -28,7 +28,8 @@ function RegisterForm() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - name: `${firstName} ${lastName}`, + firstName, + lastName, email, password, }), diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 27cea9a..138c61a 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -1,290 +1,11 @@ -"use client"; - -import { useState, Suspense } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { authClient } from "~/lib/auth-client"; -import { Card, CardContent } from "~/components/ui/card"; -import { Input } from "~/components/ui/input"; -import { Button } from "~/components/ui/button"; -import { Label } from "~/components/ui/label"; -import { toast } from "sonner"; -import { Logo } from "~/components/branding/logo"; -import { LegalModal } from "~/components/ui/legal-modal"; +import { Suspense } from "react"; import { env } from "~/env"; -import { - Mail, - Lock, - ArrowRight, - Users, - FileText, - TrendingUp, - Shield, -} from "lucide-react"; - -function SignInForm() { - const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; - const router = useRouter(); - const searchParams = useSearchParams(); - const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [loading, setLoading] = useState(false); - - async function handleSignIn(e: React.FormEvent) { - e.preventDefault(); - setLoading(true); - - const { error } = await authClient.signIn.email({ - email, - password, - }); - - setLoading(false); - - if (error) { - toast.error(error.message ?? "Invalid email or password"); - } else { - toast.success("Signed in successfully!"); - router.push(callbackUrl); - router.refresh(); - } - } - - async function handleSocialSignIn() { - setLoading(true); - try { - await authClient.signIn.oauth2({ - providerId: "authentik", - callbackURL: callbackUrl, - }); - // The signIn.sso method will automatically redirect to the SSO provider - } catch (error) { - console.error("[SSO Error]", error); - setLoading(false); - } - } - - return ( -
- {/* Blob Background */} -
-
-
-
- - - - {/* Hero Section - Hidden on mobile */} -
-
-
- -
-

- Welcome back to your - - {" "} - invoicing workspace - -

-

- Continue managing your clients and creating professional - invoices that get you paid faster. -

-
-
- -
-
-
- -
-
-

- Client Management -

-

- Organize and track all your clients in one place -

-
-
- -
-
- -
-
-

- Professional Invoices -

-

- Beautiful templates that get you paid faster -

-
-
- -
-
- -
-
-

- Payment Tracking -

-

- Monitor your income with real-time insights -

-
-
-
-
-
- - {/* Sign In Form */} -
-
- {/* Mobile Logo */} -
- -
- -
-

Sign In

-

- Enter your credentials to access your account -

-
- - {authentikEnabled && ( -
- - -
-
- -
-
- - Or continue with - -
-
-
- )} - -
-
- -
- - setEmail(e.target.value)} - required - autoFocus - className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all" - placeholder="m@example.com" - /> -
-
- -
-
- - - Forgot password? - -
-
- - setPassword(e.target.value)} - required - className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all" - placeholder="Enter your password" - /> -
-
- - -
- -
- Don't have an account?{" "} - - Sign up - -
- -
- By signing in, you agree to our{" "} - - Terms of Service - - } - />{" "} - and{" "} - - Privacy Policy - - } - /> - . -
-
-
-
-
-
- ); -} +import { SignInForm } from "./signin-form"; export default function SignInPage() { return ( Loading...}> - + ); } diff --git a/src/app/auth/signin/signin-form.tsx b/src/app/auth/signin/signin-form.tsx new file mode 100644 index 0000000..5279040 --- /dev/null +++ b/src/app/auth/signin/signin-form.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { authClient } from "~/lib/auth-client"; +import { Card, CardContent } from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Button } from "~/components/ui/button"; +import { Label } from "~/components/ui/label"; +import { toast } from "sonner"; +import { Logo } from "~/components/branding/logo"; +import { LegalModal } from "~/components/ui/legal-modal"; +import { env } from "~/env"; +import { + Mail, + Lock, + ArrowRight, + Users, + FileText, + TrendingUp, + Shield, +} from "lucide-react"; + +interface SignInFormProps { + allowRegistration: boolean; +} + +export function SignInForm({ allowRegistration }: SignInFormProps) { + const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; + const signupDisabled = searchParams.get("signup") === "disabled"; + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSignIn(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + const { error } = await authClient.signIn.email({ + email, + password, + }); + + setLoading(false); + + if (error) { + toast.error(error.message ?? "Invalid email or password"); + } else { + toast.success("Signed in successfully!"); + router.push(callbackUrl); + router.refresh(); + } + } + + async function handleSocialSignIn() { + setLoading(true); + try { + await authClient.signIn.oauth2({ + providerId: "authentik", + callbackURL: callbackUrl, + }); + // The signIn.sso method will automatically redirect to the SSO provider + } catch (error) { + console.error("[SSO Error]", error); + setLoading(false); + } + } + + return ( +
+ {/* Blob Background */} +
+
+
+
+ + + + {/* Hero Section - Hidden on mobile */} +
+
+
+ +
+

+ Welcome back to your + + {" "} + invoicing workspace + +

+

+ Continue managing your clients and creating professional + invoices that get you paid faster. +

+
+
+ +
+
+
+ +
+
+

+ Client Management +

+

+ Organize and track all your clients in one place +

+
+
+ +
+
+ +
+
+

+ Professional Invoices +

+

+ Beautiful templates that get you paid faster +

+
+
+ +
+
+ +
+
+

+ Payment Tracking +

+

+ Monitor your income with real-time insights +

+
+
+
+
+
+ + {/* Sign In Form */} +
+
+ {/* Mobile Logo */} +
+ +
+ +
+

Sign In

+

+ Enter your credentials to access your account +

+
+ + {signupDisabled && ( +
+ New account registration is currently disabled. +
+ )} + + {authentikEnabled && ( +
+ + +
+
+ +
+
+ + Or continue with + +
+
+
+ )} + +
+
+ +
+ + setEmail(e.target.value)} + required + autoFocus + className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all" + placeholder="m@example.com" + /> +
+
+ +
+
+ + + Forgot password? + +
+
+ + setPassword(e.target.value)} + required + className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all" + placeholder="Enter your password" + /> +
+
+ + +
+ + {allowRegistration && ( +
+ Don't have an account?{" "} + + Sign up + +
+ )} + +
+ By signing in, you agree to our{" "} + + Terms of Service + + } + />{" "} + and{" "} + + Privacy Policy + + } + /> + . +
+
+
+
+
+
+ ); +} + +export default function SignInPageClient() { + return ( + Loading...}> + + + ); +} diff --git a/src/app/dashboard/invoices/_components/invoices-data-table.tsx b/src/app/dashboard/invoices/_components/invoices-data-table.tsx index 3867e49..e86f667 100644 --- a/src/app/dashboard/invoices/_components/invoices-data-table.tsx +++ b/src/app/dashboard/invoices/_components/invoices-data-table.tsx @@ -23,7 +23,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; -import { Eye, Edit, Trash2, FileText, CheckCircle, Send, ChevronDown } from "lucide-react"; +import { + Eye, + Edit, + Trash2, + FileText, + CheckCircle, + Send, + ChevronDown, +} from "lucide-react"; import { api } from "~/trpc/react"; import { toast } from "sonner"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; @@ -45,11 +53,28 @@ interface Invoice { createdById: string; createdAt: Date; updatedAt: Date | null; - client?: { id: string; name: string; email: string | null; phone: string | null } | null; - business?: { id: string; name: string; email: string | null; phone: string | null } | null; + client?: { + id: string; + name: string; + email: string | null; + phone: string | null; + } | null; + business?: { + id: string; + name: string; + email: string | null; + phone: string | null; + } | null; items?: Array<{ - id: string; invoiceId: string; date: Date; description: string; - hours: number; rate: number; amount: number; position: number; createdAt: Date; + id: string; + invoiceId: string; + date: Date; + description: string; + hours: number; + rate: number; + amount: number; + position: number; + createdAt: Date; }> | null; } @@ -58,10 +83,17 @@ interface InvoicesDataTableProps { } const getStatusType = (invoice: Invoice): StatusType => - getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType; + getEffectiveInvoiceStatus( + invoice.status as StoredInvoiceStatus, + invoice.dueDate, + ) as StatusType; const formatDate = (date: Date) => - new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date)); + new Intl.DateTimeFormat("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }).format(new Date(date)); export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { const router = useRouter(); @@ -84,7 +116,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { const bulkDelete = api.invoices.bulkDelete.useMutation({ onSuccess: (data) => { - toast.success(`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`); + toast.success( + `${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`, + ); void utils.invoices.getAll.invalidate(); setBulkDeleteDialogOpen(false); setPendingBulkDelete([]); @@ -94,7 +128,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({ onSuccess: (data) => { - toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`); + toast.success( + `${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`, + ); void utils.invoices.getAll.invalidate(); }, onError: (e) => toast.error(e.message ?? "Failed to update invoices"), @@ -105,7 +141,10 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { id: "select", header: ({ table }) => ( table.toggleAllPageRowsSelected(!!v)} aria-label="Select all" data-action-button="true" @@ -124,7 +163,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { }, { accessorKey: "client.name", - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) => { const invoice = row.original; return ( @@ -133,10 +174,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
-

{invoice.client?.name ?? "—"}

-

{invoice.invoiceNumber}

+

+ {invoice.client?.name ?? "—"} +

+

+ {invoice.invoiceNumber} +

- + {formatCurrency(invoice.totalAmount, invoice.currency)} @@ -148,38 +196,59 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { }, { accessorKey: "issueDate", - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) => (
-

{formatDate(row.getValue("issueDate") as Date)}

-

Due {formatDate(new Date(row.original.dueDate))}

+

+ {formatDate(row.getValue("issueDate"))} +

+

+ Due {formatDate(new Date(row.original.dueDate))} +

), }, { accessorKey: "status", - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) => ( ), - filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)), - meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, + filterFn: (row, _id, value: string[]) => + value.includes(getStatusType(row.original)), + meta: { + headerClassName: "hidden sm:table-cell", + cellClassName: "hidden sm:table-cell", + }, }, { accessorKey: "totalAmount", - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) => (

- {formatCurrency(row.getValue("totalAmount") as number, row.original.currency)} + {formatCurrency(row.getValue("totalAmount"), row.original.currency)} +

+

+ {row.original.items?.length ?? 0} items

-

{row.original.items?.length ?? 0} items

), - meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, + meta: { + headerClassName: "hidden sm:table-cell", + cellClassName: "hidden sm:table-cell", + }, }, { id: "actions", @@ -188,19 +257,34 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { return (
- - diff --git a/src/app/dashboard/invoices/templates/page.tsx b/src/app/dashboard/invoices/templates/page.tsx index 1e901aa..3b0cdbc 100644 --- a/src/app/dashboard/invoices/templates/page.tsx +++ b/src/app/dashboard/invoices/templates/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { api } from "~/trpc/react"; +import { api, type RouterOutputs } from "~/trpc/react"; import { PageHeader } from "~/components/layout/page-header"; import { Button } from "~/components/ui/button"; import { Card, CardContent } from "~/components/ui/card"; @@ -18,12 +18,7 @@ import { DialogHeader, DialogTitle, } from "~/components/ui/dialog"; -import { - Tabs, - TabsList, - TabsTrigger, - TabsContent, -} from "~/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; import { toast } from "sonner"; import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react"; @@ -34,87 +29,81 @@ interface TemplateForm { isDefault: boolean; } -const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false }; +const defaultForm: TemplateForm = { + name: "", + type: "notes", + content: "", + isDefault: false, +}; -export default function TemplatesPage() { - const [open, setOpen] = useState(false); - const [editId, setEditId] = useState(null); - const [form, setForm] = useState(defaultForm); - const [deleteId, setDeleteId] = useState(null); - const [tab, setTab] = useState<"notes" | "terms">("notes"); +type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number]; - const utils = api.useUtils(); - const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery(); +interface TemplateListProps { + items: InvoiceTemplate[]; + type: "notes" | "terms"; + isLoading: boolean; + onCreate: (type: "notes" | "terms") => void; + onEdit: (template: InvoiceTemplate) => void; + onDelete: (id: string) => void; +} - const create = api.invoiceTemplates.create.useMutation({ - onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); }, - onError: (e) => toast.error(e.message), - }); - const update = api.invoiceTemplates.update.useMutation({ - onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); }, - onError: (e) => toast.error(e.message), - }); - const del = api.invoiceTemplates.delete.useMutation({ - onSuccess: () => { toast.success("Template deleted"); void utils.invoiceTemplates.getAll.invalidate(); setDeleteId(null); }, - onError: (e) => toast.error(e.message), - }); - - const handleOpen = (type: "notes" | "terms") => { - setEditId(null); - setForm({ ...defaultForm, type }); - setOpen(true); - }; - const handleEdit = (t: typeof templates[0]) => { - setEditId(t.id); - setForm({ name: t.name, type: t.type as "notes" | "terms", content: t.content, isDefault: t.isDefault }); - setOpen(true); - }; - const handleSubmit = () => { - if (!form.name.trim()) { toast.error("Name is required"); return; } - if (!form.content.trim()) { toast.error("Content is required"); return; } - if (editId) update.mutate({ id: editId, ...form }); - else create.mutate(form); - }; - - const notesTemplates = templates.filter((t) => t.type === "notes"); - const termsTemplates = templates.filter((t) => t.type === "terms"); - - const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => ( +function TemplateList({ + items, + type, + isLoading, + onCreate, + onEdit, + onDelete, +}: TemplateListProps) { + return (
-
{isLoading ? ( -
Loading…
+
+ Loading... +
) : items.length === 0 ? (
No {type} templates yet.
) : ( - items.map((t) => ( - + items.map((template) => ( +
-

{t.name}

- {t.isDefault && ( +

{template.name}

+ {template.isDefault && ( Default )}

- {t.content} + {template.content}

- -
@@ -125,6 +114,77 @@ export default function TemplatesPage() { )}
); +} + +export default function TemplatesPage() { + const [open, setOpen] = useState(false); + const [editId, setEditId] = useState(null); + const [form, setForm] = useState(defaultForm); + const [deleteId, setDeleteId] = useState(null); + const [tab, setTab] = useState<"notes" | "terms">("notes"); + + const utils = api.useUtils(); + const { data: templates = [], isLoading } = + api.invoiceTemplates.getAll.useQuery(); + + const create = api.invoiceTemplates.create.useMutation({ + onSuccess: () => { + toast.success("Template created"); + void utils.invoiceTemplates.getAll.invalidate(); + setOpen(false); + setForm(defaultForm); + }, + onError: (e) => toast.error(e.message), + }); + const update = api.invoiceTemplates.update.useMutation({ + onSuccess: () => { + toast.success("Template updated"); + void utils.invoiceTemplates.getAll.invalidate(); + setOpen(false); + setEditId(null); + setForm(defaultForm); + }, + onError: (e) => toast.error(e.message), + }); + const del = api.invoiceTemplates.delete.useMutation({ + onSuccess: () => { + toast.success("Template deleted"); + void utils.invoiceTemplates.getAll.invalidate(); + setDeleteId(null); + }, + onError: (e) => toast.error(e.message), + }); + + const handleOpen = (type: "notes" | "terms") => { + setEditId(null); + setForm({ ...defaultForm, type }); + setOpen(true); + }; + const handleEdit = (t: InvoiceTemplate) => { + setEditId(t.id); + setForm({ + name: t.name, + type: t.type as "notes" | "terms", + content: t.content, + isDefault: t.isDefault, + }); + setOpen(true); + }; + const handleSubmit = () => { + if (!form.name.trim()) { + toast.error("Name is required"); + return; + } + if (!form.content.trim()) { + toast.error("Content is required"); + return; + } + if (editId) update.mutate({ id: editId, ...form }); + else create.mutate(form); + }; + + const notesTemplates = templates.filter((t) => t.type === "notes"); + const termsTemplates = templates.filter((t) => t.type === "terms"); return (
@@ -137,17 +197,33 @@ export default function TemplatesPage() { setTab(v as "notes" | "terms")}> - Notes ({notesTemplates.length}) + Notes ( + {notesTemplates.length}) - Terms ({termsTemplates.length}) + Terms ( + {termsTemplates.length}) - + - + @@ -155,16 +231,29 @@ export default function TemplatesPage() { - {editId ? "Edit Template" : "New Template"} + + {editId ? "Edit Template" : "New Template"} +
- setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" /> + + setForm((p) => ({ ...p, name: e.target.value })) + } + placeholder="e.g. Standard Payment Terms" + />
- setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}> + + setForm((p) => ({ ...p, type: v as "notes" | "terms" })) + } + > Notes Terms @@ -175,20 +264,36 @@ export default function TemplatesPage() {