feat: implement complete invoicing application with CSV import and PDF export

- Add comprehensive CSV import system with drag-and-drop upload and validation
- Create UniversalTable component with advanced filtering, searching, and batch actions
- Implement invoice management (view, edit, delete) with professional PDF export
- Add client management with full CRUD operations
- Set up authentication with NextAuth.js and email/password login
- Configure database schema with users, clients, invoices, and invoice_items tables
- Build responsive UI with shadcn/ui components and emerald branding
- Add type-safe API layer with tRPC and Zod validation
- Include proper error handling and user feedback with toast notifications
- Set up development environment with Bun, TypeScript, and Tailwind CSS
This commit is contained in:
2025-07-10 04:07:19 -04:00
commit 2d217fab47
85 changed files with 17074 additions and 0 deletions

129
.cursorrules Normal file
View File

@@ -0,0 +1,129 @@
# beenvoice - AI Assistant Guidelines
## Project Overview
beenvoice is a modern invoicing application built with the T3 stack (Next.js 15, tRPC, Drizzle/LibSQL, NextAuth.js) and shadcn/ui components. The app allows users to create and manage clients and invoices with a professional, clean interface.
## Tech Stack & Architecture
### Core Technologies
- **Frontend**: Next.js 15 with App Router
- **Backend**: tRPC for type-safe API calls
- **Database**: Drizzle ORM with LibSQL (SQLite)
- **Authentication**: NextAuth.js with email/password (Credentials provider)
- **UI**: shadcn/ui components with Tailwind CSS
- **Styling**: Geist font family for professional typography
- **Package Manager**: Bun (with npm fallback)
### Project Structure
```
src/
├── app/ # Next.js App Router pages
│ ├── api/ # API routes (NextAuth, tRPC)
│ ├── auth/ # Authentication pages
│ ├── clients/ # Client management pages
│ ├── invoices/ # Invoice management pages
│ └── _components/ # Page-specific components
├── components/ # Shared UI components
├── server/ # Server-side code
│ ├── api/ # tRPC routers
│ ├── auth/ # NextAuth configuration
│ └── db/ # Database schema and connection
├── styles/ # Global styles
└── trpc/ # tRPC client configuration
```
## Development Guidelines
### Code Style & Conventions
- Use TypeScript for all new code
- Follow the existing component patterns with shadcn/ui
- Use the `cn()` utility for conditional className merging
- Implement proper error handling with toast notifications
- Use tRPC for all API calls (no direct fetch calls)
### Database Schema
The app uses three main tables:
- **users**: User accounts with email/password authentication
- **clients**: Client information (name, email, phone, address)
- **invoices**: Invoice headers with client relationships
- **invoice_items**: Individual line items with dates, descriptions, hours, rates
### Authentication Flow
- Email/password registration and sign-in
- Password hashing with bcrypt
- Session management via NextAuth.js
- Protected routes require authentication
### UI/UX Principles
- Clean, professional design suitable for business use
- Responsive design that works on all devices
- Consistent spacing and typography using Geist font
- Green color scheme (#16a34a) for branding
- Toast notifications for user feedback
- Modal dialogs for forms and confirmations
### Component Guidelines
- Use shadcn/ui components as the foundation
- Create reusable components in `src/components/`
- Page-specific components go in `src/app/_components/`
- Follow the existing Logo component pattern for branding
### API Development
- All API logic goes through tRPC routers
- Use Zod for input validation
- Implement proper error handling
- Follow the existing router patterns in `src/server/api/routers/`
### Database Operations
- Use Drizzle ORM for all database operations
- Follow the existing schema patterns
- Implement proper relationships between tables
- Use transactions for multi-table operations
## Common Tasks & Patterns
### Adding New Features
1. Create tRPC router procedures
2. Add validation with Zod schemas
3. Create UI components using shadcn/ui
4. Add proper error handling and user feedback
5. Update navigation if needed
### Styling Guidelines
- Use Tailwind CSS classes
- Follow the existing color scheme
- Use Geist font family
- Maintain consistent spacing (4px grid system)
- Use the existing component patterns
### Error Handling
- Use toast notifications for user feedback
- Implement proper form validation
- Handle API errors gracefully
- Provide clear error messages
## File Naming Conventions
- Components: PascalCase (e.g., `ClientList.tsx`)
- Pages: kebab-case (e.g., `new-client.tsx`)
- Utilities: camelCase (e.g., `formatCurrency.ts`)
- Constants: UPPER_SNAKE_CASE
## Testing Considerations
- Ensure all forms have proper validation
- Test responsive design on different screen sizes
- Verify authentication flows work correctly
- Test database operations with proper error handling
## Performance Guidelines
- Use Next.js Image component for images
- Implement proper loading states
- Optimize database queries
- Use React.memo for expensive components when needed
## Security Considerations
- Always validate user input
- Use proper authentication checks
- Sanitize data before database operations
- Follow NextAuth.js security best practices
Remember: This is a business application, so prioritize reliability, security, and professional user experience over flashy features.

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Next Auth
# You can generate a new secret on the command line with:
# npx auth secret
# https://next-auth.js.org/configuration/options#secret
AUTH_SECRET=""
# Next Auth Discord Provider
AUTH_DISCORD_ID=""
AUTH_DISCORD_SECRET=""
# Drizzle
DATABASE_URL="file:./db.sqlite"

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# idea files
.idea

262
README.md Normal file
View File

@@ -0,0 +1,262 @@
# beenvoice - Invoicing Made Simple
A modern, professional invoicing application built for freelancers and small businesses. beenvoice provides a clean, efficient way to manage clients and create professional invoices with ease.
![beenvoice Logo](https://img.shields.io/badge/beenvoice-Invoicing%20Made%20Simple-green?style=for-the-badge)
## ✨ Features
- **🔐 Secure Authentication** - Email/password registration and sign-in with NextAuth.js
- **👥 Client Management** - Create, edit, and manage client information
- **📄 Professional Invoices** - Generate detailed invoices with line items
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
- **💾 Local Database** - SQLite database with Drizzle ORM
## 🚀 Tech Stack
- **Frontend**: Next.js 15 with App Router
- **Backend**: tRPC for type-safe API calls
- **Database**: Drizzle ORM with LibSQL (SQLite)
- **Authentication**: NextAuth.js with email/password
- **UI Components**: shadcn/ui with Tailwind CSS
- **Styling**: Geist font family
- **Package Manager**: Bun (with npm fallback)
## 📦 Installation
### Prerequisites
- Node.js 18+ or Bun
- Git
### Quick Start
1. **Clone the repository**
```bash
git clone https://github.com/yourusername/beenvoice.git
cd beenvoice
```
2. **Install dependencies**
```bash
# Using Bun (recommended)
bun install
# Or using npm
npm install
```
3. **Set up environment variables**
```bash
cp .env.example .env.local
```
Edit `.env.local` and add your configuration:
```env
DATABASE_URL="file:./db.sqlite"
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"
```
4. **Initialize the database**
```bash
bun run db:push
```
5. **Start the development server**
```bash
bun run dev
```
6. **Open your browser**
Navigate to [http://localhost:3000](http://localhost:3000)
## 🏗️ Project Structure
```
beenvoice/
├── src/
│ ├── app/ # Next.js App Router pages
│ │ ├── api/ # API routes (NextAuth, tRPC)
│ │ ├── auth/ # Authentication pages
│ │ ├── clients/ # Client management pages
│ │ ├── invoices/ # Invoice management pages
│ │ └── _components/ # Page-specific components
│ ├── components/ # Shared UI components
│ ├── server/ # Server-side code
│ │ ├── api/ # tRPC routers
│ │ ├── auth/ # NextAuth configuration
│ │ └── db/ # Database schema and connection
│ ├── styles/ # Global styles
│ └── trpc/ # tRPC client configuration
├── drizzle/ # Database migrations
├── public/ # Static assets
└── docs/ # Documentation
```
## 🎯 Usage
### Getting Started
1. **Register an Account**
- Visit the sign-up page
- Enter your name, email, and password
- Verify your email (if configured)
2. **Add Your First Client**
- Navigate to the Clients page
- Click "Add New Client"
- Fill in client details (name, email, phone, address)
3. **Create an Invoice**
- Go to the Invoices page
- Click "Create New Invoice"
- Select a client
- Add line items with descriptions, dates, hours, and rates
- Save and generate your invoice
### Features Overview
#### Client Management
- Create and edit client profiles
- Store contact information and addresses
- Search and filter client list
- View client history
#### Invoice Creation
- Select from existing clients
- Add multiple line items
- Set custom rates per item
- Automatic total calculations
- Professional invoice formatting
#### User Interface
- Clean, modern design
- Responsive layout
- Intuitive navigation
- Toast notifications for feedback
- Modal dialogs for forms
## 🔧 Development
### Available Scripts
```bash
# Development
bun run dev # Start development server
bun run build # Build for production
bun run start # Start production server
# Database
bun run db:push # Push schema changes to database
bun run db:studio # Open Drizzle Studio
bun run db:generate # Generate new migration
# Code Quality
bun run lint # Run ESLint
bun run format # Format code with Prettier
bun run type-check # Run TypeScript type checking
```
### Database Schema
The application uses four main tables:
- **users**: User accounts and authentication
- **clients**: Client information and contact details
- **invoices**: Invoice headers with client relationships
- **invoice_items**: Individual line items with pricing
### API Development
All API endpoints are built with tRPC for type safety:
- **Authentication**: NextAuth.js integration
- **Clients**: CRUD operations for client management
- **Invoices**: Invoice creation and management
- **Validation**: Zod schemas for input validation
## 🎨 Customization
### Styling
The app uses Tailwind CSS with a custom design system:
- **Primary Color**: Green (#16a34a)
- **Font**: Geist for professional typography
- **Components**: shadcn/ui component library
- **Spacing**: 4px grid 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
## 🚀 Deployment
### Vercel (Recommended)
1. Push your code to GitHub
2. Connect your repository to Vercel
3. Set environment variables in Vercel dashboard
4. Deploy automatically on push
### Other Platforms
The app can be deployed to any platform that supports Next.js:
- **Netlify**: Use the Next.js build command
- **Railway**: Connect your GitHub repository
- **DigitalOcean App Platform**: Deploy with automatic scaling
### Environment Variables
Required for production:
```env
DATABASE_URL="your-database-url"
NEXTAUTH_SECRET="your-secret-key"
NEXTAUTH_URL="https://your-domain.com"
```
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Development Guidelines
- Follow TypeScript best practices
- Use shadcn/ui components for consistency
- Implement proper error handling
- Add tests for new features
- Follow the existing code style
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
- [NextAuth.js](https://next-auth.js.org/) for authentication
- [Drizzle ORM](https://orm.drizzle.team/) for database management
## 📞 Support
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
- **Email**: support@beenvoice.com
---
Built with ❤️ for freelancers and small businesses who deserve better invoicing tools.

1298
bun.lock Normal file

File diff suppressed because it is too large Load Diff

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}

12
drizzle.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { type Config } from "drizzle-kit";
import { env } from "~/env";
export default {
schema: "./src/server/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: env.DATABASE_URL,
},
tablesFilter: ["beenvoice_*"],
} satisfies Config;

61
eslint.config.js Normal file
View File

@@ -0,0 +1,61 @@
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
// @ts-ignore -- no types for this plugin
import drizzle from "eslint-plugin-drizzle";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config(
{
ignores: [".next"],
},
...compat.extends("next/core-web-vitals"),
{
files: ["**/*.ts", "**/*.tsx"],
plugins: {
drizzle,
},
extends: [
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
rules: {
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"drizzle/enforce-delete-with-where": [
"error",
{ drizzleObjectName: ["db", "ctx.db"] },
],
"drizzle/enforce-update-with-where": [
"error",
{ drizzleObjectName: ["db", "ctx.db"] },
],
},
},
{
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
parserOptions: {
projectService: true,
},
},
},
);

10
next.config.js Normal file
View File

@@ -0,0 +1,10 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {};
export default config;

6809
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

88
package.json Normal file
View File

@@ -0,0 +1,88 @@
{
"name": "beenvoice",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "next lint && tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
"lint:fix": "next lint --fix",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.7.2",
"@libsql/client": "^0.14.0",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.1",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"next": "^15.2.3",
"next-auth": "5.0.0-beta.25",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.8",
"server-only": "^0.0.1",
"sonner": "^2.0.6",
"superjson": "^2.2.1",
"tailwind-merge": "^3.3.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"drizzle-kit": "^0.30.5",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.15",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"trustedDependencies": [
"@tailwindcss/oxide",
"core-js",
"unrs-resolver"
]
}

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

4
prettier.config.js Normal file
View File

@@ -0,0 +1,4 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
export default {
plugins: ["prettier-plugin-tailwindcss"],
};

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,3 @@
import { handlers } from "~/server/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,60 @@
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "~/server/db";
import { 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"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json() as z.infer<typeof registerSchema>;
const { firstName, lastName, email, password } = registerSchema.parse(body);
// Check if user already exists
const existingUser = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (existingUser) {
return NextResponse.json(
{ error: "User with this email already exists" },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
await db.insert(users).values({
name: `${firstName} ${lastName}`,
email,
password: hashedPassword,
});
return NextResponse.json(
{ message: "User created successfully" },
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? "Validation error" },
{ status: 400 }
);
}
console.error("Registration error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,34 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";
import { env } from "~/env";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g. when you make requests from Client Components).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,161 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } 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/logo";
import { User, Mail, Lock, ArrowRight } from "lucide-react";
export default function RegisterPage() {
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName,
lastName,
email,
password
}),
});
setLoading(false);
if (res.ok) {
toast.success("Account created successfully! Please sign in.");
router.push("/auth/signin");
} else {
const error = await res.text();
toast.error(error || "Failed to create account");
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo and Welcome */}
<div className="text-center space-y-4">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="text-2xl font-bold text-gray-900">Join beenvoice</h1>
<p className="text-gray-600 mt-2">Create your account to get started</p>
</div>
</div>
{/* Registration Form */}
<Card className="shadow-xl border-0">
<CardHeader className="space-y-1">
<CardTitle className="text-xl text-center">Create Account</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="firstName"
type="text"
value={firstName}
onChange={e => setFirstName(e.target.value)}
required
autoFocus
className="pl-10"
placeholder="First name"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="lastName"
type="text"
value={lastName}
onChange={e => setLastName(e.target.value)}
required
className="pl-10"
placeholder="Last name"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
className="pl-10"
placeholder="Enter your email"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
minLength={6}
className="pl-10"
placeholder="Create a password"
/>
</div>
<p className="text-xs text-gray-500">Must be at least 6 characters</p>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
"Creating account..."
) : (
<>
Create Account
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<div className="mt-6 text-center text-sm">
<span className="text-gray-600">Already have an account? </span>
<Link href="/auth/signin" className="text-green-600 hover:text-green-700 font-medium">
Sign in here
</Link>
</div>
</CardContent>
</Card>
{/* Features */}
<div className="text-center space-y-4">
<p className="text-sm text-gray-500">Start invoicing like a pro</p>
<div className="flex justify-center space-x-6 text-xs text-gray-400">
<span> Free to start</span>
<span> No credit card</span>
<span> Cancel anytime</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle } 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/logo";
import { Mail, Lock, ArrowRight } from "lucide-react";
export default function SignInPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
setLoading(false);
if (result?.error) {
toast.error("Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push("/dashboard");
router.refresh();
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo and Welcome */}
<div className="text-center space-y-4">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
<p className="text-gray-600 mt-2">Sign in to your beenvoice account</p>
</div>
</div>
{/* Sign In Form */}
<Card className="shadow-xl border-0">
<CardHeader className="space-y-1">
<CardTitle className="text-xl text-center">Sign In</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoFocus
className="pl-10"
placeholder="Enter your email"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
className="pl-10"
placeholder="Enter your password"
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
"Signing in..."
) : (
<>
Sign In
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<div className="mt-6 text-center text-sm">
<span className="text-gray-600">Don&apos;t have an account? </span>
<Link href="/auth/register" className="text-green-600 hover:text-green-700 font-medium">
Create one now
</Link>
</div>
</CardContent>
</Card>
{/* Features */}
<div className="text-center space-y-4">
<p className="text-sm text-gray-500">Simple invoicing for freelancers and small businesses</p>
<div className="flex justify-center space-x-6 text-xs text-gray-400">
<span> Easy client management</span>
<span> Professional invoices</span>
<span> Payment tracking</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { auth } from "~/server/auth";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { ClientForm } from "~/components/client-form";
import Link from "next/link";
interface EditClientPageProps {
params: Promise<{ id: string }>;
}
export default async function EditClientPage({ params }: EditClientPageProps) {
const session = await auth();
const { id } = await params;
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to edit clients</p>
<Link href="/api/auth/signin">
<Button
size="lg"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
Sign In
</Button>
</Link>
</div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Edit Client</h1>
<p className="text-gray-600 mt-1 text-lg">Update client information below.</p>
</div>
<HydrateClient>
<ClientForm mode="edit" clientId={id} />
</HydrateClient>
</div>
);
}

View File

@@ -0,0 +1,203 @@
import { notFound } from "next/navigation";
import { api } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import Link from "next/link";
import { Edit, Mail, Phone, MapPin, Building, Calendar, DollarSign } from "lucide-react";
interface ClientDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function ClientDetailPage({ params }: ClientDetailPageProps) {
const { id } = await params;
const client = await api.clients.getById({ id });
if (!client) {
notFound();
}
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const totalInvoiced = client.invoices?.reduce((sum, invoice) => sum + invoice.totalAmount, 0) || 0;
const paidInvoices = client.invoices?.filter(invoice => invoice.status === "paid").length || 0;
const pendingInvoices = client.invoices?.filter(invoice => invoice.status === "sent").length || 0;
return (
<div className="p-4 md:p-6 md:ml-72 md:mr-4">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
{client.name}
</h1>
<p className="text-muted-foreground">Client Details</p>
</div>
<Link href={`/clients/${client.id}/edit`}>
<Button className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
<Edit className="mr-2 h-4 w-4" />
Edit Client
</Button>
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Client Information Card */}
<div className="lg:col-span-2">
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center space-x-2 text-emerald-700">
<Building className="h-5 w-5" />
<span>Contact Information</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{client.email && (
<div className="flex items-center space-x-3">
<div className="p-2 bg-emerald-100 rounded-lg">
<Mail className="h-4 w-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Email</p>
<p className="text-sm">{client.email}</p>
</div>
</div>
)}
{client.phone && (
<div className="flex items-center space-x-3">
<div className="p-2 bg-emerald-100 rounded-lg">
<Phone className="h-4 w-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Phone</p>
<p className="text-sm">{client.phone}</p>
</div>
</div>
)}
</div>
{/* Address */}
{(client.addressLine1 ?? client.city ?? client.state) && (
<div className="space-y-4">
<div className="flex items-center space-x-3">
<div className="p-2 bg-emerald-100 rounded-lg">
<MapPin className="h-4 w-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Address</p>
</div>
</div>
<div className="ml-11 space-y-1 text-sm">
{client.addressLine1 && <p>{client.addressLine1}</p>}
{client.addressLine2 && <p>{client.addressLine2}</p>}
{(client.city ?? client.state ?? client.postalCode) && (
<p>
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
</p>
)}
{client.country && <p>{client.country}</p>}
</div>
</div>
)}
{/* Client Since */}
<div className="flex items-center space-x-3">
<div className="p-2 bg-emerald-100 rounded-lg">
<Calendar className="h-4 w-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Client Since</p>
<p className="text-sm">{formatDate(client.createdAt)}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Stats Card */}
<div className="space-y-6">
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center space-x-2 text-emerald-700">
<DollarSign className="h-5 w-5" />
<span>Invoice Summary</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<p className="text-2xl font-bold text-emerald-600">
{formatCurrency(totalInvoiced)}
</p>
<p className="text-sm text-gray-500">Total Invoiced</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<p className="text-lg font-semibold text-green-600">{paidInvoices}</p>
<p className="text-xs text-gray-500">Paid</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-orange-600">{pendingInvoices}</p>
<p className="text-xs text-gray-500">Pending</p>
</div>
</div>
</CardContent>
</Card>
{/* Recent Invoices */}
{client.invoices && client.invoices.length > 0 && (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg">Recent Invoices</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{client.invoices.slice(0, 3).map((invoice) => (
<div key={invoice.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p className="font-medium text-sm">{invoice.invoiceNumber}</p>
<p className="text-xs text-gray-500">{formatDate(invoice.issueDate)}</p>
</div>
<div className="text-right">
<p className="font-medium text-sm">{formatCurrency(invoice.totalAmount)}</p>
<Badge
variant={
invoice.status === "paid" ? "default" :
invoice.status === "sent" ? "secondary" : "outline"
}
className="text-xs"
>
{invoice.status}
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { auth } from "~/server/auth";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { ClientForm } from "~/components/client-form";
import Link from "next/link";
export default async function NewClientPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to create clients</p>
<Link href="/api/auth/signin">
<Button
size="lg"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
Sign In
</Button>
</Link>
</div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Add Client</h1>
<p className="text-gray-600 mt-1 text-lg">Enter client details below to add a new client.</p>
</div>
<HydrateClient>
<ClientForm mode="create" />
</HydrateClient>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import Link from "next/link";
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { UniversalTable } from "~/components/ui/universal-table";
import { Plus } from "lucide-react";
export default async function ClientsPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to view clients</p>
<Link href="/api/auth/signin">
<Button
size="lg"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
Sign In
</Button>
</Link>
</div>
</div>
);
}
// Prefetch clients data
void api.clients.getAll.prefetch();
return (
<div>
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Clients</h1>
<p className="text-gray-600 mt-1 text-lg">Manage your clients and their information.</p>
</div>
<Button asChild size="lg" className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl">
<Link href="/dashboard/clients/new">
<Plus className="mr-2 h-5 w-5" /> Add Client
</Link>
</Button>
</div>
<HydrateClient>
<UniversalTable resource="clients" />
</HydrateClient>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { InvoiceForm } from "~/components/invoice-form";
import Link from "next/link";
import { notFound } from "next/navigation";
interface EditInvoicePageProps {
params: Promise<{ id: string }>;
}
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
const session = await auth();
const { id } = await params;
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to edit invoices</p>
<Link href="/api/auth/signin">
<Button
size="lg"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
Sign In
</Button>
</Link>
</div>
</div>
);
}
// Prefetch invoice data
try {
await api.invoices.getById.prefetch({ id: id });
} catch (error) {
notFound();
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Edit Invoice</h1>
<p className="text-gray-600 mt-1 text-lg">Update the invoice details below.</p>
</div>
<HydrateClient>
<InvoiceForm invoiceId={id} />
</HydrateClient>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { InvoiceView } from "~/components/invoice-view";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Edit } from "lucide-react";
interface InvoicePageProps {
params: Promise<{ id: string }>;
}
export default async function InvoicePage({ params }: InvoicePageProps) {
const session = await auth();
const { id } = await params;
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to view invoices</p>
<Link href="/api/auth/signin">
<Button
size="lg"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
Sign In
</Button>
</Link>
</div>
</div>
);
}
// Prefetch invoice data
try {
await api.invoices.getById.prefetch({ id: id });
} catch (error) {
notFound();
}
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 gap-4">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Invoice Details</h1>
<p className="text-gray-600 mt-1 text-lg">View and manage invoice information.</p>
</div>
<div className="flex gap-3">
<Button asChild variant="outline" size="lg" className="bg-white/80 border-gray-200 hover:bg-gray-50 text-gray-700 font-medium shadow-lg hover:shadow-xl">
<Link href={`/dashboard/invoices/${id}/edit`}>
<Edit className="mr-2 h-5 w-5" /> Edit Invoice
</Link>
</Button>
</div>
</div>
<HydrateClient>
<InvoiceView invoiceId={id} />
</HydrateClient>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import Link from "next/link";
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { CSVImportPage } from "~/components/csv-import-page";
export default async function ImportPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to import invoices</p>
<Link href="/api/auth/signin">
<Button
size="lg"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
Sign In
</Button>
</Link>
</div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Import Invoices</h1>
<p className="text-gray-600 mt-1 text-lg">Upload CSV files to create invoices in batch.</p>
</div>
<HydrateClient>
<CSVImportPage />
</HydrateClient>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { auth } from "~/server/auth";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { InvoiceForm } from "~/components/invoice-form";
export default async function NewInvoicePage() {
const session = await auth();
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to create invoices</p>
<Link href="/api/auth/signin">
<Button
size="lg"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
Sign In
</Button>
</Link>
</div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Create Invoice</h1>
<p className="text-gray-600 mt-1 text-lg">Fill out the details below to create a new invoice.</p>
</div>
<HydrateClient>
<InvoiceForm />
</HydrateClient>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import Link from "next/link";
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { UniversalTable } from "~/components/ui/universal-table";
import { Plus, Upload } from "lucide-react";
export default async function InvoicesPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to view invoices</p>
<Link href="/api/auth/signin">
<Button
size="lg"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
Sign In
</Button>
</Link>
</div>
</div>
);
}
// Prefetch invoices data
void api.invoices.getAll.prefetch();
return (
<div>
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Invoices</h1>
<p className="text-gray-600 mt-1 text-lg">Manage your invoices and payments.</p>
</div>
<div className="flex gap-3">
<Button asChild variant="outline" size="lg" className="bg-white/80 border-gray-200 hover:bg-gray-50 text-gray-700 font-medium shadow-lg hover:shadow-xl">
<Link href="/dashboard/invoices/import">
<Upload className="mr-2 h-5 w-5" /> Import CSV
</Link>
</Button>
<Button asChild size="lg" className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" /> Add Invoice
</Link>
</Button>
</div>
</div>
<HydrateClient>
<UniversalTable resource="invoices" />
</HydrateClient>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Navbar } from "~/components/Navbar";
import { Sidebar } from "~/components/Sidebar";
import { DashboardBreadcrumbs } from "~/components/dashboard-breadcrumbs";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Navbar />
<Sidebar />
<main className="min-h-screen pt-24 ml-70">
<div className="px-8 pt-6 pb-6">
<DashboardBreadcrumbs />
{children}
</div>
</main>
</>
);
}

191
src/app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,191 @@
import { redirect } from "next/navigation";
import { auth } from "~/server/auth";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import {
Users,
FileText,
TrendingUp,
Calendar,
Plus,
ArrowRight
} from "lucide-react";
import Link from "next/link";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/auth/signin");
}
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
Welcome back, {session.user.name?.split(" ")[0] ?? "User"}!
</h1>
<p className="text-gray-600 mt-2 text-lg">
Here&apos;s what&apos;s happening with your invoicing business
</p>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-700">Total Clients</CardTitle>
<div className="p-2 bg-emerald-100 rounded-lg">
<Users className="h-4 w-4 text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-emerald-600">0</div>
<p className="text-xs text-gray-500">
+0 from last month
</p>
</CardContent>
</Card>
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-700">Total Invoices</CardTitle>
<div className="p-2 bg-blue-100 rounded-lg">
<FileText className="h-4 w-4 text-blue-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-blue-600">0</div>
<p className="text-xs text-gray-500">
+0 from last month
</p>
</CardContent>
</Card>
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-700">Revenue</CardTitle>
<div className="p-2 bg-teal-100 rounded-lg">
<TrendingUp className="h-4 w-4 text-teal-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-teal-600">$0</div>
<p className="text-xs text-gray-500">
+0% from last month
</p>
</CardContent>
</Card>
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-700">Pending Invoices</CardTitle>
<div className="p-2 bg-orange-100 rounded-lg">
<Calendar className="h-4 w-4 text-orange-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-orange-600">0</div>
<p className="text-xs text-gray-500">
Due this month
</p>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-700">
<div className="p-2 bg-emerald-100 rounded-lg">
<Users className="h-5 w-5" />
</div>
Manage Clients
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-gray-600">
Add new clients and manage your existing client relationships.
</p>
<div className="flex gap-3">
<Button
asChild
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
<Link href="/dashboard/clients/new">
<Plus className="mr-2 h-4 w-4" />
Add Client
</Link>
</Button>
<Button
variant="outline"
asChild
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
>
<Link href="/dashboard/clients">
View All Clients
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-700">
<div className="p-2 bg-emerald-100 rounded-lg">
<FileText className="h-5 w-5" />
</div>
Create Invoices
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-gray-600">
Generate professional invoices and track payments.
</p>
<div className="flex gap-3">
<Button
asChild
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
New Invoice
</Link>
</Button>
<Button
variant="outline"
asChild
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
>
<Link href="/dashboard/invoices">
View All Invoices
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Recent Activity */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-emerald-700">Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-12 text-gray-500">
<div className="p-4 bg-gray-100 rounded-full w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<FileText className="h-8 w-8 text-gray-400" />
</div>
<p className="text-lg font-medium mb-2">No recent activity</p>
<p className="text-sm">Start by adding your first client or creating an invoice</p>
</div>
</CardContent>
</Card>
</div>
);
}

31
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,31 @@
import "~/styles/globals.css";
import { type Metadata } from "next";
import { Geist } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react";
import { Toaster } from "~/components/ui/toaster";
export const metadata: Metadata = {
title: "beenvoice - Invoicing Made Simple",
description: "Simple and efficient invoicing for freelancers and small businesses",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable}`}>
<body className="relative min-h-screen font-sans antialiased overflow-x-hidden bg-gradient-to-br from-emerald-100 via-white via-60% to-teal-100 before:content-[''] before:fixed before:inset-0 before:z-0 before:pointer-events-none before:bg-[radial-gradient(ellipse_at_80%_0%,rgba(16,185,129,0.10)_0%,transparent_60%)]">
<TRPCReactProvider>{children}</TRPCReactProvider>
<Toaster />
</body>
</html>
);
}

266
src/app/page.tsx Normal file
View File

@@ -0,0 +1,266 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "~/server/auth";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Logo } from "~/components/logo";
import {
Users,
FileText,
DollarSign,
Calendar,
CheckCircle,
ArrowRight,
Star,
Zap,
Shield,
Clock
} from "lucide-react";
export default async function HomePage() {
const session = await auth();
if (session?.user) {
redirect("/dashboard");
}
// Landing page for non-authenticated users
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
{/* Header */}
<header className="border-b border-green-200 bg-white/80 backdrop-blur-sm">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Logo />
<div className="flex items-center space-x-4">
<Link href="/auth/signin">
<Button variant="ghost">Sign In</Button>
</Link>
<Link href="/auth/register">
<Button>Get Started</Button>
</Link>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<section className="py-20 px-4">
<div className="container mx-auto text-center max-w-4xl">
<h1 className="text-5xl md:text-6xl font-bold text-gray-900 mb-6">
Simple Invoicing for
<span className="text-green-600"> Freelancers</span>
</h1>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
Create professional invoices, manage clients, and get paid faster with beenvoice.
The invoicing app that works as hard as you do.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/auth/register">
<Button size="lg" className="text-lg px-8 py-6">
Start Free Trial
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="#features">
<Button variant="outline" size="lg" className="text-lg px-8 py-6">
See How It Works
</Button>
</Link>
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="py-20 px-4 bg-white">
<div className="container mx-auto max-w-6xl">
<div className="text-center mb-16">
<h2 className="text-4xl font-bold text-gray-900 mb-4">
Everything you need to invoice like a pro
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Powerful features designed for freelancers and small businesses
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<Card className="border-0 shadow-lg">
<CardHeader>
<Users className="h-12 w-12 text-green-600 mb-4" />
<CardTitle>Client Management</CardTitle>
<CardDescription>
Keep all your client information organized in one place
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-gray-600">
<li className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
Store contact details and addresses
</li>
<li className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
Track client history and invoices
</li>
<li className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
Search and filter clients easily
</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-lg">
<CardHeader>
<FileText className="h-12 w-12 text-green-600 mb-4" />
<CardTitle>Professional Invoices</CardTitle>
<CardDescription>
Create beautiful, detailed invoices with line items
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-gray-600">
<li className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
Add multiple line items with dates
</li>
<li className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
Automatic calculations and totals
</li>
<li className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
Professional invoice numbering
</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-lg">
<CardHeader>
<DollarSign className="h-12 w-12 text-green-600 mb-4" />
<CardTitle>Payment Tracking</CardTitle>
<CardDescription>
Monitor invoice status and track payments
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-gray-600">
<li className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
Track draft, sent, paid, and overdue status
</li>
<li className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
View outstanding amounts at a glance
</li>
<li className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
Payment history and analytics
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Benefits Section */}
<section className="py-20 px-4 bg-gray-50">
<div className="container mx-auto max-w-4xl text-center">
<h2 className="text-4xl font-bold text-gray-900 mb-16">
Why choose beenvoice?
</h2>
<div className="grid md:grid-cols-2 gap-12">
<div className="space-y-6">
<div className="flex items-start space-x-4">
<Zap className="h-8 w-8 text-green-600 mt-1" />
<div className="text-left">
<h3 className="text-xl font-semibold mb-2">Lightning Fast</h3>
<p className="text-gray-600">Create invoices in seconds, not minutes. Our streamlined interface gets you back to work faster.</p>
</div>
</div>
<div className="flex items-start space-x-4">
<Shield className="h-8 w-8 text-green-600 mt-1" />
<div className="text-left">
<h3 className="text-xl font-semibold mb-2">Secure & Private</h3>
<p className="text-gray-600">Your data is encrypted and secure. We never share your information with third parties.</p>
</div>
</div>
</div>
<div className="space-y-6">
<div className="flex items-start space-x-4">
<Star className="h-8 w-8 text-green-600 mt-1" />
<div className="text-left">
<h3 className="text-xl font-semibold mb-2">Professional Quality</h3>
<p className="text-gray-600">Generate invoices that look professional and build trust with your clients.</p>
</div>
</div>
<div className="flex items-start space-x-4">
<Clock className="h-8 w-8 text-green-600 mt-1" />
<div className="text-left">
<h3 className="text-xl font-semibold mb-2">Save Time</h3>
<p className="text-gray-600">Automated calculations, templates, and client management save you hours every month.</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-4 bg-green-600">
<div className="container mx-auto text-center max-w-2xl">
<h2 className="text-4xl font-bold text-white mb-4">
Ready to get started?
</h2>
<p className="text-xl text-green-100 mb-8">
Join thousands of freelancers who trust beenvoice for their invoicing needs.
</p>
<Link href="/auth/register">
<Button size="lg" variant="secondary" className="text-lg px-8 py-6">
Start Your Free Trial
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<p className="text-green-200 mt-4 text-sm">
No credit card required Cancel anytime
</p>
</div>
</section>
{/* Footer */}
<footer className="py-12 px-4 bg-gray-900 text-white">
<div className="container mx-auto text-center">
<Logo className="mx-auto mb-4" />
<p className="text-gray-400 mb-4">
Simple invoicing for freelancers and small businesses
</p>
<div className="flex justify-center space-x-6 text-sm text-gray-400">
<Link href="/auth/signin" className="hover:text-white">Sign In</Link>
<Link href="/auth/register" className="hover:text-white">Register</Link>
</div>
</div>
</footer>
</div>
);
}
// Client components for stats and activity
function DashboardStats({ type }: { type: "clients" | "invoices" | "revenue" | "outstanding" }) {
// This will be implemented with tRPC queries
return <span>0</span>;
}
function RecentActivity() {
// This will be implemented with tRPC queries
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">No recent activity</p>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useState, useRef } from "react";
import { Input } from "~/components/ui/input";
import { Card } from "~/components/ui/card";
interface AddressAutocompleteProps {
value: string;
onChange: (value: string) => void;
onSelect: (value: string) => void;
placeholder?: string;
}
export function AddressAutocomplete({ value, onChange, onSelect, placeholder }: AddressAutocompleteProps) {
const [suggestions, setSuggestions] = useState<any[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const fetchSuggestions = async (query: string) => {
if (!query) {
setSuggestions([]);
return;
}
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`);
const data = await res.json();
setSuggestions(data);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
onChange(val);
setShowSuggestions(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => fetchSuggestions(val), 300);
};
const handleSelect = (address: string) => {
onSelect(address);
setShowSuggestions(false);
setSuggestions([]);
};
return (
<div className="relative">
<Input
value={value}
onChange={handleInputChange}
placeholder={placeholder || "Start typing address..."}
autoComplete="off"
onFocus={() => value && setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
/>
{showSuggestions && suggestions.length > 0 && (
<Card className="absolute z-10 mt-1 w-full max-h-60 overflow-auto shadow-lg border bg-white">
<ul>
{suggestions.map((s, i) => (
<li
key={s.place_id}
className="px-4 py-2 cursor-pointer hover:bg-muted text-sm"
onMouseDown={() => handleSelect(s.display_name)}
>
{s.display_name}
</li>
))}
</ul>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronRight } from "lucide-react";
export function Breadcrumbs() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
const crumbs = [
{ name: "Dashboard", href: "/dashboard" },
...segments.slice(1).map((seg, i) => ({
name: seg.charAt(0).toUpperCase() + seg.slice(1),
href: "/dashboard/" + segments.slice(1, i + 2).join("/"),
})),
];
return (
<nav className="flex items-center text-sm text-muted-foreground" aria-label="Breadcrumb">
{crumbs.map((crumb, i) => (
<span key={crumb.href} className="flex items-center">
{i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />}
{i < crumbs.length - 1 ? (
<Link href={crumb.href} className="hover:underline text-gray-500">
{crumb.name}
</Link>
) : (
<span className="font-medium text-gray-700">{crumb.name}</span>
)}
</span>
))}
</nav>
);
}

60
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import { Button } from "~/components/ui/button";
import { Logo } from "./logo";
export function Navbar() {
const { data: session } = useSession();
return (
<header className="fixed top-6 left-6 right-6 z-30">
<div className="bg-white/60 backdrop-blur-md shadow-2xl rounded-xl border-0">
<div className="flex h-16 items-center justify-between px-8">
<div className="flex items-center gap-6">
<Link href="/dashboard" className="flex items-center gap-2">
<Logo size="md" />
</Link>
</div>
<div className="flex items-center gap-4">
{session?.user ? (
<>
<span className="text-sm text-gray-700 hidden sm:inline font-medium">
{session.user.name ?? session.user.email}
</span>
<Button
variant="outline"
size="sm"
onClick={() => signOut({ callbackUrl: "/" })}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
Sign Out
</Button>
</>
) : (
<>
<Link href="/auth/signin">
<Button
variant="ghost"
size="sm"
className="text-gray-700 hover:bg-gray-100"
>
Sign In
</Button>
</Link>
<Link href="/auth/register">
<Button
size="sm"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium"
>
Register
</Button>
</Link>
</>
)}
</div>
</div>
</div>
</header>
);
}

111
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,111 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { MenuIcon, Settings, LayoutDashboard, Users, FileText } from "lucide-react";
import { useState } from "react";
const navLinks = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
];
export function Sidebar() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<>
{/* Mobile trigger */}
<div className="md:hidden p-2">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" aria-label="Open sidebar">
<MenuIcon className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-64 bg-white/95 border-0 rounded-r-xl backdrop-blur-sm">
<nav className="flex flex-col gap-1 p-4">
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Main</div>
{navLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-base font-medium transition-all duration-200 ${
pathname === link.href
? "bg-emerald-100 text-emerald-700 shadow-lg"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => setOpen(false)}
>
<Icon className="h-5 w-5" />
{link.name}
</Link>
);
})}
<div className="border-t border-gray-200 my-4" />
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Account</div>
<Link
href="/dashboard/settings"
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-base font-medium transition-all duration-200 ${
pathname === "/dashboard/settings"
? "bg-emerald-100 text-emerald-700 shadow-lg"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => setOpen(false)}
>
<Settings className="h-5 w-5" />
Settings
</Link>
</nav>
</SheetContent>
</Sheet>
</div>
{/* Desktop sidebar */}
<aside className="hidden md:flex flex-col justify-between fixed left-6 top-28 bottom-6 w-64 z-20 bg-white/60 backdrop-blur-md shadow-2xl rounded-xl border-0 p-8">
<nav className="flex flex-col gap-1">
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Main</div>
{navLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={`flex items-center gap-3 rounded-lg px-4 py-2 text-base font-medium transition-all duration-200 ${
pathname === link.href
? "bg-emerald-100 text-emerald-700 shadow-lg"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<Icon className="h-5 w-5" />
{link.name}
</Link>
);
})}
</nav>
<div>
<div className="border-t border-gray-200 my-4" />
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Account</div>
<Link
href="/dashboard/settings"
className={`flex items-center gap-3 rounded-lg px-4 py-2 text-base font-medium transition-all duration-200 ${
pathname === "/dashboard/settings"
? "bg-emerald-100 text-emerald-700 shadow-lg"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<Settings className="h-5 w-5" />
Settings
</Link>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,356 @@
"use client";
import { Building, Mail, MapPin, Phone, Save } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { api } from "~/trpc/react";
interface ClientFormProps {
clientId?: string;
mode: "create" | "edit";
}
export function ClientForm({ clientId, mode }: ClientFormProps) {
const router = useRouter();
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "",
});
const [loading, setLoading] = useState(false);
// Fetch client data if editing
const { data: client, isLoading: isLoadingClient } = api.clients.getById.useQuery(
{ id: clientId! },
{ enabled: mode === "edit" && !!clientId }
);
const createClient = api.clients.create.useMutation({
onSuccess: () => {
toast.success("Client created successfully");
router.push("/dashboard/clients");
},
onError: (error) => {
toast.error(error.message || "Failed to create client");
},
});
const updateClient = api.clients.update.useMutation({
onSuccess: () => {
toast.success("Client updated successfully");
router.push("/dashboard/clients");
},
onError: (error) => {
toast.error(error.message || "Failed to update client");
},
});
// Load client data when editing
useEffect(() => {
if (client && mode === "edit") {
setFormData({
name: client.name,
email: client.email ?? "",
phone: client.phone ?? "",
addressLine1: client.addressLine1 ?? "",
addressLine2: client.addressLine2 ?? "",
city: client.city ?? "",
state: client.state ?? "",
postalCode: client.postalCode ?? "",
country: client.country ?? "",
});
}
}, [client, mode]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (mode === "create") {
await createClient.mutateAsync(formData);
} else {
await updateClient.mutateAsync({
id: clientId!,
...formData,
});
}
} finally {
setLoading(false);
}
};
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
// Phone number formatting
const formatPhoneNumber = (value: string) => {
const phoneNumber = value.replace(/\D/g, '');
if (phoneNumber.length <= 3) {
return phoneNumber;
} else if (phoneNumber.length <= 6) {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
} else {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
}
};
const handlePhoneChange = (value: string) => {
const formatted = formatPhoneNumber(value);
handleInputChange("phone", formatted);
};
const US_STATES = [
"", "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY"
];
const MOST_USED_COUNTRIES = [
"United States", "United Kingdom", "Canada", "Australia", "Germany", "France", "India"
];
const ALL_COUNTRIES = [
"Afghanistan", "Albania", "Algeria", "Andorra", "Angola", "Antigua and Barbuda", "Argentina", "Armenia", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bhutan", "Bolivia", "Bosnia and Herzegovina", "Botswana", "Brazil", "Brunei", "Bulgaria", "Burkina Faso", "Burundi", "Cabo Verde", "Cambodia", "Cameroon", "Canada", "Central African Republic", "Chad", "Chile", "China", "Colombia", "Comoros", "Congo", "Costa Rica", "Croatia", "Cuba", "Cyprus", "Czech Republic", "Denmark", "Djibouti", "Dominica", "Dominican Republic", "East Timor", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia", "Eswatini", "Ethiopia", "Fiji", "Finland", "France", "Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Greece", "Grenada", "Guatemala", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Honduras", "Hungary", "Iceland", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy", "Ivory Coast", "Jamaica", "Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Kuwait", "Kyrgyzstan", "Laos", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Mauritania", "Mauritius", "Mexico", "Micronesia", "Moldova", "Monaco", "Mongolia", "Montenegro", "Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "New Zealand", "Nicaragua", "Niger", "Nigeria", "North Korea", "North Macedonia", "Norway", "Oman", "Pakistan", "Palau", "Palestine", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Poland", "Portugal", "Qatar", "Romania", "Russia", "Rwanda", "Saint Kitts and Nevis", "Saint Lucia", "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Korea", "South Sudan", "Spain", "Sri Lanka", "Sudan", "Suriname", "Sweden", "Switzerland", "Syria", "Taiwan", "Tajikistan", "Tanzania", "Thailand", "Togo", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "Uruguay", "Uzbekistan", "Vanuatu", "Vatican City", "Venezuela", "Vietnam", "Yemen", "Zambia", "Zimbabwe"
];
const OTHER_COUNTRIES = ALL_COUNTRIES.filter(
c => !MOST_USED_COUNTRIES.includes(c)
).sort();
if (mode === "edit" && isLoadingClient) {
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle>Loading client...</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="h-10 bg-muted rounded animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
<div className="h-20 bg-muted rounded animate-pulse" />
</div>
</CardContent>
</Card>
);
}
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
{/* <CardHeader className="text-center pb-8"> */}
{/* <div className="flex items-center justify-center space-x-4 mb-4"> */}
{/* <Link href="/dashboard/clients">
<Button variant="ghost" size="sm" className="hover:bg-white/50">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Clients
</Button>
</Link> */}
{/* </div> */}
{/* <CardTitle className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
{mode === "create" ? "Add New Client" : "Edit Client"}
</CardTitle> */}
{/* <p className="text-muted-foreground mt-2">
{mode === "create"
? "Create a new client profile with complete contact information"
: "Update your client's information"
}
</p> */}
{/* </CardHeader> */}
<CardContent>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Basic Information Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<Building className="h-5 w-5" />
<h3 className="text-lg font-semibold">Business Information</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium text-gray-700">
Business Name / Full Name *
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
placeholder="Enter business name or full name"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium text-gray-700">
Email Address
</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="business@example.com"
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
</div>
</div>
{/* Contact Information Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<Phone className="h-5 w-5" />
<h3 className="text-lg font-semibold">Contact Information</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="phone" className="text-sm font-medium text-gray-700">
Phone Number
</Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="(555) 123-4567"
maxLength={14}
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<p className="text-xs text-gray-500">Format: (555) 123-4567</p>
</div>
</div>
</div>
{/* Address Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<MapPin className="h-5 w-5" />
<h3 className="text-lg font-semibold">Address Information</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="addressLine1" className="text-sm font-medium text-gray-700">
Address Line 1
</Label>
<Input
id="addressLine1"
value={formData.addressLine1}
onChange={(e) => handleInputChange("addressLine1", e.target.value)}
placeholder="Street address, P.O. box, company name"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="addressLine2" className="text-sm font-medium text-gray-700">
Address Line 2
</Label>
<Input
id="addressLine2"
value={formData.addressLine2}
onChange={(e) => handleInputChange("addressLine2", e.target.value)}
placeholder="Apartment, suite, unit, building, floor, etc."
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="city" className="text-sm font-medium text-gray-700">
City
</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleInputChange("city", e.target.value)}
placeholder="City or town"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="state" className="text-sm font-medium text-gray-700">
State
</Label>
<select
id="state"
value={formData.state}
onChange={e => handleInputChange("state", e.target.value)}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
>
<option value="">Select a state</option>
{US_STATES.filter(s => s).map(state => (
<option key={state} value={state}>{state}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="postalCode" className="text-sm font-medium text-gray-700">
Postal Code
</Label>
<Input
id="postalCode"
value={formData.postalCode}
onChange={(e) => handleInputChange("postalCode", e.target.value)}
placeholder="ZIP or postal code"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="country" className="text-sm font-medium text-gray-700">
Country
</Label>
<select
id="country"
value={formData.country}
onChange={e => handleInputChange("country", e.target.value)}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
>
<option value="">Select a country</option>
{MOST_USED_COUNTRIES.map(country => (
<option key={country} value={country}>{country}</option>
))}
<option disabled></option>
{OTHER_COUNTRIES.map(country => (
<option key={country} value={country}>{country}</option>
))}
</select>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-4 pt-6 border-t border-gray-200">
<Button
type="submit"
disabled={loading}
className="flex-1 h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
<Save className="mr-2 h-4 w-4" />
{loading ? "Saving..." : mode === "create" ? "Create Client" : "Update Client"}
</Button>
<Link href="/dashboard/clients" className="flex-1">
<Button
type="button"
variant="outline"
className="w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
>
Cancel
</Button>
</Link>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { toast } from "sonner";
import { Mail, Phone, MapPin, Edit, Trash2, Eye, Plus, Search } from "lucide-react";
export function ClientList() {
const [searchTerm, setSearchTerm] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [clientToDelete, setClientToDelete] = useState<string | null>(null);
const { data: clients, isLoading, refetch } = api.clients.getAll.useQuery();
const deleteClient = api.clients.delete.useMutation({
onSuccess: () => {
toast.success("Client deleted successfully");
void refetch();
setDeleteDialogOpen(false);
setClientToDelete(null);
},
onError: (error) => {
toast.error(error.message || "Failed to delete client");
},
});
const filteredClients = clients?.filter(client =>
client.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
client.email?.toLowerCase().includes(searchTerm.toLowerCase())
) ?? [];
const handleDelete = (clientId: string) => {
setClientToDelete(clientId);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (clientToDelete) {
deleteClient.mutate({ id: clientToDelete });
}
};
if (isLoading) {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[...Array(3)].map((_, i: number) => (
<Card key={i} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="h-4 bg-gray-200 rounded animate-pulse" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-3 bg-gray-200 rounded animate-pulse" />
<div className="h-3 bg-gray-200 rounded w-2/3 animate-pulse" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
if (!clients || clients.length === 0) {
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
No Clients Yet
</CardTitle>
<CardDescription className="text-lg">
Get started by adding your first client
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Link href="/dashboard/clients/new">
<Button
className="w-full h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
<Plus className="mr-2 h-4 w-4" />
Add Your First Client
</Button>
</Link>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="flex-1 relative">
<Label htmlFor="search" className="sr-only">Search clients</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="search"
placeholder="Search by name or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
<Link href="/dashboard/clients/new">
<Button
className="w-full sm:w-auto h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
<Plus className="mr-2 h-4 w-4" />
Add Client
</Button>
</Link>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredClients.map((client) => (
<Card key={client.id} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300 group">
<CardHeader>
<CardTitle className="flex items-center justify-between text-lg">
<span className="font-semibold text-gray-800 group-hover:text-emerald-600 transition-colors">
{client.name}
</span>
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/clients/${client.id}`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-emerald-100">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/clients/${client.id}/edit`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-emerald-100">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(client.id)}
className="h-8 w-8 p-0 hover:bg-red-100 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{client.email && (
<div className="flex items-center text-sm text-gray-600">
<div className="p-1.5 bg-emerald-100 rounded mr-3">
<Mail className="h-3 w-3 text-emerald-600" />
</div>
{client.email}
</div>
)}
{client.phone && (
<div className="flex items-center text-sm text-gray-600">
<div className="p-1.5 bg-blue-100 rounded mr-3">
<Phone className="h-3 w-3 text-blue-600" />
</div>
{client.phone}
</div>
)}
{(client.addressLine1 ?? client.city ?? client.state) && (
<div className="flex items-start text-sm text-gray-600">
<div className="p-1.5 bg-teal-100 rounded mr-3 mt-0.5 flex-shrink-0">
<MapPin className="h-3 w-3 text-teal-600" />
</div>
<div className="min-w-0">
{client.addressLine1 && <div>{client.addressLine1}</div>}
{client.addressLine2 && <div>{client.addressLine2}</div>}
{(client.city ?? client.state ?? client.postalCode) && (
<div>
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
</div>
)}
{client.country && <div>{client.country}</div>}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-gray-800">Delete Client</DialogTitle>
<DialogDescription className="text-gray-600">
Are you sure you want to delete this client? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,706 @@
"use client";
import { AlertCircle, Clock, DollarSign, Eye, FileText, Trash2, Upload, Users } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { DatePicker } from "~/components/ui/date-picker";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { FileUpload } from "~/components/ui/file-upload";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
interface CSVRow {
DATE: string;
DESCRIPTION: string;
HOURS: number;
RATE: number;
AMOUNT: number;
}
interface ParsedItem {
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface FileData {
file: File;
parsedItems: ParsedItem[];
previewData: CSVRow[];
invoiceNumber: string;
clientId: string;
issueDate: Date | null;
dueDate: Date | null;
status: "pending" | "ready" | "error";
errors: string[];
hasDateError: boolean;
}
export function CSVImportPage() {
const [files, setFiles] = useState<FileData[]>([]);
const [globalClientId, setGlobalClientId] = useState("");
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [selectedFileIndex, setSelectedFileIndex] = useState<number | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progressCount, setProgressCount] = useState(0);
// Fetch clients for dropdown
const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery();
const createInvoice = api.invoices.create.useMutation({
onSuccess: () => {
toast.success("Invoice created successfully");
},
onError: (error) => {
toast.error(error.message || "Failed to create invoice");
},
});
const parseCSVLine = (line: string): string[] => {
const result: string[] = [];
let current = '';
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
// Escaped quote inside quoted field
current += '"';
i += 2; // Skip both quotes
} else {
// Toggle quote state
inQuotes = !inQuotes;
i++;
}
} else if (char === ',' && !inQuotes) {
// End of field
result.push(current.trim());
current = '';
i++;
} else {
// Regular character
current += char;
i++;
}
}
// Add the last field
result.push(current.trim());
return result;
};
const parseCSV = (csvText: string): CSVRow[] => {
const lines = csvText.split('\n');
const headers = parseCSVLine(lines[0] ?? '');
// Validate headers
const requiredHeaders = ['DATE', 'DESCRIPTION', 'HOURS', 'RATE', 'AMOUNT'];
const missingHeaders = requiredHeaders.filter(h => !headers?.includes(h));
if (missingHeaders.length > 0) {
throw new Error(`Missing required headers: ${missingHeaders.join(', ')}`);
}
return lines.slice(1)
.filter(line => line.trim())
.map(line => {
const values = parseCSVLine(line);
return {
DATE: values[0] ?? '',
DESCRIPTION: values[1] ?? '',
HOURS: parseFloat(values[2] ?? '0') || 0,
RATE: parseFloat(values[3] ?? '0') || 0,
AMOUNT: parseFloat(values[4] ?? '0') || 0,
};
})
.filter(row => row.DESCRIPTION && row.HOURS > 0 && row.RATE > 0);
};
const parseDate = (dateStr: string): Date => {
// Handle m/dd/yy format
const parts = dateStr.split('/');
if (parts.length === 3) {
const month = parseInt(parts[0] ?? '1') - 1; // 0-based month
const day = parseInt(parts[1] ?? '1');
const year = parseInt(parts[2] ?? '2000') + 2000; // Assume 20xx
return new Date(year, month, day);
}
// Fallback to standard date parsing
return new Date(dateStr);
};
const handleFileSelect = async (selectedFiles: File[]) => {
for (const file of selectedFiles) {
const errors: string[] = [];
let hasDateError = false;
let issueDate: Date | null = null;
let dueDate: Date | null = null;
// Check filename format
const filenameMatch = /^(\d{4}-\d{2}-\d{2})\.csv$/.exec(file.name);
if (!filenameMatch) {
errors.push("Filename must be in YYYY-MM-DD.csv format");
hasDateError = true;
} else {
const filenameDate = filenameMatch[1] ?? "";
issueDate = new Date(filenameDate);
if (isNaN(issueDate.getTime())) {
errors.push("Invalid date in filename");
hasDateError = true;
} else {
dueDate = new Date(issueDate);
dueDate.setDate(dueDate.getDate() + 30);
}
}
try {
const text = await file.text();
const csvData = parseCSV(text);
// Parse items for invoice creation
const items = csvData.map(row => ({
date: parseDate(row.DATE),
description: row.DESCRIPTION,
hours: row.HOURS,
rate: row.RATE,
amount: row.HOURS * row.RATE, // Calculate amount ourselves
}));
const fileData: FileData = {
file,
parsedItems: items,
previewData: csvData,
invoiceNumber: issueDate ? `INV-${issueDate.toISOString().slice(0, 10).replace(/-/g, '')}-${Date.now().toString().slice(-6)}` : `INV-${Date.now()}`,
clientId: globalClientId, // Use global client if set
issueDate,
dueDate,
status: errors.length > 0 ? "error" : "pending",
errors,
hasDateError
};
setFiles(prev => [...prev, fileData]);
if (errors.length > 0) {
toast.error(`${file.name} has ${errors.length} error${errors.length > 1 ? 's' : ''}`);
} else {
toast.success(`Parsed ${items.length} items from ${file.name}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
const fileData: FileData = {
file,
parsedItems: [],
previewData: [],
invoiceNumber: `INV-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
clientId: globalClientId,
issueDate: null,
dueDate: null,
status: "error",
errors: [`Error parsing CSV: ${errorMessage}`],
hasDateError: true
};
setFiles(prev => [...prev, fileData]);
toast.error(`Error parsing ${file.name}: ${errorMessage}`);
}
}
};
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
// Apply global client to all files that don't have a client selected
const applyGlobalClient = (clientId: string) => {
setFiles(prev => prev.map(file => ({
...file,
clientId: file.clientId || clientId // Only apply if no client is already selected
})));
};
const updateFileData = (index: number, updates: Partial<FileData>) => {
setFiles(prev => prev.map((file, i) => {
if (i !== index) return file;
const updatedFile = { ...file, ...updates };
// Recalculate errors if issue date or due date was updated
if (updates.issueDate !== undefined || updates.dueDate !== undefined) {
const newErrors = [...updatedFile.errors];
// Remove filename format error if a valid issue date is now set
if (updatedFile.issueDate && newErrors.includes("Filename must be in YYYY-MM-DD.csv format")) {
const errorIndex = newErrors.indexOf("Filename must be in YYYY-MM-DD.csv format");
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
// Remove invalid date error if a valid issue date is now set
if (updatedFile.issueDate && newErrors.includes("Invalid date in filename")) {
const errorIndex = newErrors.indexOf("Invalid date in filename");
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
updatedFile.errors = newErrors;
updatedFile.status = newErrors.length > 0 ? "error" : "pending";
updatedFile.hasDateError = newErrors.some(error =>
error.includes("Filename") || error.includes("Invalid date")
);
}
return updatedFile;
}));
};
const openPreview = (index: number) => {
setSelectedFileIndex(index);
setPreviewModalOpen(true);
};
const validateFiles = () => {
const errors: string[] = [];
files.forEach((fileData) => {
// Check for existing errors
if (fileData.errors.length > 0) {
errors.push(`${fileData.file.name}: ${fileData.errors.join(', ')}`);
}
if (!fileData.clientId && !globalClientId) {
errors.push(`${fileData.file.name}: Client not selected`);
}
if (fileData.parsedItems.length === 0) {
errors.push(`${fileData.file.name}: No valid items found`);
}
if (!fileData.issueDate) {
errors.push(`${fileData.file.name}: Issue date required`);
}
if (!fileData.dueDate) {
errors.push(`${fileData.file.name}: Due date required`);
}
});
return errors;
};
const processBatch = async () => {
const errors = validateFiles();
if (errors.length > 0) {
toast.error(`Please fix the following issues:\n${errors.join('\n')}`);
return;
}
setIsProcessing(true);
setProgressCount(0);
let successCount = 0;
let errorCount = 0;
for (const fileData of files) {
try {
// Validate required fields before sending
const clientId = fileData.clientId || globalClientId;
if (!clientId) {
throw new Error(`No client selected for ${fileData.file.name}`);
}
if (!fileData.issueDate) {
throw new Error(`No issue date for ${fileData.file.name}`);
}
if (!fileData.dueDate) {
throw new Error(`No due date for ${fileData.file.name}`);
}
if (!fileData.invoiceNumber) {
throw new Error(`No invoice number for ${fileData.file.name}`);
}
if (!fileData.parsedItems || fileData.parsedItems.length === 0) {
throw new Error(`No items found for ${fileData.file.name}`);
}
const invoiceData = {
invoiceNumber: fileData.invoiceNumber,
clientId: clientId,
issueDate: fileData.issueDate,
dueDate: fileData.dueDate,
status: "draft" as const,
notes: `Imported from CSV: ${fileData.file.name}`,
items: fileData.parsedItems.map(item => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})),
};
console.log('Creating invoice with data:', invoiceData);
await createInvoice.mutateAsync(invoiceData);
console.log('Invoice created successfully');
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to create invoice for ${fileData.file.name}:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
toast.error(`Failed to create invoice for ${fileData.file.name}: ${errorMessage}`);
}
setProgressCount(prev => prev + 1);
}
setIsProcessing(false);
if (successCount > 0) {
toast.success(`Successfully created ${successCount} invoice${successCount > 1 ? 's' : ''}`);
}
if (errorCount > 0) {
toast.error(`Failed to create ${errorCount} invoice${errorCount > 1 ? 's' : ''}`);
}
if (successCount > 0) {
setFiles([]);
}
};
const totalFiles = files.length;
const readyFiles = files.filter(f =>
f.errors.length === 0 &&
(f.clientId || globalClientId) &&
f.issueDate &&
f.dueDate
).length;
const totalItems = files.reduce((sum, f) => sum + f.parsedItems.length, 0);
const totalAmount = files.reduce((sum, f) => sum + f.parsedItems.reduce((itemSum, item) => itemSum + item.amount, 0), 0);
return (
<div className="space-y-6">
{/* Global Client Selection */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-800">
<Users className="h-5 w-5" />
Default Client
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-client" className="text-sm font-medium text-gray-700">
Select Default Client (Optional)
</Label>
<select
id="global-client"
value={globalClientId}
onChange={(e) => {
const newClientId = e.target.value;
setGlobalClientId(newClientId);
if (newClientId) {
applyGlobalClient(newClientId);
}
}}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
disabled={loadingClients}
>
<option value="">No default client (select individually)</option>
{clients?.map(client => (
<option key={client.id} value={client.id}>{client.name}</option>
))}
</select>
<p className="text-xs text-gray-500">
This client will be automatically selected for all uploaded files. You can still change individual files below.
</p>
</div>
</CardContent>
</Card>
{/* File Upload Area */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-800">
<Upload className="h-5 w-5" />
Upload CSV Files
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FileUpload
onFilesSelected={handleFileSelect}
accept={{ "text/csv": [".csv"] }}
maxFiles={50}
maxSize={5 * 1024 * 1024} // 5MB
placeholder="Drag & drop CSV files here, or click to select"
description="Files must be named YYYY-MM-DD.csv (e.g., 2024-01-15.csv). Up to 50 files can be uploaded at once."
/>
{/* Summary Stats */}
{totalFiles > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-emerald-50/50 rounded-lg">
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">{totalFiles}</div>
<div className="text-sm text-gray-600">Files</div>
<div className="text-xs text-gray-500">of 50 max</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">{totalItems}</div>
<div className="text-sm text-gray-600">Total Items</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">
{totalAmount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
</div>
<div className="text-sm text-gray-600">Total Amount</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">{readyFiles}/{totalFiles}</div>
<div className="text-sm text-gray-600">Ready</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* File List */}
{files.length > 0 && (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-emerald-800">Uploaded Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{files.map((fileData, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 bg-white">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-emerald-600" />
<div>
<h3 className="font-medium text-gray-900 truncate">{fileData.file.name}</h3>
<p className="text-sm text-gray-500">
{fileData.parsedItems.length} items {fileData.parsedItems.reduce((sum, item) => sum + item.hours, 0).toFixed(1)} hours
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openPreview(index)}
>
<Eye className="mr-1 h-4 w-4" />
Preview
</Button>
<Button
variant="outline"
size="sm"
onClick={() => removeFile(index)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="mr-1 h-4 w-4" />
Remove
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">Invoice Number</Label>
<Input
value={fileData.invoiceNumber}
className="h-9 text-sm bg-gray-50"
placeholder="Auto-generated"
readOnly
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">Client</Label>
<select
value={fileData.clientId}
onChange={(e) => updateFileData(index, { clientId: e.target.value })}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 py-1 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
>
<option value="">Select client</option>
{clients?.map(client => (
<option key={client.id} value={client.id}>{client.name}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">Issue Date</Label>
<DatePicker
date={fileData.issueDate ?? undefined}
onDateChange={(date) => updateFileData(index, { issueDate: date ?? null })}
placeholder="Select issue date"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">Due Date</Label>
<DatePicker
date={fileData.dueDate ?? undefined}
onDateChange={(date) => updateFileData(index, { dueDate: date ?? null })}
placeholder="Select due date"
className="h-9"
/>
</div>
</div>
{/* Error Display */}
{fileData.errors.length > 0 && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-red-600" />
<span className="text-sm font-medium text-red-800">Issues Found</span>
</div>
<ul className="text-sm text-red-700 space-y-1">
{fileData.errors.map((error, errorIndex) => (
<li key={errorIndex} className="flex items-start gap-2">
<span className="text-red-600"></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
)}
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
Total: {fileData.parsedItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
</div>
<div className="flex items-center gap-2">
{fileData.errors.length > 0 && (
<Badge variant="destructive" className="text-xs">
{fileData.errors.length} Error{fileData.errors.length !== 1 ? 's' : ''}
</Badge>
)}
<Badge variant={
fileData.errors.length > 0 ? "destructive" :
(fileData.clientId || globalClientId) && fileData.issueDate && fileData.dueDate ? "default" : "secondary"
}>
{fileData.errors.length > 0 ? "Has Errors" :
(fileData.clientId || globalClientId) && fileData.issueDate && fileData.dueDate ? "Ready" : "Pending"}
</Badge>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Batch Actions */}
{files.length > 0 && (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardContent>
<div className="flex flex-col gap-4">
{isProcessing && (
<div className="w-full flex flex-col gap-2">
<span className="text-xs text-gray-500">Uploading invoices...</span>
<Progress value={Math.round((progressCount / totalFiles) * 100)} />
</div>
)}
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
{readyFiles} of {totalFiles} files ready for import
</div>
<Button
onClick={processBatch}
disabled={readyFiles === 0 || isProcessing}
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white"
>
{isProcessing ? "Processing..." : `Import ${readyFiles} Invoice${readyFiles !== 1 ? 's' : ''}`}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="text-xl font-bold text-gray-800 flex items-center gap-2">
<FileText className="h-5 w-5 text-emerald-600" />
{selectedFileIndex !== null && files[selectedFileIndex]?.file.name}
</DialogTitle>
<DialogDescription className="text-gray-600">
Preview of parsed CSV data
</DialogDescription>
</DialogHeader>
{selectedFileIndex !== null && files[selectedFileIndex] && (
<div className="flex-1 flex flex-col min-h-0 space-y-4">
<div className="flex-shrink-0 grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-emerald-600" />
<span className="text-sm text-gray-600">{files[selectedFileIndex].parsedItems.length} items</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-emerald-600" />
<span className="text-sm text-gray-600">
{files[selectedFileIndex].parsedItems.reduce((sum, item) => sum + item.hours, 0).toFixed(1)} total hours
</span>
</div>
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-emerald-600" />
<span className="text-sm text-gray-600 font-medium">
{files[selectedFileIndex].parsedItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
</span>
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<div className="max-h-96 overflow-y-auto">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[600px]">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="text-left p-2 font-medium text-gray-700 whitespace-nowrap">Date</th>
<th className="text-left p-2 font-medium text-gray-700">Description</th>
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Hours</th>
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Rate</th>
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Amount</th>
</tr>
</thead>
<tbody>
{files[selectedFileIndex].parsedItems.map((item, index) => (
<tr key={index} className="border-b border-gray-100">
<td className="p-2 text-gray-600 whitespace-nowrap">{item.date.toLocaleDateString()}</td>
<td className="p-2 text-gray-600 max-w-xs truncate">{item.description}</td>
<td className="p-2 text-gray-600 text-right whitespace-nowrap">{item.hours}</td>
<td className="p-2 text-gray-600 text-right whitespace-nowrap">{item.rate.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td>
<td className="p-2 text-gray-600 text-right font-medium whitespace-nowrap">{item.amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
<Button
variant="outline"
onClick={() => setPreviewModalOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import React from "react";
import { api } from "~/trpc/react";
function isUUID(str: string) {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(str);
}
export function DashboardBreadcrumbs() {
const pathname = usePathname();
const segments = pathname.split('/').filter(Boolean);
// Find clientId if present
let clientId: string | undefined = undefined;
if (segments[1] === "clients" && segments[2] && isUUID(segments[2])) {
clientId = segments[2];
}
const { data: client } = api.clients.getById.useQuery(
{ id: clientId ?? "" },
{ enabled: !!clientId }
);
// Generate breadcrumb items based on pathname
const breadcrumbs = React.useMemo(() => {
const items = [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const path = `/${segments.slice(0, i + 1).join('/')}`;
if (segment === 'dashboard') continue;
let label = segment;
if (segment === 'clients') label = 'Clients';
if (isUUID(segment ?? "") && client) label = client.name ?? "";
if (segment === 'invoices') label = 'Invoices';
if (segment === 'new') label = 'New';
// Only show 'Edit' if not the last segment
if (segment === 'edit' && i !== segments.length - 1) label = 'Edit';
// Don't show 'edit' as the last breadcrumb, just show the client name
if (segment === 'edit' && i === segments.length - 1 && client) continue;
if (segment === 'import') label = 'Import';
items.push({
label,
href: path,
isLast: i === segments.length - 1 || (segment === 'edit' && i === segments.length - 1 && client),
});
}
return items;
}, [segments, client]);
if (breadcrumbs.length === 0) return null;
return (
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/dashboard">Dashboard</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb) => (
<React.Fragment key={crumb.href}>
<BreadcrumbSeparator>
<ChevronRight className="h-4 w-4" />
</BreadcrumbSeparator>
<BreadcrumbItem>
{crumb.isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={crumb.href}>{crumb.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@@ -0,0 +1,384 @@
"use client";
import * as React from "react";
import { useState, useEffect } from "react";
import { api } from "~/trpc/react";
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 { DatePicker } from "~/components/ui/date-picker";
import { toast } from "sonner";
import { Calendar, FileText, User, Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
const STATUS_OPTIONS = ["draft", "sent", "paid", "overdue"];
interface InvoiceFormProps {
invoiceId?: string;
}
export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const [formData, setFormData] = useState({
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${Date.now().toString().slice(-6)}`,
clientId: "",
issueDate: new Date(),
dueDate: new Date(),
status: "draft" as "draft" | "sent" | "paid" | "overdue",
notes: "",
items: [
{ date: new Date(), description: "", hours: 0, rate: 0, amount: 0 },
],
});
const [loading, setLoading] = useState(false);
// Fetch clients for dropdown
const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery();
// Fetch existing invoice data if editing
const { data: existingInvoice, isLoading: loadingInvoice } = api.invoices.getById.useQuery(
{ id: invoiceId! },
{ enabled: !!invoiceId }
);
// Populate form with existing data when editing
React.useEffect(() => {
if (existingInvoice && invoiceId) {
setFormData({
invoiceNumber: existingInvoice.invoiceNumber,
clientId: existingInvoice.clientId,
issueDate: new Date(existingInvoice.issueDate),
dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid" | "overdue",
notes: existingInvoice.notes || "",
items: existingInvoice.items?.map(item => ({
date: new Date(item.date),
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})) || [{ date: new Date(), description: "", hours: 0, rate: 0, amount: 0 }],
});
}
}, [existingInvoice, invoiceId]);
// Calculate total amount
const totalAmount = formData.items.reduce(
(sum, item) => sum + (item.hours * item.rate),
0
);
// Update item amount on change
const handleItemChange = (idx: number, field: string, value: any) => {
setFormData((prev) => {
const items = [...prev.items];
if (field === "hours" || field === "rate") {
if (items[idx]) {
items[idx][field as "hours" | "rate"] = parseFloat(value) || 0;
items[idx].amount = items[idx].hours * items[idx].rate;
}
} else if (field === "date") {
if (items[idx]) {
items[idx][field as "date"] = value;
}
} else {
if (items[idx]) {
items[idx][field as "description"] = value;
}
}
return { ...prev, items };
});
};
// Add new item
const addItem = () => {
setFormData((prev) => ({
...prev,
items: [
...prev.items,
{ date: new Date(), description: "", hours: 0, rate: 0, amount: 0 },
],
}));
};
// Remove item
const removeItem = (idx: number) => {
setFormData((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== idx),
}));
};
// tRPC mutations
const createInvoice = api.invoices.create.useMutation({
onSuccess: () => {
toast.success("Invoice created successfully");
router.push("/dashboard/invoices");
},
onError: (error) => {
toast.error(error.message || "Failed to create invoice");
},
});
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
toast.success("Invoice updated successfully");
router.push(`/dashboard/invoices/${invoiceId}`);
},
onError: (error) => {
toast.error(error.message || "Failed to update invoice");
},
});
// Handle form submit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const submitData = {
...formData,
items: formData.items.map(item => ({
...item,
date: new Date(item.date),
})),
};
if (invoiceId) {
await updateInvoice.mutateAsync({
id: invoiceId,
...submitData,
});
} else {
await createInvoice.mutateAsync(submitData);
}
} finally {
setLoading(false);
}
};
// Show loading state while fetching existing invoice data
if (invoiceId && loadingInvoice) {
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
<CardContent>
<div className="space-y-8">
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<FileText className="h-5 w-5" />
<h3 className="text-lg font-semibold">Invoice Details</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse md:col-span-2"></div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
<CardContent>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Invoice Details */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<FileText className="h-5 w-5" />
<h3 className="text-lg font-semibold">Invoice Details</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="invoiceNumber" className="text-sm font-medium text-gray-700">
Invoice Number
</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
className="h-12 border-gray-200 bg-gray-50"
placeholder="Auto-generated"
readOnly
/>
</div>
<div className="space-y-2">
<Label htmlFor="clientId" className="text-sm font-medium text-gray-700">
Client
</Label>
<select
id="clientId"
value={formData.clientId}
onChange={e => setFormData(f => ({ ...f, clientId: e.target.value }))}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
required
disabled={loadingClients}
>
<option value="">Select a client</option>
{clients?.map(client => (
<option key={client.id} value={client.id}>{client.name}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="issueDate" className="text-sm font-medium text-gray-700">
Issue Date
</Label>
<DatePicker
date={formData.issueDate}
onDateChange={date => setFormData(f => ({ ...f, issueDate: date || new Date() }))}
placeholder="Select issue date"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="dueDate" className="text-sm font-medium text-gray-700">
Due Date
</Label>
<DatePicker
date={formData.dueDate}
onDateChange={date => setFormData(f => ({ ...f, dueDate: date || new Date() }))}
placeholder="Select due date"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="status" className="text-sm font-medium text-gray-700">
Status
</Label>
<select
id="status"
value={formData.status}
onChange={e => setFormData(f => ({ ...f, status: e.target.value as "draft" | "sent" | "paid" | "overdue" }))}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
required
>
{STATUS_OPTIONS.map(status => (
<option key={status} value={status}>{status.charAt(0).toUpperCase() + status.slice(1)}</option>
))}
</select>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="notes" className="text-sm font-medium text-gray-700">
Notes
</Label>
<Input
id="notes"
value={formData.notes}
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
placeholder="Additional notes (optional)"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
</div>
{/* Invoice Items */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<User className="h-5 w-5" />
<h3 className="text-lg font-semibold">Invoice Items</h3>
</div>
<div className="space-y-4">
{formData.items.map((item, idx) => (
<div key={idx} className="grid grid-cols-1 md:grid-cols-5 gap-4 items-end bg-emerald-50/30 rounded-lg p-4">
<div className="space-y-1">
<Label>Date</Label>
<Input
type="date"
value={format(item.date, "yyyy-MM-dd")}
onChange={e => handleItemChange(idx, "date", new Date(e.target.value))}
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
required
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label>Description</Label>
<Input
value={item.description}
onChange={e => handleItemChange(idx, "description", e.target.value)}
placeholder="Description"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
required
/>
</div>
<div className="space-y-1">
<Label>Hours</Label>
<Input
type="number"
min={0}
step={0.1}
value={item.hours}
onChange={e => handleItemChange(idx, "hours", e.target.value)}
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
required
/>
</div>
<div className="space-y-1">
<Label>Rate</Label>
<Input
type="number"
min={0}
step={0.01}
value={item.rate}
onChange={e => handleItemChange(idx, "rate", e.target.value)}
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
required
/>
</div>
<div className="space-y-1">
<Label>Amount</Label>
<Input
value={item.amount.toFixed(2)}
readOnly
className="h-10 border-gray-200 bg-gray-100 text-gray-700"
/>
</div>
<div className="flex items-center justify-center md:col-span-5">
{formData.items.length > 1 && (
<Button type="button" variant="destructive" size="sm" onClick={() => removeItem(idx)}>
<Trash2 className="h-4 w-4 mr-1" /> Remove
</Button>
)}
</div>
</div>
))}
<Button type="button" variant="outline" onClick={addItem} className="w-full md:w-auto">
<Plus className="mr-2 h-4 w-4" /> Add Item
</Button>
</div>
</div>
{/* Total Amount */}
<div className="flex justify-end items-center text-lg font-semibold text-emerald-700">
Total: ${totalAmount.toFixed(2)}
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-4 pt-6 border-t border-gray-200">
<Button
type="submit"
disabled={loading || (!!invoiceId && loadingInvoice)}
className="flex-1 h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
{loading ? "Saving..." : invoiceId ? "Update Invoice" : "Create Invoice"}
</Button>
<Button
type="button"
variant="outline"
className="flex-1 w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
onClick={() => router.push(invoiceId ? `/dashboard/invoices/${invoiceId}` : "/dashboard/invoices")}
>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,207 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { toast } from "sonner";
import { FileText, Calendar, DollarSign, Edit, Trash2, Eye, Plus, User } from "lucide-react";
const statusColors = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800",
};
const statusLabels = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
};
export function InvoiceList() {
const [searchTerm, setSearchTerm] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<string | null>(null);
const { data: invoices, isLoading, refetch } = api.invoices.getAll.useQuery();
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
refetch();
setDeleteDialogOpen(false);
setInvoiceToDelete(null);
},
onError: (error) => {
toast.error(error.message || "Failed to delete invoice");
},
});
const filteredInvoices = invoices?.filter(invoice =>
invoice.invoiceNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase())
) || [];
const handleDelete = (invoiceId: string) => {
setInvoiceToDelete(invoiceId);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (invoiceToDelete) {
deleteInvoice.mutate({ id: invoiceToDelete });
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString();
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-4 bg-muted rounded animate-pulse" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-3 bg-muted rounded animate-pulse" />
<div className="h-3 bg-muted rounded w-2/3 animate-pulse" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
if (!invoices || invoices.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>No Invoices Yet</CardTitle>
<CardDescription>
Get started by creating your first invoice
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/dashboard/invoices/new">
<Button className="w-full">
<Plus className="mr-2 h-4 w-4" />
Create Your First Invoice
</Button>
</Link>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<div className="flex items-center space-x-4">
<div className="flex-1">
<Label htmlFor="search">Search invoices</Label>
<Input
id="search"
placeholder="Search by invoice number or client..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Link href="/dashboard/invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Invoice
</Button>
</Link>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredInvoices.map((invoice) => (
<Card key={invoice.id}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="truncate">{invoice.invoiceNumber}</span>
<div className="flex space-x-1">
<Link href={`/invoices/${invoice.id}`}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(invoice.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardTitle>
<div className="flex items-center justify-between">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[invoice.status as keyof typeof statusColors]}`}>
{statusLabels[invoice.status as keyof typeof statusLabels]}
</span>
<span className="text-lg font-bold text-green-600">
{formatCurrency(invoice.totalAmount)}
</span>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center text-sm text-muted-foreground">
<User className="mr-2 h-4 w-4" />
{invoice.client.name}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<Calendar className="mr-2 h-4 w-4" />
Due: {formatDate(invoice.dueDate)}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<FileText className="mr-2 h-4 w-4" />
{invoice.items.length} item{invoice.items.length !== 1 ? 's' : ''}
</div>
</CardContent>
</Card>
))}
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Invoice</DialogTitle>
<DialogDescription>
Are you sure you want to delete this invoice? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,385 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { Calendar, FileText, User, DollarSign, Trash2, Edit, Download, Send } from "lucide-react";
import Link from "next/link";
import { generateInvoicePDF } from "~/lib/pdf-export";
interface InvoiceViewProps {
invoiceId: string;
}
const statusColors = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800",
} as const;
const statusLabels = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
} as const;
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isExportingPDF, setIsExportingPDF] = useState(false);
// Fetch invoice data
const { data: invoice, isLoading, refetch } = api.invoices.getById.useQuery({ id: invoiceId });
// Delete mutation
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
setDeleteDialogOpen(false);
router.push("/dashboard/invoices");
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete invoice");
},
});
// Update status mutation
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: () => {
toast.success("Status updated successfully");
void refetch();
},
onError: (error) => {
toast.error(error.message ?? "Failed to update status");
},
});
const handleDelete = () => {
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
deleteInvoice.mutate({ id: invoiceId });
};
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid" | "overdue") => {
updateStatus.mutate({ id: invoiceId, status: newStatus });
};
const handlePDFExport = async () => {
if (!invoice) return;
setIsExportingPDF(true);
try {
await generateInvoicePDF(invoice);
toast.success("PDF exported successfully");
} catch (error) {
console.error("PDF export error:", error);
toast.error("Failed to export PDF. Please try again.");
} finally {
setIsExportingPDF(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const formatDate = (date: Date) => {
return format(new Date(date), "MMM dd, yyyy");
};
if (isLoading) {
return (
<div className="space-y-6">
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="h-8 bg-gray-200 rounded animate-pulse"></div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
</div>
</CardContent>
</Card>
</div>
);
}
if (!invoice) {
return (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Invoice not found</h3>
<p className="text-gray-500 mb-4">The invoice you're looking for doesn't exist or has been deleted.</p>
<Button asChild>
<Link href="/dashboard/invoices">Back to Invoices</Link>
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Invoice Header */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<FileText className="h-6 w-6 text-emerald-600" />
{invoice.invoiceNumber}
</CardTitle>
<p className="text-gray-600 mt-1">Created on {formatDate(invoice.createdAt)}</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusColors[invoice.status as keyof typeof statusColors]}`}>
{statusLabels[invoice.status as keyof typeof statusLabels]}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleStatusUpdate("sent")}
disabled={invoice.status === "sent" || updateStatus.isLoading}
>
<Send className="h-4 w-4 mr-1" />
Mark Sent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleStatusUpdate("paid")}
disabled={invoice.status === "paid" || updateStatus.isLoading}
>
<DollarSign className="h-4 w-4 mr-1" />
Mark Paid
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePDFExport}
disabled={isExportingPDF}
>
<Download className="h-4 w-4 mr-1" />
{isExportingPDF ? "Generating..." : "Export PDF"}
</Button>
</div>
</div>
</div>
</CardHeader>
</Card>
{/* Invoice Details */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Details */}
<div className="lg:col-span-2 space-y-6">
{/* Client Information */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<User className="h-5 w-5 text-emerald-600" />
Client Information
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-gray-700">Client Name</label>
<p className="text-gray-900 font-medium">{invoice.client?.name}</p>
</div>
{invoice.client?.email && (
<div>
<label className="text-sm font-medium text-gray-700">Email</label>
<p className="text-gray-900">{invoice.client.email}</p>
</div>
)}
{invoice.client?.phone && (
<div>
<label className="text-sm font-medium text-gray-700">Phone</label>
<p className="text-gray-900">{invoice.client.phone}</p>
</div>
)}
{(invoice.client?.addressLine1 || invoice.client?.city || invoice.client?.state) && (
<div>
<label className="text-sm font-medium text-gray-700">Address</label>
<p className="text-gray-900">
{[
invoice.client?.addressLine1,
invoice.client?.addressLine2,
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
].filter(Boolean).join(", ")}
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Invoice Items */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900">Invoice Items</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Date</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Description</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Hours</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Rate</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Amount</th>
</tr>
</thead>
<tbody>
{invoice.items?.map((item, index) => (
<tr key={index} className="border-b border-gray-100 hover:bg-emerald-50/30 transition-colors">
<td className="py-3 px-4 text-gray-900">{formatDate(item.date)}</td>
<td className="py-3 px-4 text-gray-900">{item.description}</td>
<td className="py-3 px-4 text-gray-900 text-right">{item.hours}</td>
<td className="py-3 px-4 text-gray-900 text-right">{formatCurrency(item.rate)}</td>
<td className="py-3 px-4 text-gray-900 font-semibold text-right">{formatCurrency(item.amount)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-emerald-200 bg-emerald-50/50">
<td colSpan={4} className="py-4 px-4 text-right font-semibold text-gray-900">Total:</td>
<td className="py-4 px-4 text-right font-bold text-emerald-600 text-lg">{formatCurrency(invoice.totalAmount)}</td>
</tr>
</tfoot>
</table>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Invoice Summary */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Calendar className="h-5 w-5 text-emerald-600" />
Invoice Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700">Issue Date</label>
<p className="text-gray-900">{formatDate(invoice.issueDate)}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Due Date</label>
<p className="text-gray-900">{formatDate(invoice.dueDate)}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Status</label>
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${statusColors[invoice.status as keyof typeof statusColors]}`}>
{statusLabels[invoice.status as keyof typeof statusLabels]}
</span>
</div>
<div className="pt-4 border-t border-gray-200">
<label className="text-lg font-semibold text-gray-900">Total Amount</label>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(invoice.totalAmount)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
{invoice.notes && (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
</CardContent>
</Card>
)}
{/* Actions */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900">Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Button asChild className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium">
<Link href={`/dashboard/invoices/${invoiceId}/edit`}>
<Edit className="h-4 w-4 mr-2" />
Edit Invoice
</Link>
</Button>
<Button
variant="outline"
className="w-full border-gray-300 text-gray-700 hover:bg-gray-50"
onClick={handlePDFExport}
disabled={isExportingPDF}
>
<Download className="h-4 w-4 mr-2" />
{isExportingPDF ? "Generating PDF..." : "Download PDF"}
</Button>
<Button
variant="destructive"
className="w-full bg-red-600 hover:bg-red-700"
onClick={handleDelete}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Invoice
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-gray-800">Delete Invoice</DialogTitle>
<DialogDescription className="text-gray-600">
Are you sure you want to delete this invoice? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={deleteInvoice.isLoading}
>
{deleteInvoice.isLoading ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

40
src/components/logo.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { cn } from "~/lib/utils";
import { CircleDollarSign } from "lucide-react";
interface LogoProps {
className?: string;
size?: "sm" | "md" | "lg";
showIcon?: boolean;
}
export function Logo({ className, size = "md" }: LogoProps) {
const sizeClasses = {
sm: "text-lg",
md: "text-2xl",
lg: "text-4xl",
};
return (
<div className={cn("flex items-center space-x-2", className)}>
<CircleDollarSign className="w-6 h-6 text-green-500"/>
<div className="flex items-center">
<span
className={cn(
"bg-gradient-to-r from-green-600 via-green-700 to-emerald-700 bg-clip-text font-bold tracking-tight text-transparent",
sizeClasses[size],
)}
>
been
</span>
<span
className={cn(
"font-semibold tracking-wide text-gray-800",
sizeClasses[size],
)}
>
voice
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
const navigation = [
{ name: "Dashboard", href: "/dashboard" },
{ name: "Clients", href: "/clients" },
{ name: "Invoices", href: "/invoices" },
];
export function Navigation() {
const pathname = usePathname();
return (
<nav className="flex space-x-2">
{navigation.map((item) => (
<Link key={item.name} href={item.href}>
<Button
variant={pathname === item.href ? "default" : "ghost"}
className={cn(
"transition-colors",
pathname === item.href
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
>
{item.name}
</Button>
</Link>
))}
</nav>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "~/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "~/lib/utils"
import { Button, buttonVariants } from "~/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,77 @@
"use client"
import { format } from "date-fns"
import { Calendar as CalendarIcon } from "lucide-react"
import * as React from "react"
import { Button } from "~/components/ui/button"
import { Calendar } from "~/components/ui/calendar"
import { Label } from "~/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
import { cn } from "~/lib/utils"
interface DatePickerProps {
date?: Date
onDateChange: (date: Date | undefined) => void
label?: string
placeholder?: string
className?: string
disabled?: boolean
required?: boolean
id?: string
}
export function DatePicker({
date,
onDateChange,
label,
placeholder = "Select date",
className,
disabled = false,
required = false,
id
}: DatePickerProps) {
const [open, setOpen] = React.useState(false)
return (
<div className={cn("flex flex-col gap-2", className)}>
{label && (
<Label htmlFor={id} className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id={id}
disabled={disabled}
className={cn(
"w-full justify-between font-normal h-9 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 text-sm",
!date && "text-gray-500"
)}
>
{date ? format(date, "PPP") : placeholder}
<CalendarIcon className="h-4 w-4 text-gray-400" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={date}
captionLayout="dropdown"
onSelect={(selectedDate: Date | undefined) => {
onDateChange(selectedDate)
setOpen(false)
}}
/>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,223 @@
"use client";
import * as React from "react";
import { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { cn } from "~/lib/utils";
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
import { Button } from "./button";
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
accept?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
className?: string;
disabled?: boolean;
placeholder?: string;
description?: string;
}
interface FilePreviewProps {
file: File;
onRemove: () => void;
status?: "success" | "error" | "pending";
error?: string;
}
function FilePreview({ file, onRemove, status = "pending", error }: FilePreviewProps) {
const getStatusIcon = () => {
switch (status) {
case "success":
return <CheckCircle className="h-4 w-4 text-green-600" />;
case "error":
return <AlertCircle className="h-4 w-4 text-red-600" />;
default:
return <FileText className="h-4 w-4 text-gray-400" />;
}
};
const getStatusColor = () => {
switch (status) {
case "success":
return "border-green-200 bg-green-50";
case "error":
return "border-red-200 bg-red-50";
default:
return "border-gray-200 bg-gray-50";
}
};
return (
<div className={cn(
"flex items-center justify-between p-3 rounded-lg border",
getStatusColor()
)}>
<div className="flex items-center gap-3">
{getStatusIcon()}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
<p className="text-xs text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
{error && (
<p className="text-xs text-red-600 mt-1">{error}</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
className="h-6 w-6 p-0 text-gray-400 hover:text-gray-600"
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
export function FileUpload({
onFilesSelected,
accept,
maxFiles = 10,
maxSize = 10 * 1024 * 1024, // 10MB default
className,
disabled = false,
placeholder = "Drag & drop files here, or click to select",
description
}: FileUploadProps) {
const [files, setFiles] = React.useState<File[]>([]);
const [errors, setErrors] = React.useState<Record<string, string>>({});
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: any[]) => {
// Handle accepted files
const newFiles = [...files, ...acceptedFiles];
setFiles(newFiles);
onFilesSelected(newFiles);
// Handle rejected files
const newErrors: Record<string, string> = { ...errors };
rejectedFiles.forEach(({ file, errors }) => {
const errorMessage = errors.map((e: any) => {
if (e.code === 'file-too-large') {
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
}
if (e.code === 'file-invalid-type') {
return 'File type not supported';
}
if (e.code === 'too-many-files') {
return `Too many files. Max is ${maxFiles}`;
}
return e.message;
}).join(', ');
newErrors[file.name] = errorMessage;
});
setErrors(newErrors);
}, [files, onFilesSelected, errors, maxFiles, maxSize]);
const removeFile = (fileToRemove: File) => {
const newFiles = files.filter(file => file !== fileToRemove);
setFiles(newFiles);
onFilesSelected(newFiles);
const newErrors = { ...errors };
delete newErrors[fileToRemove.name];
setErrors(newErrors);
};
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop,
accept,
maxFiles,
maxSize,
disabled
});
return (
<div className={cn("space-y-4", className)}>
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer",
"hover:border-emerald-400 hover:bg-emerald-50/50",
isDragActive && "border-emerald-400 bg-emerald-50/50",
isDragReject && "border-red-400 bg-red-50/50",
disabled && "opacity-50 cursor-not-allowed",
"bg-white/80 backdrop-blur-sm"
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-4">
<div className={cn(
"p-3 rounded-full transition-colors",
isDragActive ? "bg-emerald-100" : "bg-gray-100",
isDragReject && "bg-red-100"
)}>
<Upload className={cn(
"h-6 w-6 transition-colors",
isDragActive ? "text-emerald-600" : "text-gray-400",
isDragReject && "text-red-600"
)} />
</div>
<div className="space-y-2">
<p className={cn(
"text-lg font-medium transition-colors",
isDragActive ? "text-emerald-600" : "text-gray-900",
isDragReject && "text-red-600"
)}>
{isDragActive
? isDragReject
? "File type not supported"
: "Drop files here"
: placeholder
}
</p>
{description && (
<p className="text-sm text-gray-500">{description}</p>
)}
<p className="text-xs text-gray-400">
Max {maxFiles} file{maxFiles !== 1 ? 's' : ''} {(maxSize / 1024 / 1024).toFixed(1)}MB each
</p>
</div>
</div>
</div>
{/* File List */}
{files.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-700">Selected Files</h4>
<div className="space-y-2 max-h-60 overflow-y-auto">
{files.map((file, index) => (
<FilePreview
key={`${file.name}-${index}`}
file={file}
onRemove={() => removeFile(file)}
status={errors[file.name] ? "error" : "success"}
error={errors[file.name]}
/>
))}
</div>
</div>
)}
{/* Error Summary */}
{Object.keys(errors).length > 0 && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-red-600" />
<span className="text-sm font-medium text-red-800">Upload Errors</span>
</div>
<ul className="text-sm text-red-700 space-y-1">
{Object.entries(errors).map(([fileName, error]) => (
<li key={fileName} className="flex items-start gap-2">
<span className="text-red-600"></span>
<span><strong>{fileName}:</strong> {error}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "~/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "~/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "~/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

139
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
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",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "~/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "~/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "~/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,8 @@
// Copied from shadcn/ui documentation
import * as React from "react"
import { Toaster as Sonner } from "sonner"
export function Toaster() {
return <Sonner richColors position="top-center" />
}

View File

@@ -0,0 +1,839 @@
"use client";
import { ChevronDown, Download, FileText, Filter, LayoutGrid, List, Plus, Search, Trash2, UserPlus, Pencil } from "lucide-react";
import Link from "next/link";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
import { Input } from "~/components/ui/input";
import { Skeleton } from "~/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { generateInvoicePDF } from "~/lib/pdf-export";
import { api } from "~/trpc/react";
interface UniversalTableProps {
resource: "clients" | "invoices";
}
interface Client {
id: string;
name: string;
email: string | null;
phone: string | null;
addressLine1: string | null;
addressLine2: string | null;
city: string | null;
state: string | null;
postalCode: string | null;
country: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;
}
interface Invoice {
id: string;
invoiceNumber: string;
clientId: string;
client?: Client;
issueDate: Date;
dueDate: Date;
status: "draft" | "sent" | "paid" | "overdue";
totalAmount: number;
notes: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;
}
const statusColors = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800",
} as const;
const statusLabels = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
} as const;
export function UniversalTable({ resource }: UniversalTableProps) {
const [view, setView] = React.useState<"table" | "grid">("table");
const [search, setSearch] = React.useState("");
const [selected, setSelected] = React.useState<string[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [itemToDelete, setItemToDelete] = React.useState<string | null>(null);
const [sortField, setSortField] = React.useState<string>("");
const [sortDirection, setSortDirection] = React.useState<"asc" | "desc">("desc");
const [currentPage, setCurrentPage] = React.useState(1);
const [itemsPerPage, setItemsPerPage] = React.useState(10);
const [statusFilter, setStatusFilter] = React.useState<string>("all");
const [batchStatusDialogOpen, setBatchStatusDialogOpen] = React.useState(false);
const [exportingPDF, setExportingPDF] = React.useState<string | null>(null);
// Fetch real data for clients or invoices
const { data, isLoading, refetch } = resource === "clients"
? api.clients.getAll.useQuery()
: api.invoices.getAll.useQuery();
const deleteClient = api.clients.delete.useMutation({
onSuccess: () => {
toast.success("Client deleted successfully");
setDeleteDialogOpen(false);
setItemToDelete(null);
setSelected([]);
void refetch();
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete client");
},
});
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
setDeleteDialogOpen(false);
setItemToDelete(null);
setSelected([]);
void refetch();
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete invoice");
},
});
const updateInvoiceStatus = api.invoices.updateStatus.useMutation({
onSuccess: () => {
toast.success("Status updated successfully");
setBatchStatusDialogOpen(false);
setSelected([]);
void refetch();
},
onError: (error) => {
toast.error(error.message ?? "Failed to update status");
},
});
const handleDelete = (id: string) => {
setItemToDelete(id);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (itemToDelete && itemToDelete !== "batch") {
if (resource === "clients") {
deleteClient.mutate({ id: itemToDelete });
} else if (resource === "invoices") {
deleteInvoice.mutate({ id: itemToDelete });
}
}
};
const handleBatchDelete = () => {
if (selected.length > 0) {
setItemToDelete("batch");
setDeleteDialogOpen(true);
}
};
const handleBatchStatusUpdate = (newStatus: "draft" | "sent" | "paid" | "overdue") => {
if (selected.length > 0 && resource === "invoices") {
Promise.all(selected.map(id => updateInvoiceStatus.mutateAsync({ id, status: newStatus })))
.then(() => {
toast.success(`Updated ${selected.length} invoice${selected.length > 1 ? 's' : ''} to ${newStatus}`);
setBatchStatusDialogOpen(false);
setSelected([]);
void refetch();
})
.catch((error) => {
toast.error("Failed to update some invoices");
});
}
};
const handlePDFExport = async (invoice: Invoice) => {
setExportingPDF(invoice.id);
try {
await generateInvoicePDF(invoice);
toast.success("PDF exported successfully");
} catch (error) {
console.error("PDF export error:", error);
toast.error("Failed to export PDF. Please try again.");
} finally {
setExportingPDF(null);
}
};
const confirmBatchDelete = async () => {
if (selected.length > 0) {
try {
if (resource === "clients") {
await Promise.all(selected.map(id => deleteClient.mutateAsync({ id })));
toast.success("Selected clients deleted successfully");
} else if (resource === "invoices") {
await Promise.all(selected.map(id => deleteInvoice.mutateAsync({ id })));
toast.success("Selected invoices deleted successfully");
}
setDeleteDialogOpen(false);
setItemToDelete(null);
setSelected([]);
void refetch();
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : `Failed to delete selected ${resource}`;
toast.error(errorMessage);
}
}
};
// Filter and sort data
const filteredAndSortedData = React.useMemo(() => {
if (!data) return [];
let filtered: (Client | Invoice)[] = data as (Client | Invoice)[];
// Filter by search across all columns
if (search) {
const searchLower = search.toLowerCase();
if (resource === "clients") {
filtered = (data as Client[]).filter((row) =>
(row.name?.toLowerCase().includes(searchLower) ?? false) ||
(row.email?.toLowerCase().includes(searchLower) ?? false) ||
(row.phone?.toLowerCase().includes(searchLower) ?? false) ||
(row.addressLine1?.toLowerCase().includes(searchLower) ?? false) ||
(row.city?.toLowerCase().includes(searchLower) ?? false) ||
(row.state?.toLowerCase().includes(searchLower) ?? false)
);
} else if (resource === "invoices") {
filtered = (data as Invoice[]).filter((row) =>
(row.invoiceNumber?.toLowerCase().includes(searchLower) ?? false) ||
(row.client?.name?.toLowerCase().includes(searchLower) ?? false) ||
(row.client?.email?.toLowerCase().includes(searchLower) ?? false) ||
(row.status?.toLowerCase().includes(searchLower) ?? false) ||
(row.totalAmount?.toString().includes(searchLower) ?? false) ||
(row.notes?.toLowerCase().includes(searchLower) ?? false)
);
}
}
// Filter by status
if (statusFilter !== "all" && resource === "invoices") {
filtered = filtered.filter((row) => (row as Invoice).status === statusFilter);
}
// Sort data
if (sortField) {
filtered = [...filtered].sort((a, b) => {
let aValue: unknown = (a as unknown as Record<string, unknown>)[sortField];
let bValue: unknown = (b as unknown as Record<string, unknown>)[sortField];
// Handle nested properties (e.g., client.name)
if (sortField === "client.name") {
aValue = (a as Invoice).client?.name;
bValue = (b as Invoice).client?.name;
}
// Handle date fields
if (sortField === "issueDate" || sortField === "dueDate" || sortField === "createdAt") {
aValue = new Date(aValue as string | number | Date).getTime();
bValue = new Date(bValue as string | number | Date).getTime();
}
// Handle numeric fields
if (sortField === "totalAmount") {
aValue = parseFloat(String(aValue)) || 0;
bValue = parseFloat(String(bValue)) || 0;
}
// Type-safe comparison
const aNum = typeof aValue === 'number' ? aValue : 0;
const bNum = typeof bValue === 'number' ? bValue : 0;
if (aNum < bNum) return sortDirection === "asc" ? -1 : 1;
if (aNum > bNum) return sortDirection === "asc" ? 1 : -1;
return 0;
});
} else {
// Default sort by date (newest first for invoices, name for clients)
if (resource === "invoices") {
filtered = [...filtered].sort((a, b) => {
const aDate = new Date((a as Invoice).issueDate ?? (a as Invoice).createdAt).getTime();
const bDate = new Date((b as Invoice).issueDate ?? (b as Invoice).createdAt).getTime();
return bDate - aDate; // Newest first
});
} else if (resource === "clients") {
filtered = [...filtered].sort((a, b) => {
return ((a as Client).name ?? "").localeCompare((b as Client).name ?? "");
});
}
}
return filtered;
}, [data, search, resource, sortField, sortDirection, statusFilter]);
// Pagination logic
const totalPages = Math.ceil(filteredAndSortedData.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedData = filteredAndSortedData.slice(startIndex, endIndex);
// Reset to first page when search or filters change
React.useEffect(() => {
setCurrentPage(1);
}, [search, sortField, sortDirection, statusFilter]);
const allSelected = selected.length === (paginatedData?.length ?? 0) && (paginatedData?.length ?? 0) > 0;
const toggleAll = () => setSelected(allSelected ? [] : (paginatedData ?? []).map((d) => d.id));
const toggleOne = (id: string) => setSelected((sel) => sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]);
const handleSort = (field: string) => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection("asc");
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString();
};
const renderTableHeaders = () => {
if (resource === "clients") {
return (
<>
<TableHead className="w-12 py-4 px-4">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label="Select all"
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/>
</TableHead>
<TableHead
className="py-4 px-4 font-semibold text-gray-700 text-base cursor-pointer hover:bg-gray-50"
onClick={() => handleSort("name")}
>
<div className="flex items-center gap-1">
Name
{sortField === "name" && (
<ChevronDown className={`h-3 w-3 transition-transform ${sortDirection === "asc" ? "rotate-180" : ""}`} />
)}
</div>
</TableHead>
<TableHead className="py-4 px-4 font-semibold text-gray-700 text-base">Email</TableHead>
<TableHead className="py-4 px-4 font-semibold text-gray-700 text-base">Phone</TableHead>
<TableHead className="w-8 py-4 px-4"></TableHead>
</>
);
} else if (resource === "invoices") {
return (
<>
<TableHead className="w-12 py-4 px-4">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label="Select all"
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/>
</TableHead>
<TableHead
className="py-4 px-4 font-semibold text-gray-700 text-base cursor-pointer hover:bg-gray-50"
onClick={() => handleSort("invoiceNumber")}
>
<div className="flex items-center gap-1">
Invoice #
{sortField === "invoiceNumber" && (
<ChevronDown className={`h-3 w-3 transition-transform ${sortDirection === "asc" ? "rotate-180" : ""}`} />
)}
</div>
</TableHead>
<TableHead
className="py-4 px-4 font-semibold text-gray-700 text-base cursor-pointer hover:bg-gray-50"
onClick={() => handleSort("client.name")}
>
<div className="flex items-center gap-1">
Client
{sortField === "client.name" && (
<ChevronDown className={`h-3 w-3 transition-transform ${sortDirection === "asc" ? "rotate-180" : ""}`} />
)}
</div>
</TableHead>
<TableHead
className="py-4 px-4 font-semibold text-gray-700 text-base cursor-pointer hover:bg-gray-50"
onClick={() => handleSort("status")}
>
<div className="flex items-center gap-1">
Status
{sortField === "status" && (
<ChevronDown className={`h-3 w-3 transition-transform ${sortDirection === "asc" ? "rotate-180" : ""}`} />
)}
</div>
</TableHead>
<TableHead
className="py-4 px-4 font-semibold text-gray-700 text-base cursor-pointer hover:bg-gray-50"
onClick={() => handleSort("totalAmount")}
>
<div className="flex items-center gap-1">
Total
{sortField === "totalAmount" && (
<ChevronDown className={`h-3 w-3 transition-transform ${sortDirection === "asc" ? "rotate-180" : ""}`} />
)}
</div>
</TableHead>
<TableHead
className="py-4 px-4 font-semibold text-gray-700 text-base cursor-pointer hover:bg-gray-50"
onClick={() => handleSort("dueDate")}
>
<div className="flex items-center gap-1">
Due Date
{sortField === "dueDate" && (
<ChevronDown className={`h-3 w-3 transition-transform ${sortDirection === "asc" ? "rotate-180" : ""}`} />
)}
</div>
</TableHead>
<TableHead className="w-8 py-4 px-4"></TableHead>
</>
);
}
return null;
};
const renderTableRows = () => {
if (isLoading) {
const skeletonCount = resource === "invoices" ? 6 : 5;
return Array.from({ length: skeletonCount }).map((_, index) => (
<TableRow key={`skeleton-${index}`}>
<TableCell className="py-4 px-4"><Skeleton className="h-4 w-4" /></TableCell>
<TableCell className="py-4 px-4"><Skeleton className="h-4 w-32" /></TableCell>
<TableCell className="py-4 px-4"><Skeleton className="h-4 w-40" /></TableCell>
<TableCell className="py-4 px-4"><Skeleton className="h-4 w-28" /></TableCell>
{resource === "invoices" && (
<>
<TableCell className="py-4 px-4"><Skeleton className="h-4 w-20" /></TableCell>
<TableCell className="py-4 px-4"><Skeleton className="h-4 w-24" /></TableCell>
</>
)}
<TableCell className="py-4 px-4"><Skeleton className="h-8 w-8 rounded" /></TableCell>
</TableRow>
));
}
if (paginatedData.length === 0) {
const colSpan = resource === "invoices" ? 7 : 5;
return (
<TableRow>
<TableCell colSpan={colSpan} className="py-12 text-center text-gray-500">
<div className="flex flex-col items-center gap-2">
{resource === "clients" ? (
<UserPlus className="h-8 w-8 text-emerald-400 mb-2" />
) : (
<FileText className="h-8 w-8 text-emerald-400 mb-2" />
)}
<div className="text-lg font-semibold">No {resource} found</div>
<div className="text-gray-500 mb-2">Get started by adding your first {resource.slice(0, -1)}.</div>
<Button asChild className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white font-medium">
<Link href={`/dashboard/${resource}/new`}>
<Plus className="mr-2 h-4 w-4" /> Add {resource.slice(0, -1).charAt(0).toUpperCase() + resource.slice(0, -1).slice(1)}
</Link>
</Button>
</div>
</TableCell>
</TableRow>
);
}
return paginatedData.map((row) => {
if (resource === "clients") {
const client = row as Client;
return (
<TableRow
key={client.id}
data-selected={selected.includes(client.id)}
className="transition-colors hover:bg-emerald-50/60 group cursor-pointer"
onClick={e => {
if ((e.target as HTMLElement).closest('button, input, [role="menuitem"]')) return;
window.location.href = `/dashboard/clients/${client.id}/edit`;
}}
>
<TableCell className="py-4 px-4" onClick={e => e.stopPropagation()}>
<Checkbox
checked={selected.includes(client.id)}
onCheckedChange={() => toggleOne(client.id)}
aria-label="Select row"
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/>
</TableCell>
<TableCell className="py-4 px-4 text-gray-900 text-base font-medium group-hover:text-emerald-700">
<Link href={`/dashboard/clients/${client.id}/edit`} className="hover:underline">
{client.name}
</Link>
</TableCell>
<TableCell className="py-4 px-4 text-gray-700">{client.email}</TableCell>
<TableCell className="py-4 px-4 text-gray-700">{client.phone}</TableCell>
<TableCell className="py-4 px-4" onClick={e => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Actions">
...
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/clients/${client.id}/edit`}>Edit</Link>
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(client.id)}>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
} else if (resource === "invoices") {
const invoice = row as Invoice;
return (
<TableRow
key={invoice.id}
data-selected={selected.includes(invoice.id)}
className="transition-colors hover:bg-emerald-50/60 group cursor-pointer"
onClick={e => {
if ((e.target as HTMLElement).closest('button, input, [role="menuitem"]')) return;
window.location.href = `/dashboard/invoices/${invoice.id}`;
}}
>
<TableCell className="py-4 px-4" onClick={e => e.stopPropagation()}>
<Checkbox
checked={selected.includes(invoice.id)}
onCheckedChange={() => toggleOne(invoice.id)}
aria-label="Select row"
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/>
</TableCell>
<TableCell className="py-4 px-4 text-gray-900 text-base font-medium group-hover:text-emerald-700">
<Link href={`/dashboard/invoices/${invoice.id}`} className="hover:underline">
{invoice.invoiceNumber}
</Link>
</TableCell>
<TableCell className="py-4 px-4 text-gray-700">{invoice.client?.name}</TableCell>
<TableCell className="py-4 px-4">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[invoice.status]}`}>
{statusLabels[invoice.status]}
</span>
</TableCell>
<TableCell className="py-4 px-4 text-gray-700 font-medium">{formatCurrency(invoice.totalAmount)}</TableCell>
<TableCell className="py-4 px-4 text-gray-700">{formatDate(invoice.dueDate)}</TableCell>
<TableCell className="py-4 px-4" onClick={e => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Actions">
...
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(invoice.id)}><Trash2 className="h-4 w-4 mr-2" />Delete</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlePDFExport(invoice)} disabled={exportingPDF === invoice.id}>
<Download className="h-4 w-4 mr-2" />
{exportingPDF === invoice.id ? "Generating..." : "Export"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
}
return null;
});
};
return (
<div className="w-full">
{/* Controls */}
<div className="flex flex-col sm:flex-row sm:items-center gap-4 mb-4 p-4 bg-white/90 rounded-lg border border-gray-200 shadow-sm">
<div className="flex gap-2 items-center">
<Button variant={view === "table" ? "default" : "ghost"} size="icon" onClick={() => setView("table")}> <List className="h-4 w-4" /> </Button>
<Button variant={view === "grid" ? "default" : "ghost"} size="icon" onClick={() => setView("grid")}> <LayoutGrid className="h-4 w-4" /> </Button>
{/* Filter Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Filter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem className="font-medium text-gray-700">Filters</DropdownMenuItem>
{resource === "invoices" && (
<>
<DropdownMenuItem
onClick={() => setStatusFilter("all")}
className={statusFilter === "all" ? "bg-emerald-50 text-emerald-700" : ""}
>
All Statuses
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatusFilter("draft")}
className={statusFilter === "draft" ? "bg-emerald-50 text-emerald-700" : ""}
>
Draft
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatusFilter("sent")}
className={statusFilter === "sent" ? "bg-emerald-50 text-emerald-700" : ""}
>
Sent
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatusFilter("paid")}
className={statusFilter === "paid" ? "bg-emerald-50 text-emerald-700" : ""}
>
Paid
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatusFilter("overdue")}
className={statusFilter === "overdue" ? "bg-emerald-50 text-emerald-700" : ""}
>
Overdue
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex gap-2 items-center min-w-0 flex-1">
<Input
placeholder={`Search ${resource}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
<Button variant="outline" size="icon"><Search className="h-4 w-4" /></Button>
</div>
<div className="flex gap-2 items-center flex-shrink-0">
{selected.length > 0 && (
<>
<span className="text-sm text-gray-500">{selected.length} selected</span>
{resource === "invoices" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Update Status <ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleBatchStatusUpdate("draft")}>
Mark as Draft
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleBatchStatusUpdate("sent")}>
Mark as Sent
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleBatchStatusUpdate("paid")}>
Mark as Paid
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleBatchStatusUpdate("overdue")}>
Mark as Overdue
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button variant="destructive" size="sm" onClick={handleBatchDelete}><Trash2 className="h-4 w-4 mr-1" />Delete</Button>
</>
)}
</div>
</div>
{/* Table View */}
{view === "table" && (
<div className="rounded-2xl shadow-xl border border-gray-200 bg-white/90 overflow-hidden">
<Table className="w-full">
<TableHeader>
<TableRow>
{renderTableHeaders()}
</TableRow>
</TableHeader>
<TableBody>
{renderTableRows()}
</TableBody>
</Table>
</div>
)}
{/* Pagination Controls */}
{view === "table" && totalPages > 1 && (
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 py-3 bg-white/95 border-t border-gray-200 rounded-xl shadow-sm mt-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>
Showing {startIndex + 1} to {Math.min(endIndex, filteredAndSortedData.length)} of {filteredAndSortedData.length} {resource}
</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="w-28 h-8 px-2 py-1 text-sm border border-gray-300 rounded-md bg-white focus:border-emerald-500 focus:ring-emerald-500"
>
<option value={5}>5 per page</option>
<option value={10}>10 per page</option>
<option value={25}>25 per page</option>
<option value={50}>50 per page</option>
</select>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
aria-label="Previous page"
>
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
if (totalPages <= 5) {
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum)}
className="w-8 h-8 p-0"
aria-current={currentPage === pageNum ? "page" : undefined}
>
{pageNum}
</Button>
);
}
if (pageNum === 1 || pageNum === totalPages || (pageNum >= currentPage - 1 && pageNum <= currentPage + 1)) {
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum)}
className="w-8 h-8 p-0"
aria-current={currentPage === pageNum ? "page" : undefined}
>
{pageNum}
</Button>
);
}
if (pageNum === currentPage - 2 || pageNum === currentPage + 2) {
return <span key={pageNum} className="px-2 text-gray-400">...</span>;
}
return null;
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
aria-label="Next page"
>
Next
</Button>
</div>
</div>
)}
{/* Grid View (placeholder) */}
{view === "grid" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{isLoading ? (
Array.from({ length: 6 }).map((_, index) => (
<div key={`skeleton-card-${index}`} className="bg-white/90 rounded-2xl shadow-xl border border-gray-200 p-4 flex flex-col gap-2">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-28" />
</div>
))
) : filteredAndSortedData.length === 0 ? (
<div className="col-span-full flex flex-col items-center py-16 text-gray-500">
{resource === "clients" ? (
<UserPlus className="h-8 w-8 text-emerald-400 mb-2" />
) : (
<FileText className="h-8 w-8 text-emerald-400 mb-2" />
)}
<div className="text-lg font-semibold">No {resource} found</div>
<div className="text-gray-500 mb-2">Get started by adding your first {resource.slice(0, -1)}.</div>
<Button asChild className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white font-medium">
<Link href={`/dashboard/${resource}/new`}>
<Plus className="mr-2 h-4 w-4" /> Add {resource.slice(0, -1).charAt(0).toUpperCase() + resource.slice(0, -1).slice(1)}
</Link>
</Button>
</div>
) : (
filteredAndSortedData.map((row) => {
if (resource === "clients") {
const client = row as Client;
return (
<div key={client.id} className="bg-white/90 rounded-2xl shadow-xl border border-gray-200 p-4 flex flex-col gap-2 transition-colors hover:bg-emerald-50/60 cursor-pointer">
<div className="font-semibold text-lg text-gray-900 group-hover:text-emerald-700">{client.name}</div>
<div className="text-sm text-gray-700">{client.email}</div>
<div className="text-sm text-gray-700">{client.phone}</div>
</div>
);
} else {
const invoice = row as Invoice;
return (
<div key={invoice.id} className="bg-white/90 rounded-2xl shadow-xl border border-gray-200 p-4 flex flex-col gap-2 transition-colors hover:bg-emerald-50/60 cursor-pointer">
<div className="font-semibold text-lg text-gray-900 group-hover:text-emerald-700">{invoice.invoiceNumber}</div>
<div className="text-sm text-gray-700">{invoice.client?.name}</div>
<div className="text-sm text-gray-700">{formatCurrency(invoice.totalAmount)}</div>
</div>
);
}
})
)}
</div>
)}
{/* Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-gray-800">Delete {resource.slice(0, -1).charAt(0).toUpperCase() + resource.slice(0, -1).slice(1)}{itemToDelete === "batch" ? "s" : ""}</DialogTitle>
<DialogDescription className="text-gray-600">
{itemToDelete === "batch"
? `Are you sure you want to delete the selected ${resource}? This action cannot be undone.`
: `Are you sure you want to delete this ${resource.slice(0, -1)}? This action cannot be undone.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={itemToDelete === "batch" ? confirmBatchDelete : confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

48
src/env.js Normal file
View File

@@ -0,0 +1,48 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string()
: z.string().optional(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
AUTH_SECRET: process.env.AUTH_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

212
src/lib/pdf-export.ts Normal file
View File

@@ -0,0 +1,212 @@
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
interface InvoiceData {
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
totalAmount: number;
notes?: string | null;
client?: {
name: string;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
} | null;
items?: Array<{
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}> | null;
}
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
// Create a temporary div to render the invoice
const tempDiv = document.createElement('div');
tempDiv.style.position = 'absolute';
tempDiv.style.left = '-9999px';
tempDiv.style.top = '0';
tempDiv.style.width = '800px';
tempDiv.style.backgroundColor = 'white';
tempDiv.style.padding = '40px';
tempDiv.style.fontFamily = 'Arial, sans-serif';
tempDiv.style.fontSize = '12px';
tempDiv.style.lineHeight = '1.4';
tempDiv.style.color = '#333';
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const statusColors = {
draft: '#6B7280',
sent: '#3B82F6',
paid: '#10B981',
overdue: '#EF4444',
};
const statusLabels = {
draft: 'Draft',
sent: 'Sent',
paid: 'Paid',
overdue: 'Overdue',
};
tempDiv.innerHTML = `
<div style="max-width: 720px; margin: 0 auto;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; border-bottom: 2px solid #10B981; padding-bottom: 20px;">
<div>
<h1 style="margin: 0; font-size: 32px; font-weight: bold; color: #10B981;">beenvoice</h1>
<p style="margin: 5px 0 0 0; color: #6B7280; font-size: 14px;">Professional Invoicing</p>
</div>
<div style="text-align: right;">
<h2 style="margin: 0; font-size: 24px; color: #1F2937;">INVOICE</h2>
<p style="margin: 5px 0 0 0; font-size: 18px; font-weight: bold; color: #10B981;">${invoice.invoiceNumber}</p>
<div style="margin-top: 10px; display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: bold; background-color: ${statusColors[invoice.status as keyof typeof statusColors] || '#6B7280'}; color: white;">
${statusLabels[invoice.status as keyof typeof statusLabels] || invoice.status}
</div>
</div>
</div>
<!-- Invoice Details -->
<div style="display: flex; justify-content: space-between; margin-bottom: 40px;">
<div style="flex: 1;">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #1F2937; border-bottom: 1px solid #E5E7EB; padding-bottom: 5px;">Bill To:</h3>
<div style="font-size: 14px; line-height: 1.6;">
<p style="margin: 0 0 5px 0; font-weight: bold; font-size: 16px;">${invoice.client?.name || 'N/A'}</p>
${invoice.client?.email ? `<p style="margin: 0 0 5px 0;">${invoice.client.email}</p>` : ''}
${invoice.client?.phone ? `<p style="margin: 0 0 5px 0;">${invoice.client.phone}</p>` : ''}
${invoice.client?.addressLine1 || invoice.client?.city || invoice.client?.state ? `
<p style="margin: 0 0 5px 0;">
${[
invoice.client?.addressLine1,
invoice.client?.addressLine2,
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
].filter(Boolean).join(', ')}
</p>
` : ''}
</div>
</div>
<div style="flex: 1; text-align: right;">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #1F2937; border-bottom: 1px solid #E5E7EB; padding-bottom: 5px;">Invoice Details:</h3>
<div style="font-size: 14px; line-height: 1.6;">
<p style="margin: 0 0 5px 0;"><strong>Issue Date:</strong> ${formatDate(invoice.issueDate)}</p>
<p style="margin: 0 0 5px 0;"><strong>Due Date:</strong> ${formatDate(invoice.dueDate)}</p>
<p style="margin: 0 0 5px 0;"><strong>Invoice #:</strong> ${invoice.invoiceNumber}</p>
</div>
</div>
</div>
<!-- Invoice Items Table -->
<div style="margin-bottom: 30px;">
<table style="width: 100%; border-collapse: collapse; border: 1px solid #E5E7EB;">
<thead>
<tr style="background-color: #F9FAFB;">
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: left; font-weight: bold; color: #1F2937;">Date</th>
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: left; font-weight: bold; color: #1F2937;">Description</th>
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; font-weight: bold; color: #1F2937;">Hours</th>
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; font-weight: bold; color: #1F2937;">Rate</th>
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; font-weight: bold; color: #1F2937;">Amount</th>
</tr>
</thead>
<tbody>
${invoice.items?.map(item => `
<tr>
<td style="border: 1px solid #E5E7EB; padding: 12px; color: #374151;">${formatDate(item.date)}</td>
<td style="border: 1px solid #E5E7EB; padding: 12px; color: #374151;">${item.description}</td>
<td style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; color: #374151;">${item.hours}</td>
<td style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; color: #374151;">${formatCurrency(item.rate)}</td>
<td style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; font-weight: bold; color: #374151;">${formatCurrency(item.amount)}</td>
</tr>
`).join('') || ''}
</tbody>
</table>
</div>
<!-- Total -->
<div style="display: flex; justify-content: flex-end; margin-bottom: 30px;">
<div style="width: 300px; border: 2px solid #10B981; border-radius: 8px; padding: 20px; background-color: #F0FDF4;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 18px; font-weight: bold; color: #1F2937;">Total Amount:</span>
<span style="font-size: 24px; font-weight: bold; color: #10B981;">${formatCurrency(invoice.totalAmount)}</span>
</div>
</div>
</div>
<!-- Notes -->
${invoice.notes ? `
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #1F2937; border-bottom: 1px solid #E5E7EB; padding-bottom: 5px;">Notes:</h3>
<div style="background-color: #F9FAFB; border: 1px solid #E5E7EB; border-radius: 6px; padding: 15px; font-size: 14px; line-height: 1.6; color: #374151; white-space: pre-wrap;">
${invoice.notes}
</div>
</div>
` : ''}
<!-- Footer -->
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #E5E7EB; text-align: center; color: #6B7280; font-size: 12px;">
<p style="margin: 0;">Thank you for your business!</p>
<p style="margin: 5px 0 0 0;">Generated by beenvoice - Professional Invoicing Solution</p>
</div>
</div>
`;
document.body.appendChild(tempDiv);
try {
const canvas = await html2canvas(tempDiv, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
width: 800,
height: tempDiv.scrollHeight,
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const imgWidth = 210; // A4 width in mm
const pageHeight = 295; // A4 height in mm
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
while (heightLeft >= 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
pdf.save(`${invoice.invoiceNumber}.pdf`);
} finally {
document.body.removeChild(tempDiv);
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

25
src/server/api/root.ts Normal file
View File

@@ -0,0 +1,25 @@
import { clientsRouter } from "~/server/api/routers/clients";
import { invoicesRouter } from "~/server/api/routers/invoices";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/**
* This is the primary router for your server.
*
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
clients: clientsRouter,
invoices: invoicesRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
/**
* Create a server-side caller for the tRPC API.
* @example
* const trpc = createCaller(createContext);
* const res = await trpc.clients.getAll();
* ^? Client[]
*/
export const createCaller = createCallerFactory(appRouter);

View File

@@ -0,0 +1,70 @@
import { z } from "zod";
import { eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { clients } from "~/server/db/schema";
const createClientSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email").optional(),
phone: z.string().optional(),
addressLine1: z.string().optional(),
addressLine2: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
postalCode: z.string().optional(),
country: z.string().optional(),
});
const updateClientSchema = createClientSchema.partial().extend({
id: z.string(),
});
export const clientsRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.clients.findMany({
where: eq(clients.createdById, ctx.session.user.id),
orderBy: (clients, { desc }) => [desc(clients.createdAt)],
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.clients.findFirst({
where: eq(clients.id, input.id),
with: {
invoices: {
orderBy: (invoices, { desc }) => [desc(invoices.createdAt)],
},
},
});
}),
create: protectedProcedure
.input(createClientSchema)
.mutation(async ({ ctx, input }) => {
return await ctx.db.insert(clients).values({
...input,
createdById: ctx.session.user.id,
});
}),
update: protectedProcedure
.input(updateClientSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return await ctx.db
.update(clients)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(clients.id, id));
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return await ctx.db.delete(clients).where(eq(clients.id, input.id));
}),
});

View File

@@ -0,0 +1,148 @@
import { z } from "zod";
import { eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { invoices, invoiceItems, clients } from "~/server/db/schema";
const invoiceItemSchema = z.object({
date: z.date(),
description: z.string().min(1, "Description is required"),
hours: z.number().min(0, "Hours must be positive"),
rate: z.number().min(0, "Rate must be positive"),
});
const createInvoiceSchema = z.object({
invoiceNumber: z.string().min(1, "Invoice number is required"),
clientId: z.string().min(1, "Client is required"),
issueDate: z.date(),
dueDate: z.date(),
status: z.enum(["draft", "sent", "paid", "overdue"]).default("draft"),
notes: z.string().optional(),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
});
const updateInvoiceSchema = createInvoiceSchema.partial().extend({
id: z.string(),
});
export const invoicesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, ctx.session.user.id),
with: {
client: true,
items: true,
},
orderBy: (invoices, { desc }) => [desc(invoices.createdAt)],
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.id),
with: {
client: true,
items: {
orderBy: (items, { asc }) => [asc(items.date)],
},
},
});
}),
create: protectedProcedure
.input(createInvoiceSchema)
.mutation(async ({ ctx, input }) => {
const { items, ...invoiceData } = input;
// Calculate total amount
const totalAmount = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
// Create invoice
const [invoice] = await ctx.db.insert(invoices).values({
...invoiceData,
totalAmount,
createdById: ctx.session.user.id,
}).returning();
if (!invoice) {
throw new Error("Failed to create invoice");
}
// Create invoice items
const itemsToInsert = items.map(item => ({
...item,
invoiceId: invoice.id,
amount: item.hours * item.rate,
}));
await ctx.db.insert(invoiceItems).values(itemsToInsert);
return invoice;
}),
update: protectedProcedure
.input(updateInvoiceSchema)
.mutation(async ({ ctx, input }) => {
const { id, items, ...invoiceData } = input;
if (items) {
// Calculate total amount
const totalAmount = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
// Update invoice
await ctx.db
.update(invoices)
.set({
...invoiceData,
totalAmount,
updatedAt: new Date(),
})
.where(eq(invoices.id, id));
// Delete existing items and create new ones
await ctx.db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
const itemsToInsert = items.map(item => ({
...item,
invoiceId: id,
amount: item.hours * item.rate,
}));
await ctx.db.insert(invoiceItems).values(itemsToInsert);
} else {
// Update invoice without items
await ctx.db
.update(invoices)
.set({
...invoiceData,
updatedAt: new Date(),
})
.where(eq(invoices.id, id));
}
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Items will be deleted automatically due to cascade
return await ctx.db.delete(invoices).where(eq(invoices.id, input.id));
}),
updateStatus: protectedProcedure
.input(z.object({
id: z.string(),
status: z.enum(["draft", "sent", "paid", "overdue"]),
}))
.mutation(async ({ ctx, input }) => {
return await ctx.db
.update(invoices)
.set({
status: input.status,
updatedAt: new Date(),
})
.where(eq(invoices.id, input.id));
}),
});

View File

@@ -0,0 +1,39 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
import { posts } from "~/server/db/schema";
export const postRouter = createTRPCRouter({
hello: publicProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => {
return {
greeting: `Hello ${input.text}`,
};
}),
create: protectedProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(posts).values({
name: input.name,
createdById: ctx.session.user.id,
});
}),
getLatest: protectedProcedure.query(async ({ ctx }) => {
const post = await ctx.db.query.posts.findFirst({
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
});
return post ?? null;
}),
getSecretMessage: protectedProcedure.query(() => {
return "you can now see this secret message!";
}),
});

133
src/server/api/trpc.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1).
* 2. You want to create a new middleware or type of procedure (see Part 3).
*
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
* need to use are documented accordingly near the end.
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "~/server/auth";
import { db } from "~/server/db";
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API.
*
* These allow you to access things when processing a request, like the database, the session, etc.
*
* This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
* wrap this and provides the required context.
*
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
return {
db,
session,
...opts,
};
};
/**
* 2. INITIALIZATION
*
* This is where the tRPC API is initialized, connecting the context and transformer. We also parse
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
* errors on the backend.
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
* Create a server-side caller.
*
* @see https://trpc.io/docs/server/server-side-calls
*/
export const createCallerFactory = t.createCallerFactory;
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these a lot in the
* "/src/server/api/routers" directory.
*/
/**
* This is how you create new routers and sub-routers in your tRPC API.
*
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
/**
* Middleware for timing procedure execution and adding an artificial delay in development.
*
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
});
/**
* Public (unauthenticated) procedure
*
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(timingMiddleware);
/**
* Protected (authenticated) procedure
*
* If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
* the session is valid and guarantees `ctx.session.user` is not null.
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});

110
src/server/auth/config.ts Normal file
View File

@@ -0,0 +1,110 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { type DefaultSession, type NextAuthConfig } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { db } from "~/server/db";
import {
accounts,
sessions,
users,
verificationTokens,
} from "~/server/db/schema";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authConfig = {
session: {
strategy: "jwt",
},
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await db.query.users.findFirst({
where: eq(users.email, credentials.email),
});
if (!user || !user.password) {
return null;
}
const isPasswordValid = await bcrypt.compare(credentials.password, user.password);
if (!isPasswordValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
}
}),
],
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
callbacks: {
session: ({ session, token }) => ({
...session,
user: {
...session.user,
id: token.sub,
},
}),
jwt: ({ token, user }) => {
if (user) {
token.sub = user.id;
}
return token;
},
redirect: ({ url, baseUrl }) => {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
// Allows callback URLs on the same origin
else if (new URL(url).origin === baseUrl) return url;
return baseUrl + "/dashboard";
},
},
pages: {
signIn: "/auth/signin",
},
} satisfies NextAuthConfig;

10
src/server/auth/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import NextAuth from "next-auth";
import { cache } from "react";
import { authConfig } from "./config";
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
const auth = cache(uncachedAuth);
export { auth, handlers, signIn, signOut };

19
src/server/db/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createClient, type Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { env } from "~/env";
import * as schema from "./schema";
/**
* Cache the database connection in development. This avoids creating a new connection on every HMR
* update.
*/
const globalForDb = globalThis as unknown as {
client: Client | undefined;
};
export const client =
globalForDb.client ?? createClient({ url: env.DATABASE_URL });
if (env.NODE_ENV !== "production") globalForDb.client = client;
export const db = drizzle(client, { schema });

202
src/server/db/schema.ts Normal file
View File

@@ -0,0 +1,202 @@
import { relations, sql } from "drizzle-orm";
import { index, primaryKey, sqliteTableCreator } from "drizzle-orm/sqlite-core";
import { type AdapterAccount } from "next-auth/adapters";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
*
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
export const createTable = sqliteTableCreator((name) => `beenvoice_${name}`);
// Auth-related tables (keeping existing)
export const users = createTable("user", (d) => ({
id: d
.text({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.text({ length: 255 }),
email: d.text({ length: 255 }).notNull(),
password: d.text({ length: 255 }),
emailVerified: d.integer({ mode: "timestamp" }).default(sql`(unixepoch())`),
image: d.text({ length: 255 }),
}));
export const usersRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
clients: many(clients),
invoices: many(invoices),
}));
export const accounts = createTable(
"account",
(d) => ({
userId: d
.text({ length: 255 })
.notNull()
.references(() => users.id),
type: d.text({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.text({ length: 255 }).notNull(),
providerAccountId: d.text({ length: 255 }).notNull(),
refresh_token: d.text(),
access_token: d.text(),
expires_at: d.integer(),
token_type: d.text({ length: 255 }),
scope: d.text({ length: 255 }),
id_token: d.text(),
session_state: d.text({ length: 255 }),
}),
(t) => [
primaryKey({
columns: [t.provider, t.providerAccountId],
}),
index("account_user_id_idx").on(t.userId),
],
);
export const accountsRelations = relations(accounts, ({ one }) => ({
user: one(users, { fields: [accounts.userId], references: [users.id] }),
}));
export const sessions = createTable(
"session",
(d) => ({
sessionToken: d.text({ length: 255 }).notNull().primaryKey(),
userId: d
.text({ length: 255 })
.notNull()
.references(() => users.id),
expires: d.integer({ mode: "timestamp" }).notNull(),
}),
(t) => [index("session_userId_idx").on(t.userId)],
);
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const verificationTokens = createTable(
"verification_token",
(d) => ({
identifier: d.text({ length: 255 }).notNull(),
token: d.text({ length: 255 }).notNull(),
expires: d.integer({ mode: "timestamp" }).notNull(),
}),
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
);
// Invoicing app tables
export const clients = createTable(
"client",
(d) => ({
id: d
.text({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.text({ length: 255 }).notNull(),
email: d.text({ length: 255 }),
phone: d.text({ length: 50 }),
addressLine1: d.text({ length: 255 }),
addressLine2: d.text({ length: 255 }),
city: d.text({ length: 100 }),
state: d.text({ length: 50 }),
postalCode: d.text({ length: 20 }),
country: d.text({ length: 100 }),
createdById: d
.text({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.notNull(),
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
}),
(t) => [
index("client_created_by_idx").on(t.createdById),
index("client_name_idx").on(t.name),
index("client_email_idx").on(t.email),
],
);
export const clientsRelations = relations(clients, ({ one, many }) => ({
createdBy: one(users, { fields: [clients.createdById], references: [users.id] }),
invoices: many(invoices),
}));
export const invoices = createTable(
"invoice",
(d) => ({
id: d
.text({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
invoiceNumber: d.text({ length: 100 }).notNull(),
clientId: d
.text({ length: 255 })
.notNull()
.references(() => clients.id),
issueDate: d.integer({ mode: "timestamp" }).notNull(),
dueDate: d.integer({ mode: "timestamp" }).notNull(),
status: d.text({ length: 50 }).notNull().default("draft"), // draft, sent, paid, overdue
totalAmount: d.real().notNull().default(0),
notes: d.text({ length: 1000 }),
createdById: d
.text({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.notNull(),
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
}),
(t) => [
index("invoice_client_id_idx").on(t.clientId),
index("invoice_created_by_idx").on(t.createdById),
index("invoice_number_idx").on(t.invoiceNumber),
index("invoice_status_idx").on(t.status),
],
);
export const invoicesRelations = relations(invoices, ({ one, many }) => ({
client: one(clients, { fields: [invoices.clientId], references: [clients.id] }),
createdBy: one(users, { fields: [invoices.createdById], references: [users.id] }),
items: many(invoiceItems),
}));
export const invoiceItems = createTable(
"invoice_item",
(d) => ({
id: d
.text({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
invoiceId: d
.text({ length: 255 })
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
date: d.integer({ mode: "timestamp" }).notNull(),
description: d.text({ length: 500 }).notNull(),
hours: d.real().notNull(),
rate: d.real().notNull(),
amount: d.real().notNull(),
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.notNull(),
}),
(t) => [
index("invoice_item_invoice_id_idx").on(t.invoiceId),
index("invoice_item_date_idx").on(t.date),
],
);
export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
invoice: one(invoices, { fields: [invoiceItems.invoiceId], references: [invoices.id] }),
}));

125
src/styles/globals.css Normal file
View File

@@ -0,0 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

25
src/trpc/query-client.ts Normal file
View File

@@ -0,0 +1,25 @@
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import SuperJSON from "superjson";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
},
});

81
src/trpc/react.tsx Normal file
View File

@@ -0,0 +1,81 @@
"use client";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { httpBatchStreamLink, loggerLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";
import { SessionProvider } from "next-auth/react";
import { type AppRouter } from "~/server/api/root";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
}
// Browser: use singleton pattern to keep the same query client
clientQueryClientSingleton ??= createQueryClient();
return clientQueryClientSingleton;
};
export const api = createTRPCReact<AppRouter>();
/**
* Inference helper for inputs.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs.
*
* @example type HelloOutput = RouterOutputs['example']['hello']
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchStreamLink({
transformer: SuperJSON,
url: getBaseUrl() + "/api/trpc",
headers: () => {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
}),
);
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
</SessionProvider>
);
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}

30
src/trpc/server.ts Normal file
View File

@@ -0,0 +1,30 @@
import "server-only";
import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { headers } from "next/headers";
import { cache } from "react";
import { createCaller, type AppRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
import { createQueryClient } from "./query-client";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
headers: heads,
});
});
const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
caller,
getQueryClient,
);

72
tailwind.config.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-geist-sans)", "ui-sans-serif", "system-ui", "sans-serif"],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;

42
tsconfig.json Normal file
View File

@@ -0,0 +1,42 @@
{
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}