mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
Compare commits
10 Commits
1b6dfbb460
...
74f9696023
| Author | SHA1 | Date | |
|---|---|---|---|
| 74f9696023 | |||
| 1f76cf38a7 | |||
| e5242b37a4 | |||
| 38206f34fe | |||
| e950abd805 | |||
| 4c0eae4b11 | |||
| e6b79ce2c2 | |||
| ba14526fc5 | |||
| 563d77ba65 | |||
| fb5ffc3195 |
@@ -8,8 +8,6 @@ README.md
|
|||||||
*.log
|
*.log
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
drizzle/*.sql
|
|
||||||
drizzle/*-journal
|
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ RUN bun install --frozen-lockfile --production --verbose
|
|||||||
COPY --from=builder /app/start.sh ./start.sh
|
COPY --from=builder /app/start.sh ./start.sh
|
||||||
COPY --from=builder /app/next.config.js ./next.config.js
|
COPY --from=builder /app/next.config.js ./next.config.js
|
||||||
COPY --from=builder /app/src ./src
|
COPY --from=builder /app/src ./src
|
||||||
|
COPY --from=builder /app/drizzle ./drizzle
|
||||||
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
|
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
|
||||||
COPY --from=builder /app/.env.example ./.env.example
|
COPY --from=builder /app/.env.example ./.env.example
|
||||||
|
|
||||||
|
|||||||
@@ -8,23 +8,29 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **🔐 Secure Authentication** - Email/password registration and sign-in with NextAuth.js
|
- **🔐 Secure Authentication** - Email/password registration and sign-in with better-auth, plus SSO via Authentik OIDC
|
||||||
- **👥 Client Management** - Create, edit, and manage client information
|
- **👥 Client Management** - Create, edit, and manage client information
|
||||||
|
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
|
||||||
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
||||||
|
- **📅 Timesheet View** - Calendar-based time entry with month and week views
|
||||||
|
- **📧 Email Delivery** - Send invoices via email using Resend
|
||||||
|
- **📥 PDF Export** - Download invoices as professional PDFs
|
||||||
|
- **📊 CSV Import** - Bulk import invoice data from CSV files
|
||||||
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
||||||
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
||||||
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
||||||
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
||||||
- **💾 Local Database** - SQLite database with Drizzle ORM
|
- **💾 PostgreSQL Database** - Robust relational database with Drizzle ORM
|
||||||
|
|
||||||
## 🚀 Tech Stack
|
## 🚀 Tech Stack
|
||||||
|
|
||||||
- **Frontend**: Next.js 15 with App Router
|
- **Frontend**: Next.js 16 with App Router
|
||||||
- **Backend**: tRPC for type-safe API calls
|
- **Backend**: tRPC for type-safe API calls
|
||||||
- **Database**: Drizzle ORM with LibSQL (SQLite)
|
- **Database**: Drizzle ORM with PostgreSQL
|
||||||
- **Authentication**: NextAuth.js with email/password
|
- **Authentication**: better-auth with email/password and Authentik OIDC SSO
|
||||||
- **UI Components**: shadcn/ui with Tailwind CSS
|
- **UI Components**: shadcn/ui with Tailwind CSS v4
|
||||||
- **Styling**: Geist font family
|
- **Email**: Resend for transactional email delivery
|
||||||
|
- **PDF**: @react-pdf/renderer for invoice PDF generation
|
||||||
- **Package Manager**: Bun
|
- **Package Manager**: Bun
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
@@ -32,6 +38,7 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+ or Bun
|
- Node.js 18+ or Bun
|
||||||
|
- Docker & Docker Compose (for local PostgreSQL)
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
@@ -43,7 +50,6 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Install dependencies**
|
2. **Install dependencies**
|
||||||
```bash
|
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
@@ -55,22 +61,39 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
|
|
||||||
Edit `.env.local` and add your configuration:
|
Edit `.env.local` and add your configuration:
|
||||||
```env
|
```env
|
||||||
DATABASE_URL="file:./db.sqlite"
|
# Database
|
||||||
NEXTAUTH_SECRET="your-secret-key-here"
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
DB_DISABLE_SSL="true"
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
AUTH_SECRET="your-secret-key-here"
|
||||||
|
BETTER_AUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# Email (optional for local dev)
|
||||||
|
RESEND_API_KEY="your-resend-api-key"
|
||||||
|
RESEND_DOMAIN="yourdomain.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Initialize the database**
|
4. **Start the database**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Push the database schema**
|
||||||
```bash
|
```bash
|
||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Start the development server**
|
6. **Start the development server**
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Open your browser**
|
7. **Open your browser**
|
||||||
Navigate to [http://localhost:3000](http://localhost:3000)
|
Navigate to [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
## 🏗️ Project Structure
|
## 🏗️ Project Structure
|
||||||
@@ -79,21 +102,28 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
beenvoice/
|
beenvoice/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/ # Next.js App Router pages
|
│ ├── app/ # Next.js App Router pages
|
||||||
│ │ ├── api/ # API routes (NextAuth, tRPC)
|
│ │ ├── api/ # API routes (better-auth, tRPC)
|
||||||
│ │ ├── auth/ # Authentication pages
|
│ │ ├── auth/ # Authentication pages
|
||||||
│ │ ├── clients/ # Client management pages
|
│ │ ├── dashboard/ # Main app pages
|
||||||
│ │ ├── invoices/ # Invoice management pages
|
│ │ │ ├── clients/ # Client management pages
|
||||||
|
│ │ │ ├── invoices/ # Invoice management pages
|
||||||
|
│ │ │ └── businesses/ # Business profile pages
|
||||||
│ │ └── _components/ # Page-specific components
|
│ │ └── _components/ # Page-specific components
|
||||||
│ ├── components/ # Shared UI components
|
│ ├── components/ # Shared UI components
|
||||||
|
│ │ ├── ui/ # shadcn/ui components
|
||||||
|
│ │ ├── data/ # Data display components
|
||||||
|
│ │ ├── forms/ # Form components
|
||||||
|
│ │ └── layout/ # Layout components
|
||||||
│ ├── server/ # Server-side code
|
│ ├── server/ # Server-side code
|
||||||
│ │ ├── api/ # tRPC routers
|
│ │ ├── api/ # tRPC routers
|
||||||
│ │ ├── auth/ # NextAuth configuration
|
|
||||||
│ │ └── db/ # Database schema and connection
|
│ │ └── db/ # Database schema and connection
|
||||||
|
│ ├── lib/ # Utilities (auth, pdf export, etc.)
|
||||||
│ ├── styles/ # Global styles
|
│ ├── styles/ # Global styles
|
||||||
│ └── trpc/ # tRPC client configuration
|
│ └── trpc/ # tRPC client configuration
|
||||||
├── drizzle/ # Database migrations
|
├── drizzle/ # Database migrations
|
||||||
├── public/ # Static assets
|
├── public/ # Static assets
|
||||||
└── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
|
└── docker-compose.yml # Local PostgreSQL setup
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎯 Usage
|
## 🎯 Usage
|
||||||
@@ -103,41 +133,53 @@ beenvoice/
|
|||||||
1. **Register an Account**
|
1. **Register an Account**
|
||||||
- Visit the sign-up page
|
- Visit the sign-up page
|
||||||
- Enter your name, email, and password
|
- Enter your name, email, and password
|
||||||
- Verify your email (if configured)
|
|
||||||
|
|
||||||
2. **Add Your First Client**
|
2. **Set Up Your Business**
|
||||||
|
- Navigate to Business Settings
|
||||||
|
- Add your business name, contact info, and logo
|
||||||
|
- Configure email settings for invoice delivery (Resend API key + domain)
|
||||||
|
|
||||||
|
3. **Add Your First Client**
|
||||||
- Navigate to the Clients page
|
- Navigate to the Clients page
|
||||||
- Click "Add New Client"
|
- Click "Add New Client"
|
||||||
- Fill in client details (name, email, phone, address)
|
- Fill in client details (name, email, phone, address)
|
||||||
|
|
||||||
3. **Create an Invoice**
|
4. **Create an Invoice**
|
||||||
- Go to the Invoices page
|
- Go to the Invoices page
|
||||||
- Click "Create New Invoice"
|
- Click "Create New Invoice"
|
||||||
- Select a client
|
- Select a client and optionally a business profile
|
||||||
- Add line items with descriptions, dates, hours, and rates
|
- Add line items with descriptions, dates, hours, and rates
|
||||||
- Save and generate your invoice
|
- Use the Timesheet tab for calendar-based time entry
|
||||||
|
- Save and send or download as PDF
|
||||||
|
|
||||||
### Features Overview
|
### Features Overview
|
||||||
|
|
||||||
#### Client Management
|
#### Client Management
|
||||||
- Create and edit client profiles
|
- Create and edit client profiles
|
||||||
- Store contact information and addresses
|
- Store contact information and addresses
|
||||||
|
- Set default hourly rates per client
|
||||||
- Search and filter client list
|
- Search and filter client list
|
||||||
- View client history
|
|
||||||
|
|
||||||
#### Invoice Creation
|
#### Invoice Creation
|
||||||
- Select from existing clients
|
- Select from existing clients and business profiles
|
||||||
- Add multiple line items
|
- Add multiple line items with drag-and-drop reordering
|
||||||
- Set custom rates per item
|
- Set custom rates per item
|
||||||
- Automatic total calculations
|
- Automatic total calculations with configurable tax rate
|
||||||
|
- Timesheet calendar view for date-based time tracking
|
||||||
- Professional invoice formatting
|
- Professional invoice formatting
|
||||||
|
|
||||||
|
#### Invoice Delivery
|
||||||
|
- Send invoices via email directly from the app
|
||||||
|
- Rich text email composer with preview
|
||||||
|
- Resend and re-deliver sent invoices
|
||||||
|
- Track invoice status: Draft → Sent → Paid (+ Overdue)
|
||||||
|
|
||||||
#### User Interface
|
#### User Interface
|
||||||
- Clean, modern design
|
- Clean, modern design
|
||||||
- Responsive layout
|
- Fully responsive — desktop, tablet, and mobile
|
||||||
- Intuitive navigation
|
- Intuitive navigation with breadcrumbs
|
||||||
- Toast notifications for feedback
|
- Toast notifications for feedback
|
||||||
- Modal dialogs for forms
|
- Dark mode support
|
||||||
|
|
||||||
## 🔧 Development
|
## 🔧 Development
|
||||||
|
|
||||||
@@ -145,44 +187,53 @@ beenvoice/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
bun run dev # Start development server
|
bun run dev # Start development server (Turbo)
|
||||||
bun run build # Build for production
|
bun run build # Build for production
|
||||||
bun run start # Start production server
|
bun run start # Start production server
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
bun run db:push # Push schema changes to database
|
bun run db:push # Push schema changes to database
|
||||||
|
bun run db:migrate # Run migrations
|
||||||
bun run db:studio # Open Drizzle Studio
|
bun run db:studio # Open Drizzle Studio
|
||||||
bun run db:generate # Generate new migration
|
bun run db:generate # Generate new migration
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
bun run docker:up # Start local PostgreSQL via Docker
|
||||||
|
bun run docker:down # Stop Docker services
|
||||||
|
|
||||||
# Code Quality
|
# Code Quality
|
||||||
bun run lint # Run ESLint
|
bun run lint # Run ESLint
|
||||||
bun run format # Format code with Prettier
|
bun run lint:fix # Fix ESLint issues
|
||||||
bun run type-check # Run TypeScript type checking
|
bun run format:write # Format code with Prettier
|
||||||
|
bun run typecheck # Run TypeScript type checking
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
|
|
||||||
The application uses four main tables:
|
The application uses the following core tables:
|
||||||
|
|
||||||
- **users**: User accounts and authentication
|
- **users** - User accounts and authentication
|
||||||
- **clients**: Client information and contact details
|
- **sessions** - Active user sessions
|
||||||
- **invoices**: Invoice headers with client relationships
|
- **clients** - Client information and contact details
|
||||||
- **invoice_items**: Individual line items with pricing
|
- **businesses** - Business profiles with email/logo settings
|
||||||
|
- **invoices** - Invoice headers with client and business relationships
|
||||||
|
- **invoice_items** - Individual line items with pricing and position ordering
|
||||||
|
|
||||||
### API Development
|
### API Development
|
||||||
|
|
||||||
All API endpoints are built with tRPC for type safety:
|
All API endpoints are built with tRPC for type safety:
|
||||||
|
|
||||||
- **Authentication**: NextAuth.js integration
|
- **Authentication**: better-auth integration (email/password + OIDC)
|
||||||
- **Clients**: CRUD operations for client management
|
- **Clients**: CRUD operations for client management
|
||||||
- **Invoices**: Invoice creation and management
|
- **Businesses**: Business profile management
|
||||||
|
- **Invoices**: Invoice creation, management, and status tracking
|
||||||
- **Validation**: Zod schemas for input validation
|
- **Validation**: Zod schemas for input validation
|
||||||
|
|
||||||
## 🎨 Customization
|
## 🎨 Customization
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
|
|
||||||
The app uses Tailwind CSS with a custom design system:
|
The app uses Tailwind CSS v4 with a custom design system:
|
||||||
|
|
||||||
- **Primary Color**: Green (#16a34a)
|
- **Primary Color**: Green (#16a34a)
|
||||||
- **Font**: Geist for professional typography
|
- **Font**: Geist for professional typography
|
||||||
@@ -198,38 +249,54 @@ Update the logo and colors in:
|
|||||||
|
|
||||||
## 🚀 Deployment
|
## 🚀 Deployment
|
||||||
|
|
||||||
### Deployment
|
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
|
||||||
|
|
||||||
You can deploy this application to any platform that supports Next.js (Docker, Coolify, Railway, etc.).
|
1. **Build the application:**
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
1. Build the application:
|
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
|
||||||
```bash
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the server:
|
3. **Run database migrations:**
|
||||||
```bash
|
```bash
|
||||||
bun start
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
### Other Platforms
|
4. **Start the server:**
|
||||||
|
```bash
|
||||||
The app can be deployed to any platform that supports Next.js:
|
bun start
|
||||||
|
```
|
||||||
- **Netlify**: Use the Next.js build command
|
|
||||||
- **Railway**: Connect your GitHub repository
|
|
||||||
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Required for production:
|
Required for production:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL="your-database-url"
|
DATABASE_URL="postgresql://user:password@host:5432/dbname"
|
||||||
NEXTAUTH_SECRET="your-secret-key"
|
AUTH_SECRET="your-long-random-secret"
|
||||||
NEXTAUTH_URL="https://your-domain.com"
|
BETTER_AUTH_URL="https://your-domain.com"
|
||||||
|
NEXT_PUBLIC_APP_URL="https://your-domain.com"
|
||||||
|
NODE_ENV="production"
|
||||||
|
|
||||||
|
# Email (required for invoice sending)
|
||||||
|
RESEND_API_KEY="re_xxxxxxxxxxxx"
|
||||||
|
RESEND_DOMAIN="yourdomain.com"
|
||||||
|
|
||||||
|
# Optional: Authentik SSO
|
||||||
|
AUTHENTIK_ISSUER="https://your-authentik-instance/application/o/beenvoice/"
|
||||||
|
AUTHENTIK_CLIENT_ID="your-client-id"
|
||||||
|
AUTHENTIK_CLIENT_SECRET="your-client-secret"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Other Platforms
|
||||||
|
|
||||||
|
The app can be deployed to any platform that supports Next.js:
|
||||||
|
|
||||||
|
- **Coolify**: Deploy with Docker Compose support
|
||||||
|
- **Railway**: Connect your GitHub repository (includes managed PostgreSQL)
|
||||||
|
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
@@ -243,8 +310,7 @@ NEXTAUTH_URL="https://your-domain.com"
|
|||||||
- Follow TypeScript best practices
|
- Follow TypeScript best practices
|
||||||
- Use shadcn/ui components for consistency
|
- Use shadcn/ui components for consistency
|
||||||
- Implement proper error handling
|
- Implement proper error handling
|
||||||
- Add tests for new features
|
- Follow the existing code style (Prettier + ESLint configs provided)
|
||||||
- Follow the existing code style
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -254,14 +320,14 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||||||
|
|
||||||
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
||||||
- [NextAuth.js](https://next-auth.js.org/) for authentication
|
- [better-auth](https://www.better-auth.com/) for modern authentication
|
||||||
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
||||||
|
- [Resend](https://resend.com/) for reliable email delivery
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
||||||
- **Email**: support@beenvoice.com
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,11 @@
|
|||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^16.1.1",
|
"next": "^16.2.2",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.4",
|
||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "^9.12.0",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.4",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"recharts": "^3.5.1",
|
"recharts": "^3.5.1",
|
||||||
"resend": "^4.8.0",
|
"resend": "^4.8.0",
|
||||||
@@ -310,25 +310,25 @@
|
|||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" } }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" } }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="],
|
||||||
|
|
||||||
"@next/env": ["@next/env@16.1.1", "", {}, "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA=="],
|
"@next/env": ["@next/env@16.2.2", "", {}, "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ=="],
|
||||||
|
|
||||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.0.10", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-b2NlWN70bbPLmfyoLvvidPKWENBYYIe017ZGUpElvQjDytCWgxPJx7L9juxHt0xHvNVA08ZHJdOyhGzon/KJuw=="],
|
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.0.10", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-b2NlWN70bbPLmfyoLvvidPKWENBYYIe017ZGUpElvQjDytCWgxPJx7L9juxHt0xHvNVA08ZHJdOyhGzon/KJuw=="],
|
||||||
|
|
||||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA=="],
|
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg=="],
|
||||||
|
|
||||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw=="],
|
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ=="],
|
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg=="],
|
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ=="],
|
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA=="],
|
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg=="],
|
||||||
|
|
||||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA=="],
|
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g=="],
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw=="],
|
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA=="],
|
||||||
|
|
||||||
"@noble/ciphers": ["@noble/ciphers@2.0.1", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="],
|
"@noble/ciphers": ["@noble/ciphers@2.0.1", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="],
|
||||||
|
|
||||||
@@ -1238,7 +1238,7 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"next": ["next@16.1.1", "", { "dependencies": { "@next/env": "16.1.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.1", "@next/swc-darwin-x64": "16.1.1", "@next/swc-linux-arm64-gnu": "16.1.1", "@next/swc-linux-arm64-musl": "16.1.1", "@next/swc-linux-x64-gnu": "16.1.1", "@next/swc-linux-x64-musl": "16.1.1", "@next/swc-win32-arm64-msvc": "16.1.1", "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w=="],
|
"next": ["next@16.2.2", "", { "dependencies": { "@next/env": "16.2.2", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.2", "@next/swc-darwin-x64": "16.2.2", "@next/swc-linux-arm64-gnu": "16.2.2", "@next/swc-linux-arm64-musl": "16.2.2", "@next/swc-linux-x64-gnu": "16.2.2", "@next/swc-linux-x64-musl": "16.2.2", "@next/swc-win32-arm64-msvc": "16.2.2", "@next/swc-win32-x64-msvc": "16.2.2", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A=="],
|
||||||
|
|
||||||
"node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="],
|
"node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="],
|
||||||
|
|
||||||
@@ -1376,11 +1376,11 @@
|
|||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
"react-day-picker": ["react-day-picker@9.12.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-t8OvG/Zrciso5CQJu5b1A7yzEmebvST+S3pOVQJWxwjjVngyG/CA2htN/D15dLI4uTEuLLkbZyS4YYt480FAtA=="],
|
"react-day-picker": ["react-day-picker@9.12.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-t8OvG/Zrciso5CQJu5b1A7yzEmebvST+S3pOVQJWxwjjVngyG/CA2htN/D15dLI4uTEuLLkbZyS4YYt480FAtA=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
|
|
||||||
"react-dropzone": ["react-dropzone@14.3.8", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug=="],
|
"react-dropzone": ["react-dropzone@14.3.8", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug=="],
|
||||||
|
|
||||||
@@ -1720,6 +1720,8 @@
|
|||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"next/baseline-browser-mapping": ["baseline-browser-mapping@2.10.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA=="],
|
||||||
|
|
||||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||||
|
|
||||||
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ if (!process.env.DATABASE_URL) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: "./src/server/db/schema.ts",
|
schema: "./src/server/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
CREATE TABLE "beenvoice_account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"userId" varchar(255) NOT NULL,
|
||||||
|
"accountId" varchar(255) NOT NULL,
|
||||||
|
"providerId" varchar(255) NOT NULL,
|
||||||
|
"accessToken" text,
|
||||||
|
"refreshToken" text,
|
||||||
|
"accessTokenExpiresAt" timestamp,
|
||||||
|
"refreshTokenExpiresAt" timestamp,
|
||||||
|
"scope" varchar(255),
|
||||||
|
"idToken" text,
|
||||||
|
"password" text,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_business" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"nickname" varchar(255),
|
||||||
|
"email" varchar(255),
|
||||||
|
"phone" varchar(50),
|
||||||
|
"addressLine1" varchar(255),
|
||||||
|
"addressLine2" varchar(255),
|
||||||
|
"city" varchar(100),
|
||||||
|
"state" varchar(50),
|
||||||
|
"postalCode" varchar(20),
|
||||||
|
"country" varchar(100),
|
||||||
|
"website" varchar(255),
|
||||||
|
"taxId" varchar(100),
|
||||||
|
"logoUrl" varchar(500),
|
||||||
|
"isDefault" boolean DEFAULT false,
|
||||||
|
"resendApiKey" varchar(255),
|
||||||
|
"resendDomain" varchar(255),
|
||||||
|
"emailFromName" varchar(255),
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_client" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"email" varchar(255),
|
||||||
|
"phone" varchar(50),
|
||||||
|
"addressLine1" varchar(255),
|
||||||
|
"addressLine2" varchar(255),
|
||||||
|
"city" varchar(100),
|
||||||
|
"state" varchar(50),
|
||||||
|
"postalCode" varchar(20),
|
||||||
|
"country" varchar(100),
|
||||||
|
"defaultHourlyRate" real,
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_invoice_item" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"invoiceId" varchar(255) NOT NULL,
|
||||||
|
"date" timestamp NOT NULL,
|
||||||
|
"description" varchar(500) NOT NULL,
|
||||||
|
"hours" real NOT NULL,
|
||||||
|
"rate" real NOT NULL,
|
||||||
|
"amount" real NOT NULL,
|
||||||
|
"position" integer DEFAULT 0 NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_invoice" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"invoiceNumber" varchar(100) NOT NULL,
|
||||||
|
"businessId" varchar(255),
|
||||||
|
"clientId" varchar(255) NOT NULL,
|
||||||
|
"issueDate" timestamp NOT NULL,
|
||||||
|
"dueDate" timestamp NOT NULL,
|
||||||
|
"status" varchar(50) DEFAULT 'draft' NOT NULL,
|
||||||
|
"totalAmount" real DEFAULT 0 NOT NULL,
|
||||||
|
"taxRate" real DEFAULT 0 NOT NULL,
|
||||||
|
"notes" varchar(1000),
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_session" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"userId" varchar(255) NOT NULL,
|
||||||
|
"token" varchar(255) NOT NULL,
|
||||||
|
"expiresAt" timestamp NOT NULL,
|
||||||
|
"ipAddress" text,
|
||||||
|
"userAgent" text,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "beenvoice_session_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_sso_provider" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"providerId" varchar(255) NOT NULL,
|
||||||
|
"userId" varchar(255) NOT NULL,
|
||||||
|
"redirectURI" varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
"oidcConfig" text,
|
||||||
|
"samlConfig" text,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "beenvoice_sso_provider_providerId_unique" UNIQUE("providerId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_user" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"email" varchar(255) NOT NULL,
|
||||||
|
"emailVerified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" varchar(255),
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"password" varchar(255),
|
||||||
|
"resetToken" varchar(255),
|
||||||
|
"resetTokenExpiry" timestamp,
|
||||||
|
"prefersReducedMotion" boolean DEFAULT false NOT NULL,
|
||||||
|
"animationSpeedMultiplier" real DEFAULT 1 NOT NULL,
|
||||||
|
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
|
||||||
|
"customColor" varchar(50),
|
||||||
|
"theme" varchar(20) DEFAULT 'system' NOT NULL,
|
||||||
|
CONSTRAINT "beenvoice_user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_verification_token" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" varchar(255) NOT NULL,
|
||||||
|
"value" varchar(255) NOT NULL,
|
||||||
|
"expiresAt" timestamp NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_account" ADD CONSTRAINT "beenvoice_account_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_business" ADD CONSTRAINT "beenvoice_business_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_client" ADD CONSTRAINT "beenvoice_client_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_session" ADD CONSTRAINT "beenvoice_session_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_sso_provider" ADD CONSTRAINT "beenvoice_sso_provider_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_userId_idx" ON "beenvoice_account" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_created_by_idx" ON "beenvoice_business" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_name_idx" ON "beenvoice_business" USING btree ("name");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_nickname_idx" ON "beenvoice_business" USING btree ("nickname");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_email_idx" ON "beenvoice_business" USING btree ("email");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_is_default_idx" ON "beenvoice_business" USING btree ("isDefault");--> statement-breakpoint
|
||||||
|
CREATE INDEX "client_created_by_idx" ON "beenvoice_client" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "client_name_idx" ON "beenvoice_client" USING btree ("name");--> statement-breakpoint
|
||||||
|
CREATE INDEX "client_email_idx" ON "beenvoice_client" USING btree ("email");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_item_invoice_id_idx" ON "beenvoice_invoice_item" USING btree ("invoiceId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_item_date_idx" ON "beenvoice_invoice_item" USING btree ("date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_item_position_idx" ON "beenvoice_invoice_item" USING btree ("position");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_business_id_idx" ON "beenvoice_invoice" USING btree ("businessId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_client_id_idx" ON "beenvoice_invoice" USING btree ("clientId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_created_by_idx" ON "beenvoice_invoice" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_number_idx" ON "beenvoice_invoice" USING btree ("invoiceNumber");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_status_idx" ON "beenvoice_invoice" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "session_userId_idx" ON "beenvoice_session" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sso_provider_user_id_idx" ON "beenvoice_sso_provider" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "verification_token_identifier_idx" ON "beenvoice_verification_token" USING btree ("identifier");
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
CREATE TABLE "beenvoice_expense" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"businessId" varchar(255),
|
||||||
|
"clientId" varchar(255),
|
||||||
|
"invoiceId" varchar(255),
|
||||||
|
"date" timestamp NOT NULL,
|
||||||
|
"description" varchar(500) NOT NULL,
|
||||||
|
"amount" real NOT NULL,
|
||||||
|
"currency" varchar(3) DEFAULT 'USD' NOT NULL,
|
||||||
|
"category" varchar(100),
|
||||||
|
"billable" boolean DEFAULT false NOT NULL,
|
||||||
|
"reimbursable" boolean DEFAULT false NOT NULL,
|
||||||
|
"notes" varchar(500),
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_invoice_template" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"type" varchar(50) DEFAULT 'notes' NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"isDefault" boolean DEFAULT false NOT NULL,
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_client" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_template" ADD CONSTRAINT "beenvoice_invoice_template_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_created_by_idx" ON "beenvoice_expense" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_client_id_idx" ON "beenvoice_expense" USING btree ("clientId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_invoice_id_idx" ON "beenvoice_expense" USING btree ("invoiceId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_date_idx" ON "beenvoice_expense" USING btree ("date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_billable_idx" ON "beenvoice_expense" USING btree ("billable");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_template_created_by_idx" ON "beenvoice_invoice_template" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_template_type_idx" ON "beenvoice_invoice_template" USING btree ("type");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775354242672,
|
||||||
|
"tag": "0000_glossy_magneto",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775356013998,
|
||||||
|
"tag": "0001_supreme_the_enforcers",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
,{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775400000000,
|
||||||
|
"tag": "0002_tax_deductible",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"check": "eslint . && tsc --noEmit",
|
"check": "eslint . && tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "bun src/server/db/migrate.ts",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:clone": "./scripts/clone-local.sh",
|
"db:clone": "./scripts/clone-local.sh",
|
||||||
@@ -68,11 +68,11 @@
|
|||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^16.1.1",
|
"next": "^16.2.2",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.4",
|
||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "^9.12.0",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.4",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"recharts": "^3.5.1",
|
"recharts": "^3.5.1",
|
||||||
"resend": "^4.8.0",
|
"resend": "^4.8.0",
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-64 w-full">
|
<div className="h-48 w-full md:h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
||||||
|
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
|
interface ExpenseFormData {
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
category: string;
|
||||||
|
billable: boolean;
|
||||||
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean;
|
||||||
|
notes: string;
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm: ExpenseFormData = {
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
category: "",
|
||||||
|
billable: false,
|
||||||
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
|
notes: "",
|
||||||
|
clientId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExpensesPage() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
|
||||||
|
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const create = api.expenses.create.useMutation({
|
||||||
|
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const update = api.expenses.update.useMutation({
|
||||||
|
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const del = api.expenses.delete.useMutation({
|
||||||
|
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); },
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); };
|
||||||
|
const handleEdit = (expense: typeof expenses[0]) => {
|
||||||
|
setEditId(expense.id);
|
||||||
|
setForm({
|
||||||
|
date: new Date(expense.date),
|
||||||
|
description: expense.description,
|
||||||
|
amount: expense.amount,
|
||||||
|
currency: expense.currency,
|
||||||
|
category: expense.category ?? "",
|
||||||
|
billable: expense.billable,
|
||||||
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
|
notes: expense.notes ?? "",
|
||||||
|
clientId: expense.clientId ?? "",
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
||||||
|
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
|
||||||
|
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible };
|
||||||
|
if (editId) update.mutate({ id: editId, ...payload });
|
||||||
|
else create.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||||
|
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
|
||||||
|
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6 pb-6">
|
||||||
|
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient">
|
||||||
|
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md">
|
||||||
|
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p>
|
||||||
|
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expenses list */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Receipt className="h-5 w-5" /> All Expenses
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||||
|
) : expenses.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||||
|
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{expenses.map((expense) => (
|
||||||
|
<div key={expense.id} className="flex items-start justify-between gap-3 p-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="font-medium">{expense.description}</p>
|
||||||
|
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
||||||
|
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
|
||||||
|
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
|
||||||
|
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))}
|
||||||
|
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||||
|
</p>
|
||||||
|
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add/Edit dialog */}
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description *</Label>
|
||||||
|
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Amount *</Label>
|
||||||
|
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Currency</Label>
|
||||||
|
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Date</Label>
|
||||||
|
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Client (optional)</Label>
|
||||||
|
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No client</SelectItem>
|
||||||
|
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
||||||
|
<span className="text-sm">Billable</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
|
||||||
|
<span className="text-sm">Reimbursable</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} />
|
||||||
|
<span className="text-sm">Tax Deductible</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||||
|
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete dialog */}
|
||||||
|
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Expense</DialogTitle>
|
||||||
|
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
|
||||||
|
{del.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,13 +40,32 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
accessorKey: "date",
|
accessorKey: "date",
|
||||||
header: "Date",
|
header: "Date",
|
||||||
cell: ({ row }) => formatDate(row.getValue("date")),
|
cell: ({ row }) => formatDate(row.getValue("date")),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "description",
|
accessorKey: "description",
|
||||||
header: "Description",
|
header: "Description",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="font-medium">{row.getValue("description")}</div>
|
const item = row.original;
|
||||||
),
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop: plain description */}
|
||||||
|
<div className="hidden font-medium sm:block">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
{/* Mobile: description + date + hours @ rate stacked */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<p className="font-medium">{item.description}</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{formatDate(item.date)} · {item.hours}h @ {formatCurrency(item.rate)}/hr
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "hours",
|
accessorKey: "hours",
|
||||||
@@ -54,6 +73,10 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-right">{row.getValue("hours")}</div>
|
<div className="text-right">{row.getValue("hours")}</div>
|
||||||
),
|
),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "rate",
|
accessorKey: "rate",
|
||||||
@@ -61,6 +84,10 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
||||||
),
|
),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "amount",
|
accessorKey: "amount",
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef, Row } from "@tanstack/react-table";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||||
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
||||||
@@ -16,13 +17,19 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { Eye, Edit, Trash2, FileText } from "lucide-react";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
import { Eye, Edit, Trash2, FileText, CheckCircle, Send, ChevronDown } from "lucide-react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import { formatCurrency } from "~/lib/currency";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
// Type for invoice data
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string;
|
id: string;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
@@ -33,32 +40,16 @@ interface Invoice {
|
|||||||
status: string;
|
status: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
client?: {
|
client?: { id: string; name: string; email: string | null; phone: string | null } | null;
|
||||||
id: string;
|
business?: { id: string; name: string; email: string | null; phone: string | null } | null;
|
||||||
name: string;
|
|
||||||
email: string | null;
|
|
||||||
phone: string | null;
|
|
||||||
} | null;
|
|
||||||
business?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string | null;
|
|
||||||
phone: string | null;
|
|
||||||
} | null;
|
|
||||||
items?: Array<{
|
items?: Array<{
|
||||||
id: string;
|
id: string; invoiceId: string; date: Date; description: string;
|
||||||
invoiceId: string;
|
hours: number; rate: number; amount: number; position: number; createdAt: Date;
|
||||||
date: Date;
|
|
||||||
description: string;
|
|
||||||
hours: number;
|
|
||||||
rate: number;
|
|
||||||
amount: number;
|
|
||||||
position: number;
|
|
||||||
createdAt: Date;
|
|
||||||
}> | null;
|
}> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,67 +57,74 @@ interface InvoicesDataTableProps {
|
|||||||
invoices: Invoice[];
|
invoices: Invoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusType = (invoice: Invoice): StatusType => {
|
const getStatusType = (invoice: Invoice): StatusType =>
|
||||||
return getEffectiveInvoiceStatus(
|
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType;
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
) as StatusType;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) =>
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date));
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(new Date(date));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
||||||
|
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||||
|
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const deleteInvoice = api.invoices.delete.useMutation({
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Invoice deleted successfully");
|
toast.success("Invoice deleted");
|
||||||
void utils.invoices.getAll.invalidate();
|
void utils.invoices.getAll.invalidate();
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setInvoiceToDelete(null);
|
setInvoiceToDelete(null);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
||||||
toast.error(error.message ?? "Failed to delete invoice");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRowClick = (invoice: Invoice) => {
|
const bulkDelete = api.invoices.bulkDelete.useMutation({
|
||||||
router.push(`/dashboard/invoices/${invoice.id}`);
|
onSuccess: (data) => {
|
||||||
};
|
toast.success(`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`);
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
setBulkDeleteDialogOpen(false);
|
||||||
|
setPendingBulkDelete([]);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message ?? "Failed to delete invoices"),
|
||||||
|
});
|
||||||
|
|
||||||
const handleDelete = (invoice: Invoice) => {
|
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
|
||||||
setInvoiceToDelete(invoice);
|
onSuccess: (data) => {
|
||||||
setDeleteDialogOpen(true);
|
toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`);
|
||||||
};
|
void utils.invoices.getAll.invalidate();
|
||||||
|
},
|
||||||
const confirmDelete = () => {
|
onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
|
||||||
if (invoiceToDelete) {
|
});
|
||||||
deleteInvoice.mutate({ id: invoiceToDelete.id });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnDef<Invoice>[] = [
|
const columns: ColumnDef<Invoice>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "client.name",
|
id: "select",
|
||||||
header: ({ column }) => (
|
header: ({ table }) => (
|
||||||
<DataTableColumnHeader column={column} title="Client" />
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||||
|
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
||||||
|
aria-label="Select all"
|
||||||
|
data-action-button="true"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
|
cell: ({ row }: { row: Row<Invoice> }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||||
|
aria-label="Select row"
|
||||||
|
data-action-button="true"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "client.name",
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Client" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const invoice = row.original;
|
const invoice = row.original;
|
||||||
return (
|
return (
|
||||||
@@ -134,13 +132,15 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||||
<FileText className="text-primary h-4 w-4" />
|
<FileText className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-[80px] min-w-0 sm:max-w-[200px] lg:max-w-[300px]">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium">
|
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p>
|
||||||
{invoice.client?.name ?? "—"}
|
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p>
|
||||||
</p>
|
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
||||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
<StatusBadge status={getStatusType(invoice)} className="text-xs" />
|
||||||
{invoice.invoiceNumber}
|
<span className="text-foreground text-xs font-semibold">
|
||||||
</p>
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -148,69 +148,38 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "issueDate",
|
accessorKey: "issueDate",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
||||||
<DataTableColumnHeader column={column} title="Date" />
|
cell: ({ row }) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p>
|
||||||
|
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
|
||||||
const date = row.getValue("issueDate");
|
|
||||||
return (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm">{formatDate(date as Date)}</p>
|
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
|
||||||
Due {formatDate(new Date(row.original.dueDate))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
|
||||||
<DataTableColumnHeader column={column} title="Status" />
|
cell: ({ row }) => (
|
||||||
|
<StatusBadge
|
||||||
|
status={getStatusType(row.original)}
|
||||||
|
className={getStatusType(row.original) === "sent" ? "status-pending" : ""}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)),
|
||||||
const invoice = row.original;
|
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
|
||||||
return (
|
|
||||||
<StatusBadge
|
|
||||||
status={getStatusType(invoice)}
|
|
||||||
className={
|
|
||||||
getStatusType(invoice) === "sent" ? "status-pending" : ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
filterFn: (row, id, value: string[]) => {
|
|
||||||
const invoice = row.original;
|
|
||||||
const status = getStatusType(invoice);
|
|
||||||
return value.includes(status);
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
headerClassName: "hidden sm:table-cell",
|
|
||||||
cellClassName: "hidden sm:table-cell",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "totalAmount",
|
accessorKey: "totalAmount",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
|
||||||
<DataTableColumnHeader column={column} title="Amount" />
|
cell: ({ row }) => (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
|
||||||
const amount = row.getValue("totalAmount");
|
|
||||||
return (
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm font-semibold">
|
|
||||||
{formatCurrency(amount as number)}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{row.original.items?.length ?? 0} items
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
headerClassName: "hidden sm:table-cell",
|
|
||||||
cellClassName: "hidden sm:table-cell",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
@@ -219,33 +188,19 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||||
<Button
|
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="hover-scale h-8 w-8 p-0"
|
|
||||||
data-action-button="true"
|
|
||||||
>
|
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Button
|
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="hover-scale h-8 w-8 p-0"
|
|
||||||
data-action-button="true"
|
|
||||||
>
|
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost" size="sm"
|
||||||
size="sm"
|
|
||||||
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
|
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }}
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete(invoice);
|
|
||||||
}}
|
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
@@ -282,10 +237,68 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
searchKey="invoiceNumber"
|
searchKey="invoiceNumber"
|
||||||
searchPlaceholder="Search invoices..."
|
searchPlaceholder="Search invoices..."
|
||||||
filterableColumns={filterableColumns}
|
filterableColumns={filterableColumns}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`)}
|
||||||
|
selectionActions={(selected, clear) => (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={bulkUpdateStatus.isPending}>
|
||||||
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Mark as
|
||||||
|
<ChevronDown className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateStatus.mutate(
|
||||||
|
{ ids: selected.map((i) => i.id), status: "sent" },
|
||||||
|
{ onSuccess: clear },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" /> Mark Sent
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateStatus.mutate(
|
||||||
|
{ ids: selected.map((i) => i.id), status: "paid" },
|
||||||
|
{ onSuccess: clear },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" /> Mark Paid
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateStatus.mutate(
|
||||||
|
{ ids: selected.map((i) => i.id), status: "draft" },
|
||||||
|
{ onSuccess: clear },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" /> Mark Draft
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkDelete.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setPendingBulkDelete(selected);
|
||||||
|
setBulkDeleteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Delete ({selected.length})
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Single delete dialog */}
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -293,21 +306,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete invoice{" "}
|
Are you sure you want to delete invoice{" "}
|
||||||
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
|
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
|
||||||
<strong>{invoiceToDelete?.client?.name}</strong>? This action
|
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone.
|
||||||
cannot be undone.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
|
||||||
disabled={deleteInvoice.isPending}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={confirmDelete}
|
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })}
|
||||||
disabled={deleteInvoice.isPending}
|
disabled={deleteInvoice.isPending}
|
||||||
>
|
>
|
||||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
||||||
@@ -315,6 +323,31 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Bulk delete dialog */}
|
||||||
|
<Dialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete {pendingBulkDelete.length} Invoice{pendingBulkDelete.length !== 1 ? "s" : ""}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently delete {pendingBulkDelete.length} invoice{pendingBulkDelete.length !== 1 ? "s" : ""}.
|
||||||
|
This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBulkDeleteDialogOpen(false)} disabled={bulkDelete.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })}
|
||||||
|
disabled={bulkDelete.isPending}
|
||||||
|
>
|
||||||
|
{bulkDelete.isPending ? "Deleting..." : `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
TabsContent,
|
||||||
|
} from "~/components/ui/tabs";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
|
||||||
|
|
||||||
|
interface TemplateForm {
|
||||||
|
name: string;
|
||||||
|
type: "notes" | "terms";
|
||||||
|
content: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false };
|
||||||
|
|
||||||
|
export default function TemplatesPage() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<TemplateForm>(defaultForm);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState<"notes" | "terms">("notes");
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery();
|
||||||
|
|
||||||
|
const create = api.invoiceTemplates.create.useMutation({
|
||||||
|
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const update = api.invoiceTemplates.update.useMutation({
|
||||||
|
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const del = api.invoiceTemplates.delete.useMutation({
|
||||||
|
onSuccess: () => { toast.success("Template deleted"); void utils.invoiceTemplates.getAll.invalidate(); setDeleteId(null); },
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpen = (type: "notes" | "terms") => {
|
||||||
|
setEditId(null);
|
||||||
|
setForm({ ...defaultForm, type });
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const handleEdit = (t: typeof templates[0]) => {
|
||||||
|
setEditId(t.id);
|
||||||
|
setForm({ name: t.name, type: t.type as "notes" | "terms", content: t.content, isDefault: t.isDefault });
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.name.trim()) { toast.error("Name is required"); return; }
|
||||||
|
if (!form.content.trim()) { toast.error("Content is required"); return; }
|
||||||
|
if (editId) update.mutate({ id: editId, ...form });
|
||||||
|
else create.mutate(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
const notesTemplates = templates.filter((t) => t.type === "notes");
|
||||||
|
const termsTemplates = templates.filter((t) => t.type === "terms");
|
||||||
|
|
||||||
|
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => handleOpen(type)}>
|
||||||
|
<Plus className="mr-1.5 h-3.5 w-3.5" /> New {type === "notes" ? "Notes" : "Terms"} Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">Loading…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
No {type} templates yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((t) => (
|
||||||
|
<Card key={t.id}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">{t.name}</p>
|
||||||
|
{t.isDefault && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Star className="mr-1 h-3 w-3" /> Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
|
||||||
|
{t.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 gap-1">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(t)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(t.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6 pb-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Invoice Templates"
|
||||||
|
description="Reusable notes and payment terms for your invoices"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="notes">
|
||||||
|
<FileText className="mr-1.5 h-4 w-4" /> Notes ({notesTemplates.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="terms">
|
||||||
|
<FileText className="mr-1.5 h-4 w-4" /> Terms ({termsTemplates.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="notes" className="mt-4">
|
||||||
|
<TemplateList items={notesTemplates} type="notes" />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="terms" className="mt-4">
|
||||||
|
<TemplateList items={termsTemplates} type="terms" />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Create/Edit dialog */}
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editId ? "Edit Template" : "New Template"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<Input value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Tabs value={form.type} onValueChange={(v) => setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||||
|
<TabsTrigger value="terms">Terms</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Content *</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.content}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))}
|
||||||
|
placeholder="Template content…"
|
||||||
|
className="min-h-[120px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox checked={form.isDefault} onCheckedChange={(v) => setForm((p) => ({ ...p, isDefault: !!v }))} />
|
||||||
|
<span className="text-sm">Set as default for {form.type}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||||
|
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete dialog */}
|
||||||
|
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Template</DialogTitle>
|
||||||
|
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
|
||||||
|
{del.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
import { formatCurrency } from "~/lib/currency";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery();
|
||||||
|
const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery();
|
||||||
|
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||||
|
|
||||||
|
const isLoading = invoicesLoading || expensesLoading;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||||
|
|
||||||
|
// Overview data (last 12 months)
|
||||||
|
const overviewData = useMemo(() => {
|
||||||
|
if (!invoices.length) return null;
|
||||||
|
|
||||||
|
const monthMap: Record<string, number> = {};
|
||||||
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
monthMap[key] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let totalPending = 0;
|
||||||
|
let totalHours = 0;
|
||||||
|
|
||||||
|
for (const inv of invoices) {
|
||||||
|
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
|
if (status === "paid") {
|
||||||
|
totalRevenue += inv.totalAmount;
|
||||||
|
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
|
||||||
|
} else if (status === "sent" || status === "overdue") {
|
||||||
|
totalPending += inv.totalAmount;
|
||||||
|
}
|
||||||
|
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
||||||
|
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
||||||
|
revenue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||||
|
for (const inv of invoices) {
|
||||||
|
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
|
if (status === "paid" && inv.client) {
|
||||||
|
const id = inv.client.id;
|
||||||
|
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 };
|
||||||
|
clientMap[id]!.revenue += inv.totalAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6);
|
||||||
|
|
||||||
|
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
|
||||||
|
for (const inv of invoices) {
|
||||||
|
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
|
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount };
|
||||||
|
}, [invoices]);
|
||||||
|
|
||||||
|
// Tax summary for selected year
|
||||||
|
const taxData = useMemo(() => {
|
||||||
|
const year = parseInt(taxYear);
|
||||||
|
|
||||||
|
const yearInvoices = invoices.filter((inv) => {
|
||||||
|
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
|
return status === "paid" && new Date(inv.issueDate).getFullYear() === year;
|
||||||
|
});
|
||||||
|
const yearExpenses = expenses.filter((exp) => new Date(exp.date).getFullYear() === year);
|
||||||
|
|
||||||
|
const grossIncome = yearInvoices.reduce((s, inv) => s + inv.totalAmount, 0);
|
||||||
|
const taxCollected = yearInvoices.reduce((s, inv) => s + inv.totalAmount * (inv.taxRate ?? 0), 0);
|
||||||
|
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
|
||||||
|
const deductibleExpenses = yearExpenses
|
||||||
|
.filter((exp) => (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible)
|
||||||
|
.reduce((s, exp) => s + exp.amount, 0);
|
||||||
|
|
||||||
|
const netProfit = grossIncome - deductibleExpenses;
|
||||||
|
const seTaxBase = Math.max(0, netProfit) * 0.9235;
|
||||||
|
const selfEmploymentTax = seTaxBase * 0.153;
|
||||||
|
const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2);
|
||||||
|
const federalEstimate = taxableIncome * 0.22;
|
||||||
|
const totalEstimated = selfEmploymentTax + federalEstimate;
|
||||||
|
|
||||||
|
const quarters = [1, 2, 3, 4].map((q) => {
|
||||||
|
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
|
||||||
|
return {
|
||||||
|
label: `Q${q}`,
|
||||||
|
income: yearInvoices.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())).reduce((s, inv) => s + inv.totalAmount, 0),
|
||||||
|
expenses: yearExpenses.filter((exp) => qMonths.includes(new Date(exp.date).getMonth())).reduce((s, exp) => s + exp.amount, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { grossIncome, taxCollected, totalInvoiced: grossIncome + taxCollected, totalExpenses, deductibleExpenses, netProfit, selfEmploymentTax, federalEstimate, totalEstimated, quarters, yearInvoices, yearExpenses };
|
||||||
|
}, [invoices, expenses, taxYear]);
|
||||||
|
|
||||||
|
const availableYears = useMemo(() => {
|
||||||
|
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||||
|
for (const inv of invoices) years.add(new Date(inv.issueDate).getFullYear());
|
||||||
|
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||||
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
|
}, [invoices, expenses, currentYear]);
|
||||||
|
|
||||||
|
const avgInvoice = invoices.length > 0
|
||||||
|
? (overviewData?.totalRevenue ?? 0) / (invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
const rows: string[] = [
|
||||||
|
`Tax Year ${taxYear} - Income & Expense Report`,
|
||||||
|
`Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
|
||||||
|
"",
|
||||||
|
"INCOME (Paid Invoices)",
|
||||||
|
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
|
||||||
|
...taxData.yearInvoices.map((inv) => {
|
||||||
|
const taxAmt = inv.totalAmount * (inv.taxRate ?? 0);
|
||||||
|
return [new Date(inv.issueDate).toLocaleDateString("en-US"), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, inv.totalAmount.toFixed(2), `${((inv.taxRate ?? 0) * 100).toFixed(1)}%`, taxAmt.toFixed(2), (inv.totalAmount + taxAmt).toFixed(2)].join(",");
|
||||||
|
}),
|
||||||
|
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
|
||||||
|
"",
|
||||||
|
"EXPENSES",
|
||||||
|
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
||||||
|
...taxData.yearExpenses.map((exp) => [
|
||||||
|
new Date(exp.date).toLocaleDateString("en-US"),
|
||||||
|
`"${exp.description}"`,
|
||||||
|
`"${exp.category ?? ""}"`,
|
||||||
|
exp.amount.toFixed(2),
|
||||||
|
exp.currency,
|
||||||
|
exp.billable ? "Yes" : "No",
|
||||||
|
exp.reimbursable ? "Yes" : "No",
|
||||||
|
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No",
|
||||||
|
].join(",")),
|
||||||
|
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
|
||||||
|
"",
|
||||||
|
"TAX SUMMARY",
|
||||||
|
`Gross Income,${taxData.grossIncome.toFixed(2)}`,
|
||||||
|
`Tax Collected,${taxData.taxCollected.toFixed(2)}`,
|
||||||
|
`Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`,
|
||||||
|
`Net Profit,${taxData.netProfit.toFixed(2)}`,
|
||||||
|
`Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`,
|
||||||
|
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
|
||||||
|
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
|
||||||
|
];
|
||||||
|
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `tax-report-${taxYear}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6">
|
||||||
|
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6 pb-6">
|
||||||
|
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ── OVERVIEW TAB ── */}
|
||||||
|
<TabsContent value="overview" className="mt-4 space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-green-500/10 rounded p-1.5"><Users className="h-4 w-4 text-green-500" /></div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 w-full md:h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||||
|
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||||
|
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" /> Top Clients by Revenue</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!overviewData?.topClients.length ? (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 md:h-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={overviewData.topClients} layout="vertical">
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
|
||||||
|
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||||
|
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => (
|
||||||
|
<div key={status} className="flex items-center justify-between">
|
||||||
|
<StatusBadge status={status as never} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
||||||
|
<div className="bg-primary h-full rounded-full" style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="divide-y">
|
||||||
|
{stats.recentInvoices.map((inv) => (
|
||||||
|
<div key={inv.id} className="flex items-center justify-between py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
|
||||||
|
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── TAX SUMMARY TAB ── */}
|
||||||
|
<TabsContent value="tax" className="mt-4 space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium">Tax Year</span>
|
||||||
|
<Select value={taxYear} onValueChange={setTaxYear}>
|
||||||
|
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={exportCSV} className="gap-2">
|
||||||
|
<Download className="h-4 w-4" /> Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Income */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><DollarSign className="h-5 w-5" /> Income</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Gross Income (paid invoices)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.grossIncome)}</span>
|
||||||
|
</div>
|
||||||
|
{taxData.taxCollected > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Tax Collected from Clients</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.taxCollected)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between font-medium">
|
||||||
|
<span>Total Invoiced (inc. tax)</span>
|
||||||
|
<span>{formatCurrency(taxData.totalInvoiced)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expenses */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Receipt className="h-5 w-5" /> Expenses & Deductions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Total Expenses</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.totalExpenses)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Tax-Deductible Expenses</span>
|
||||||
|
<span className="font-medium text-green-600">{formatCurrency(taxData.deductibleExpenses)}</span>
|
||||||
|
</div>
|
||||||
|
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && (
|
||||||
|
<p className="text-muted-foreground text-xs">Mark expenses as "Tax Deductible" in the Expenses page to include them here.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Estimated tax */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><FileText className="h-5 w-5" /> Estimated Tax Liability</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Net Profit (income − deductible expenses)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.netProfit)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Self-Employment Tax (15.3% on 92.35% of net)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.selfEmploymentTax)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Federal Income Tax (est. 22% bracket)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.federalEstimate)}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>Total Estimated Tax</span>
|
||||||
|
<span className="text-destructive">{formatCurrency(taxData.totalEstimated)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs pt-1">
|
||||||
|
Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quarterly chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Quarterly Breakdown</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 md:h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={taxData.quarters}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v: number, name: string) => [formatCurrency(v), name === "income" ? "Income" : "Expenses"]}
|
||||||
|
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="expenses" name="expenses" fill="hsl(0, 84%, 60%)" radius={[4, 4, 0, 0]} opacity={0.75} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" /> Income</span>
|
||||||
|
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" /> Expenses</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,6 +72,11 @@ interface DataTableProps<TData, TValue> {
|
|||||||
options: { label: string; value: string }[];
|
options: { label: string; value: string }[];
|
||||||
}[];
|
}[];
|
||||||
onRowClick?: (row: TData) => void;
|
onRowClick?: (row: TData) => void;
|
||||||
|
/** Render bulk-action buttons when rows are selected. Receives selected rows and a clear function. */
|
||||||
|
selectionActions?: (
|
||||||
|
selectedRows: TData[],
|
||||||
|
clearSelection: () => void,
|
||||||
|
) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
@@ -89,6 +94,7 @@ export function DataTable<TData, TValue>({
|
|||||||
actions,
|
actions,
|
||||||
filterableColumns = [],
|
filterableColumns = [],
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
selectionActions,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||||
@@ -335,6 +341,23 @@ export function DataTable<TData, TValue>({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Selection Toolbar */}
|
||||||
|
{selectionActions && table.getSelectedRowModel().rows.length > 0 && (
|
||||||
|
<Card className="bg-primary/5 border-primary/20 border py-2">
|
||||||
|
<CardContent className="flex items-center justify-between gap-3 px-3 py-0">
|
||||||
|
<span className="text-foreground text-sm font-medium">
|
||||||
|
{table.getSelectedRowModel().rows.length} selected
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectionActions(
|
||||||
|
table.getSelectedRowModel().rows.map((r) => r.original),
|
||||||
|
() => table.resetRowSelection(),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table Content Card */}
|
{/* Table Content Card */}
|
||||||
<Card className="bg-card border-border overflow-hidden border p-0">
|
<Card className="bg-card border-border overflow-hidden border p-0">
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
@@ -471,7 +494,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-10 w-10 md:h-8 md:w-8"
|
||||||
onClick={() => table.setPageIndex(0)}
|
onClick={() => table.setPageIndex(0)}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
@@ -481,7 +504,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-10 w-10 md:h-8 md:w-8"
|
||||||
onClick={() => table.previousPage()}
|
onClick={() => table.previousPage()}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
@@ -503,7 +526,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-10 w-10 md:h-8 md:w-8"
|
||||||
onClick={() => table.nextPage()}
|
onClick={() => table.nextPage()}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
@@ -513,7 +536,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-10 w-10 md:h-8 md:w-8"
|
||||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -185,14 +185,14 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
{/* Invoice Header Card */}
|
{/* Invoice Header Card */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-4">
|
<div className="min-w-0 flex-1 space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 flex-shrink-0 p-2">
|
||||||
<FileText className="text-primary h-6 w-6" />
|
<FileText className="text-primary h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-foreground text-2xl font-bold">
|
<h2 className="text-foreground truncate text-2xl font-bold">
|
||||||
{invoice.invoiceNumber}
|
{invoice.invoiceNumber}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
@@ -217,21 +217,23 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 text-right">
|
<div className="flex flex-row items-center justify-between gap-3 sm:flex-col sm:items-end sm:text-right">
|
||||||
<StatusBadge
|
<div>
|
||||||
status={invoice.status as StatusType}
|
<StatusBadge
|
||||||
className="px-3 py-1 text-sm font-medium"
|
status={invoice.status as StatusType}
|
||||||
>
|
className="px-3 py-1 text-sm font-medium"
|
||||||
<StatusIcon className="mr-1 h-3 w-3" />
|
>
|
||||||
</StatusBadge>
|
<StatusIcon className="mr-1 h-3 w-3" />
|
||||||
<div className="text-primary text-3xl font-bold">
|
</StatusBadge>
|
||||||
{formatCurrency(invoice.totalAmount)}
|
<div className="text-primary mt-1 text-2xl font-bold sm:text-3xl">
|
||||||
|
{formatCurrency(invoice.totalAmount)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePDFExport}
|
onClick={handlePDFExport}
|
||||||
disabled={isExportingPDF}
|
disabled={isExportingPDF}
|
||||||
variant="default"
|
variant="default"
|
||||||
className="transform-none"
|
className="transform-none flex-shrink-0"
|
||||||
>
|
>
|
||||||
{isExportingPDF ? (
|
{isExportingPDF ? (
|
||||||
<>
|
<>
|
||||||
@@ -326,17 +328,18 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
{invoice.items?.map((item, index) => (
|
{invoice.items?.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.id || index}
|
key={item.id || index}
|
||||||
className="bg-background flex items-center justify-between rounded-lg p-4"
|
className="bg-background flex flex-col gap-1 rounded-lg p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-foreground font-medium break-words">
|
||||||
{formatDate(item.date)}
|
|
||||||
</div>
|
|
||||||
<div className="text-foreground font-medium">
|
|
||||||
{item.description}
|
{item.description}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 text-sm">
|
||||||
|
{formatDate(item.date)} · {item.hours}h @{" "}
|
||||||
|
{formatCurrency(item.rate)}/hr
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-foreground text-right font-medium">
|
<div className="text-foreground flex-shrink-0 font-medium sm:text-right">
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.amount)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,18 +423,24 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Subtotal</span>
|
<span className="text-muted-foreground">Subtotal</span>
|
||||||
<span className="text-foreground font-medium">
|
<span className="text-foreground font-medium">
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
{(invoice.taxRate ?? 0) > 0 && (
|
||||||
<span className="text-muted-foreground">Tax</span>
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-foreground font-medium">$0.00</span>
|
<span className="text-muted-foreground">
|
||||||
</div>
|
Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{formatCurrency(invoice.totalAmount * (invoice.taxRate ?? 0), invoice.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between text-lg font-bold">
|
<div className="flex justify-between text-lg font-bold">
|
||||||
<span className="text-foreground">Total</span>
|
<span className="text-foreground">Total</span>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(invoice.totalAmount * (1 + (invoice.taxRate ?? 0)), invoice.currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ import {
|
|||||||
VALIDATION_MESSAGES,
|
VALIDATION_MESSAGES,
|
||||||
PLACEHOLDERS,
|
PLACEHOLDERS,
|
||||||
} from "~/lib/form-constants";
|
} from "~/lib/form-constants";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
|
|
||||||
interface ClientFormProps {
|
interface ClientFormProps {
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
@@ -45,6 +53,7 @@ interface FormData {
|
|||||||
postalCode: string;
|
postalCode: string;
|
||||||
country: string;
|
country: string;
|
||||||
defaultHourlyRate: number | null;
|
defaultHourlyRate: number | null;
|
||||||
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
@@ -70,6 +79,7 @@ const initialFormData: FormData = {
|
|||||||
postalCode: "",
|
postalCode: "",
|
||||||
country: "United States",
|
country: "United States",
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
|
currency: "USD",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ClientForm({ clientId, mode }: ClientFormProps) {
|
export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||||
@@ -120,6 +130,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
postalCode: client.postalCode ?? "",
|
postalCode: client.postalCode ?? "",
|
||||||
country: client.country ?? "United States",
|
country: client.country ?? "United States",
|
||||||
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
||||||
|
currency: client.currency ?? "USD",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [client, mode]);
|
}, [client, mode]);
|
||||||
@@ -468,6 +479,30 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="currency" className="text-sm font-medium">
|
||||||
|
Currency
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground mb-2 text-xs">
|
||||||
|
Default currency for invoices created for this client.
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
value={formData.currency}
|
||||||
|
onValueChange={(v) => handleInputChange("currency", v)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUPPORTED_CURRENCIES.map((c) => (
|
||||||
|
<SelectItem key={c.code} value={c.code}>
|
||||||
|
{c.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function InvoiceCalendarView({
|
|||||||
weekday: "w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
|
weekday: "w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
|
||||||
|
|
||||||
week: "flex w-full mt-2",
|
week: "flex w-full mt-2",
|
||||||
cell: "w-[14.285%] flex-none h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
|
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
|
||||||
|
|
||||||
// Hide internal navigation & caption entirely
|
// Hide internal navigation & caption entirely
|
||||||
nav: "hidden",
|
nav: "hidden",
|
||||||
@@ -228,7 +228,7 @@ export function InvoiceCalendarView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 p-4 h-full w-full">
|
<div className="flex gap-3 overflow-x-auto p-4 pb-6 w-full">
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const isSelected = date && isSameDay(day, date);
|
const isSelected = date && isSameDay(day, date);
|
||||||
const isToday = isSameDay(day, new Date());
|
const isToday = isSameDay(day, new Date());
|
||||||
@@ -241,7 +241,7 @@ export function InvoiceCalendarView({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectDate(day)}
|
onClick={() => handleSelectDate(day)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-full min-h-[400px] border rounded-3xl p-4 text-left transition-all hover:bg-accent/30 w-full",
|
"flex flex-col min-h-[260px] flex-shrink-0 w-[120px] sm:flex-1 sm:w-auto border rounded-3xl p-3 text-left transition-all hover:bg-accent/30",
|
||||||
isSelected ? "ring-2 ring-primary ring-offset-2 bg-primary/5" : "bg-background/40",
|
isSelected ? "ring-2 ring-primary ring-offset-2 bg-primary/5" : "bg-background/40",
|
||||||
isToday && !isSelected ? "bg-accent/40" : ""
|
isToday && !isSelected ? "bg-accent/40" : ""
|
||||||
)}
|
)}
|
||||||
@@ -283,7 +283,7 @@ export function InvoiceCalendarView({
|
|||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={handleCloseSheet}
|
onOpenChange={handleCloseSheet}
|
||||||
>
|
>
|
||||||
<SheetContent side="right" className="w-[400px] sm:w-[540px] flex flex-col gap-0 p-0 sm:max-w-[540px]">
|
<SheetContent side="right" className="w-full max-w-full sm:w-[400px] sm:max-w-[540px] flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="p-6 border-b">
|
<SheetHeader className="p-6 border-b">
|
||||||
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap">
|
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap">
|
||||||
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0">
|
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0">
|
||||||
|
|||||||
@@ -21,7 +21,15 @@ import { InvoiceLineItems } from "./invoice-line-items";
|
|||||||
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Save, Calendar as CalendarIcon, Tag, User, List } from "lucide-react";
|
import { Save, Calendar as CalendarIcon, Tag, User, List, FileText, ChevronDown } from "lucide-react";
|
||||||
|
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -71,6 +79,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
status: "draft",
|
status: "draft",
|
||||||
notes: "",
|
notes: "",
|
||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
|
currency: "USD",
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -92,6 +101,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
// Queries (Same as before)
|
// Queries (Same as before)
|
||||||
const { data: clients, isLoading: loadingClients } =
|
const { data: clients, isLoading: loadingClients } =
|
||||||
api.clients.getAll.useQuery();
|
api.clients.getAll.useQuery();
|
||||||
|
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({ type: "notes" });
|
||||||
const { data: businesses, isLoading: loadingBusinesses } =
|
const { data: businesses, isLoading: loadingBusinesses } =
|
||||||
api.businesses.getAll.useQuery();
|
api.businesses.getAll.useQuery();
|
||||||
const { data: existingInvoice, isLoading: loadingInvoice } =
|
const { data: existingInvoice, isLoading: loadingInvoice } =
|
||||||
@@ -137,6 +147,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
status: existingInvoice.status as "draft" | "sent" | "paid",
|
status: existingInvoice.status as "draft" | "sent" | "paid",
|
||||||
notes: existingInvoice.notes ?? "",
|
notes: existingInvoice.notes ?? "",
|
||||||
taxRate: existingInvoice.taxRate,
|
taxRate: existingInvoice.taxRate,
|
||||||
|
currency: existingInvoice.currency ?? "USD",
|
||||||
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
||||||
items:
|
items:
|
||||||
mappedItems.length > 0
|
mappedItems.length > 0
|
||||||
@@ -329,6 +340,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
status: formData.status,
|
status: formData.status,
|
||||||
notes: formData.notes,
|
notes: formData.notes,
|
||||||
taxRate: formData.taxRate,
|
taxRate: formData.taxRate,
|
||||||
|
currency: formData.currency,
|
||||||
items: formData.items
|
items: formData.items
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||||
@@ -432,26 +444,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
value={formData.clientId}
|
value={formData.clientId}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
updateField("clientId", v);
|
updateField("clientId", v);
|
||||||
// Auto-fill Hourly Rate
|
|
||||||
const selectedClient = clients?.find((c) => c.id === v);
|
const selectedClient = clients?.find((c) => c.id === v);
|
||||||
const currentBusiness = businesses?.find(
|
const currentBusiness = businesses?.find(
|
||||||
(b) => b.id === formData.businessId,
|
(b) => b.id === formData.businessId,
|
||||||
);
|
);
|
||||||
// Explicitly prioritize client rate, then business rate, then 0
|
|
||||||
const clientRate =
|
const clientRate =
|
||||||
selectedClient && "defaultHourlyRate" in selectedClient
|
selectedClient && "defaultHourlyRate" in selectedClient
|
||||||
? selectedClient.defaultHourlyRate
|
? selectedClient.defaultHourlyRate
|
||||||
: null;
|
: null;
|
||||||
const businessRate =
|
const businessRate =
|
||||||
currentBusiness &&
|
currentBusiness && "defaultHourlyRate" in currentBusiness
|
||||||
"defaultHourlyRate" in currentBusiness
|
|
||||||
? currentBusiness.defaultHourlyRate
|
? currentBusiness.defaultHourlyRate
|
||||||
: null;
|
: null;
|
||||||
const rateToSet: number = (clientRate ??
|
updateField("defaultHourlyRate", (clientRate ?? businessRate ?? 0) as number);
|
||||||
businessRate ??
|
// Auto-fill currency from client
|
||||||
0) as number;
|
if (selectedClient && "currency" in selectedClient && selectedClient.currency) {
|
||||||
|
updateField("currency", selectedClient.currency as string);
|
||||||
updateField("defaultHourlyRate", rateToSet);
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
@@ -494,7 +503,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Issue Date</Label>
|
<Label>Issue Date</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -537,28 +546,86 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Label>Status</Label>
|
<div className="space-y-2">
|
||||||
<Select
|
<Label>Status</Label>
|
||||||
value={formData.status}
|
<Select
|
||||||
onValueChange={(v: "draft" | "sent" | "paid") =>
|
value={formData.status}
|
||||||
updateField("status", v)
|
onValueChange={(v: "draft" | "sent" | "paid") =>
|
||||||
}
|
updateField("status", v)
|
||||||
>
|
}
|
||||||
<SelectTrigger className="w-full">
|
>
|
||||||
<SelectValue />
|
<SelectTrigger className="w-full">
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{STATUS_OPTIONS.map((o) => (
|
<SelectContent>
|
||||||
<SelectItem key={o.value} value={o.value}>
|
{STATUS_OPTIONS.map((o) => (
|
||||||
{o.label}
|
<SelectItem key={o.value} value={o.value}>
|
||||||
</SelectItem>
|
{o.label}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Currency</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.currency}
|
||||||
|
onValueChange={(v) => updateField("currency", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUPPORTED_CURRENCIES.map((c) => (
|
||||||
|
<SelectItem key={c.code} value={c.code}>
|
||||||
|
{c.code}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Notes card — spans both columns */}
|
||||||
|
<Card className="h-fit lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between gap-2 text-base">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" /> Notes
|
||||||
|
</span>
|
||||||
|
{noteTemplates && noteTemplates.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs">
|
||||||
|
Use template <ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
{noteTemplates.map((t) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => updateField("notes", t.content)}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => updateField("notes", e.target.value)}
|
||||||
|
placeholder="Add notes, payment terms, or other information for the client…"
|
||||||
|
className="min-h-[100px]"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ITEMS TAB */}
|
{/* ITEMS TAB */}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface InvoiceFormData {
|
|||||||
status: "draft" | "sent" | "paid";
|
status: "draft" | "sent" | "paid";
|
||||||
notes: string;
|
notes: string;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency: string;
|
||||||
defaultHourlyRate: number | null;
|
defaultHourlyRate: number | null;
|
||||||
items: InvoiceItem[];
|
items: InvoiceItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function FloatingActionBar({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Card className="hover-lift bg-card border-border border shadow-lg">
|
<Card className="hover-lift bg-card border-border border shadow-lg">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
|
||||||
{/* Left content */}
|
{/* Left content */}
|
||||||
{leftContent && (
|
{leftContent && (
|
||||||
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
|
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const SUPPORTED_CURRENCIES = [
|
||||||
|
{ code: "USD", label: "USD – US Dollar" },
|
||||||
|
{ code: "EUR", label: "EUR – Euro" },
|
||||||
|
{ code: "GBP", label: "GBP – British Pound" },
|
||||||
|
{ code: "CAD", label: "CAD – Canadian Dollar" },
|
||||||
|
{ code: "AUD", label: "AUD – Australian Dollar" },
|
||||||
|
{ code: "NZD", label: "NZD – New Zealand Dollar" },
|
||||||
|
{ code: "CHF", label: "CHF – Swiss Franc" },
|
||||||
|
{ code: "JPY", label: "JPY – Japanese Yen" },
|
||||||
|
{ code: "SGD", label: "SGD – Singapore Dollar" },
|
||||||
|
{ code: "HKD", label: "HKD – Hong Kong Dollar" },
|
||||||
|
{ code: "SEK", label: "SEK – Swedish Krona" },
|
||||||
|
{ code: "NOK", label: "NOK – Norwegian Krone" },
|
||||||
|
{ code: "DKK", label: "DKK – Danish Krone" },
|
||||||
|
{ code: "MXN", label: "MXN – Mexican Peso" },
|
||||||
|
{ code: "BRL", label: "BRL – Brazilian Real" },
|
||||||
|
{ code: "INR", label: "INR – Indian Rupee" },
|
||||||
|
{ code: "ZAR", label: "ZAR – South African Rand" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CurrencyCode = (typeof SUPPORTED_CURRENCIES)[number]["code"];
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const EXPENSE_CATEGORIES = [
|
||||||
|
"Travel",
|
||||||
|
"Meals & Entertainment",
|
||||||
|
"Software & Subscriptions",
|
||||||
|
"Hardware & Equipment",
|
||||||
|
"Office Supplies",
|
||||||
|
"Marketing",
|
||||||
|
"Professional Services",
|
||||||
|
"Utilities",
|
||||||
|
"Other",
|
||||||
|
] as const;
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Building,
|
Building,
|
||||||
|
Receipt,
|
||||||
|
BarChart2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface NavLink {
|
export interface NavLink {
|
||||||
@@ -25,6 +27,8 @@ export const navigationConfig: NavSection[] = [
|
|||||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
||||||
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
||||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||||
|
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
||||||
|
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { invoicesRouter } from "~/server/api/routers/invoices";
|
|||||||
import { settingsRouter } from "~/server/api/routers/settings";
|
import { settingsRouter } from "~/server/api/routers/settings";
|
||||||
import { emailRouter } from "~/server/api/routers/email";
|
import { emailRouter } from "~/server/api/routers/email";
|
||||||
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
||||||
|
import { expensesRouter } from "~/server/api/routers/expenses";
|
||||||
|
import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +20,8 @@ export const appRouter = createTRPCRouter({
|
|||||||
settings: settingsRouter,
|
settings: settingsRouter,
|
||||||
email: emailRouter,
|
email: emailRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
|
expenses: expensesRouter,
|
||||||
|
invoiceTemplates: invoiceTemplatesRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const createClientSchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
defaultHourlyRate: z.number().min(0, "Rate must be positive").optional(),
|
defaultHourlyRate: z.number().min(0, "Rate must be positive").optional(),
|
||||||
|
currency: z.string().length(3).default("USD").optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateClientSchema = createClientSchema.partial().extend({
|
const updateClientSchema = createClientSchema.partial().extend({
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
|
export { EXPENSE_CATEGORIES };
|
||||||
|
|
||||||
|
const createExpenseSchema = z.object({
|
||||||
|
date: z.date(),
|
||||||
|
description: z.string().min(1, "Description is required"),
|
||||||
|
amount: z.number().min(0, "Amount must be positive"),
|
||||||
|
currency: z.string().length(3).default("USD"),
|
||||||
|
category: z.string().optional().or(z.literal("")),
|
||||||
|
billable: z.boolean().default(false),
|
||||||
|
reimbursable: z.boolean().default(false),
|
||||||
|
taxDeductible: z.boolean().default(false),
|
||||||
|
notes: z.string().optional().or(z.literal("")),
|
||||||
|
clientId: z.string().optional().or(z.literal("")),
|
||||||
|
businessId: z.string().optional().or(z.literal("")),
|
||||||
|
invoiceId: z.string().optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateExpenseSchema = createExpenseSchema.partial().extend({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expensesRouter = createTRPCRouter({
|
||||||
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return await ctx.db.query.expenses.findMany({
|
||||||
|
where: eq(expenses.createdById, ctx.session.user.id),
|
||||||
|
with: { client: true, business: true, invoice: true },
|
||||||
|
orderBy: [desc(expenses.date)],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
getById: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const expense = await ctx.db.query.expenses.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(expenses.id, input.id),
|
||||||
|
eq(expenses.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
with: { client: true, business: true, invoice: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return expense;
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(createExpenseSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const clean = {
|
||||||
|
...input,
|
||||||
|
clientId: input.clientId?.trim() || null,
|
||||||
|
businessId: input.businessId?.trim() || null,
|
||||||
|
invoiceId: input.invoiceId?.trim() || null,
|
||||||
|
category: input.category?.trim() || null,
|
||||||
|
notes: input.notes?.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clean.clientId) {
|
||||||
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(clients.id, clean.clientId),
|
||||||
|
eq(clients.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clean.businessId) {
|
||||||
|
const business = await ctx.db.query.businesses.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(businesses.id, clean.businessId),
|
||||||
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!business) throw new TRPCError({ code: "FORBIDDEN", message: "Business not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clean.invoiceId) {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invoices.id, clean.invoiceId),
|
||||||
|
eq(invoices.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [expense] = await ctx.db
|
||||||
|
.insert(expenses)
|
||||||
|
.values({ ...clean, createdById: ctx.session.user.id })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return expense;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(updateExpenseSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { id, ...data } = input;
|
||||||
|
|
||||||
|
const existing = await ctx.db.query.expenses.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(expenses.id, id),
|
||||||
|
eq(expenses.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = {
|
||||||
|
...data,
|
||||||
|
clientId: data.clientId?.trim() || null,
|
||||||
|
businessId: data.businessId?.trim() || null,
|
||||||
|
invoiceId: data.invoiceId?.trim() || null,
|
||||||
|
category: data.category?.trim() || null,
|
||||||
|
notes: data.notes?.trim() || null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await ctx.db.update(expenses).set(clean).where(eq(expenses.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await ctx.db.query.expenses.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(expenses.id, input.id),
|
||||||
|
eq(expenses.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { invoiceTemplates } from "~/server/db/schema";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
const createTemplateSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required").max(255),
|
||||||
|
type: z.enum(["notes", "terms"]).default("notes"),
|
||||||
|
content: z.string().min(1, "Content is required"),
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTemplateSchema = createTemplateSchema.partial().extend({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const invoiceTemplatesRouter = createTRPCRouter({
|
||||||
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return await ctx.db.query.invoiceTemplates.findMany({
|
||||||
|
where: eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||||
|
orderBy: (t, { asc }) => [asc(t.type), asc(t.name)],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
getByType: protectedProcedure
|
||||||
|
.input(z.object({ type: z.enum(["notes", "terms"]) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return await ctx.db.query.invoiceTemplates.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||||
|
eq(invoiceTemplates.type, input.type),
|
||||||
|
),
|
||||||
|
orderBy: (t, { asc }) => [asc(t.name)],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(createTemplateSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// If setting as default, unset others of same type
|
||||||
|
if (input.isDefault) {
|
||||||
|
await ctx.db
|
||||||
|
.update(invoiceTemplates)
|
||||||
|
.set({ isDefault: false })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||||
|
eq(invoiceTemplates.type, input.type),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [template] = await ctx.db
|
||||||
|
.insert(invoiceTemplates)
|
||||||
|
.values({ ...input, createdById: ctx.session.user.id })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return template;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(updateTemplateSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { id, ...data } = input;
|
||||||
|
|
||||||
|
const existing = await ctx.db.query.invoiceTemplates.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invoiceTemplates.id, id),
|
||||||
|
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setting as default, unset others of same type
|
||||||
|
if (data.isDefault) {
|
||||||
|
const type = data.type ?? existing.type;
|
||||||
|
await ctx.db
|
||||||
|
.update(invoiceTemplates)
|
||||||
|
.set({ isDefault: false })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||||
|
eq(invoiceTemplates.type, type),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(invoiceTemplates)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(invoiceTemplates.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await ctx.db.query.invoiceTemplates.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invoiceTemplates.id, input.id),
|
||||||
|
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.delete(invoiceTemplates)
|
||||||
|
.where(eq(invoiceTemplates.id, input.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import {
|
import {
|
||||||
invoices,
|
invoices,
|
||||||
@@ -29,6 +29,7 @@ const createInvoiceSchema = z.object({
|
|||||||
status: z.enum(["draft", "sent", "paid"]).default("draft"),
|
status: z.enum(["draft", "sent", "paid"]).default("draft"),
|
||||||
notes: z.string().optional().or(z.literal("")),
|
notes: z.string().optional().or(z.literal("")),
|
||||||
taxRate: z.number().min(0).max(100).default(0),
|
taxRate: z.number().min(0).max(100).default(0),
|
||||||
|
currency: z.string().length(3).default("USD"),
|
||||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -410,47 +411,76 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.input(updateStatusSchema)
|
.input(updateStatusSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
try {
|
try {
|
||||||
// Verify invoice exists and belongs to user
|
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
where: eq(invoices.id, input.id),
|
where: eq(invoices.id, input.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Invoice not found",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoice.createdById !== ctx.session.user.id) {
|
if (invoice.createdById !== ctx.session.user.id) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to update this invoice" });
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "You don't have permission to update this invoice",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({ status: input.status, updatedAt: new Date() })
|
||||||
status: input.status,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(invoices.id, input.id));
|
.where(eq(invoices.id, input.id));
|
||||||
|
|
||||||
console.log("Status update completed successfully");
|
return { success: true, message: `Invoice status updated to ${input.status}` };
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Invoice status updated to ${input.status}`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("UpdateStatus error:", error);
|
|
||||||
if (error instanceof TRPCError) throw error;
|
if (error instanceof TRPCError) throw error;
|
||||||
throw new TRPCError({
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to update invoice status", cause: error });
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Failed to update invoice status",
|
|
||||||
cause: error,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
bulkUpdateStatus: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
ids: z.array(z.string()).min(1),
|
||||||
|
status: z.enum(["draft", "sent", "paid"]),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Only update invoices owned by this user
|
||||||
|
const owned = await ctx.db.query.invoices.findMany({
|
||||||
|
where: inArray(invoices.id, input.ids),
|
||||||
|
columns: { id: true, createdById: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownedIds = owned
|
||||||
|
.filter((inv) => inv.createdById === ctx.session.user.id)
|
||||||
|
.map((inv) => inv.id);
|
||||||
|
|
||||||
|
if (ownedIds.length === 0) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ status: input.status, updatedAt: new Date() })
|
||||||
|
.where(inArray(invoices.id, ownedIds));
|
||||||
|
|
||||||
|
return { success: true, updated: ownedIds.length };
|
||||||
|
}),
|
||||||
|
|
||||||
|
bulkDelete: protectedProcedure
|
||||||
|
.input(z.object({ ids: z.array(z.string()).min(1) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const owned = await ctx.db.query.invoices.findMany({
|
||||||
|
where: inArray(invoices.id, input.ids),
|
||||||
|
columns: { id: true, createdById: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownedIds = owned
|
||||||
|
.filter((inv) => inv.createdById === ctx.session.user.id)
|
||||||
|
.map((inv) => inv.id);
|
||||||
|
|
||||||
|
if (ownedIds.length === 0) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
|
||||||
|
|
||||||
|
return { success: true, deleted: ownedIds.length };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Programmatic migration runner for production deployments.
|
||||||
|
*
|
||||||
|
* Run with: bun src/server/db/migrate.ts
|
||||||
|
*
|
||||||
|
* This applies any pending migrations from the drizzle/ directory to the
|
||||||
|
* database specified by DATABASE_URL. It is safe to run multiple times —
|
||||||
|
* Drizzle tracks applied migrations in the __drizzle_migrations table.
|
||||||
|
*
|
||||||
|
* If the database was previously set up via `db:push` (no migration history),
|
||||||
|
* this script will baseline it: seed the migration history without re-running
|
||||||
|
* the SQL, so only future migrations are applied.
|
||||||
|
*/
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
// Load env files before importing anything that reads process.env
|
||||||
|
dotenv.config({ path: ".env.local" });
|
||||||
|
dotenv.config({ path: ".env" });
|
||||||
|
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.error("[migrate] ERROR: DATABASE_URL is not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const migrationsFolder = path.resolve(__dirname, "../../../drizzle");
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: databaseUrl,
|
||||||
|
ssl: process.env.DB_DISABLE_SSL === "true" ? false : { rejectUnauthorized: false },
|
||||||
|
max: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = drizzle(pool);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify and repair the migration tracking table:
|
||||||
|
* 1. If no tracking table exists and DB has tables → baseline from db:push
|
||||||
|
* 2. If tracking table exists → scan for any entries that are recorded as
|
||||||
|
* applied but whose schema changes don't actually exist, and remove them
|
||||||
|
* so migrate() will re-run those migrations.
|
||||||
|
*/
|
||||||
|
async function baselineIfNeeded(client: Pool) {
|
||||||
|
const hasMigrationsTable = await tableExists(client, "drizzle", "__drizzle_migrations");
|
||||||
|
|
||||||
|
// Always ensure the drizzle schema + table exist
|
||||||
|
await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hash text NOT NULL,
|
||||||
|
created_at bigint
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const { rows: entryRows } = await client.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`
|
||||||
|
);
|
||||||
|
const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0;
|
||||||
|
|
||||||
|
if (!hasMigrationsTable || !hasEntries) {
|
||||||
|
// No history at all — check if DB was previously set up via db:push
|
||||||
|
const dbAlreadyExists = await tableExists(client, "public", "beenvoice_account");
|
||||||
|
if (!dbAlreadyExists) {
|
||||||
|
return; // Fresh DB — let migrate() run everything normally
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[migrate] Existing database detected without migration history — baselining...");
|
||||||
|
await seedMigrationHistory(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration history exists — validate that each recorded migration is
|
||||||
|
// actually reflected in the schema. Remove any bogus entries.
|
||||||
|
await removeBogusEntries(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMigrationHistory(client: Pool) {
|
||||||
|
const journal = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
|
||||||
|
) as { entries: { idx: number; tag: string; when: number }[] };
|
||||||
|
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const applied = await isMigrationApplied(client, entry.tag);
|
||||||
|
if (!applied) {
|
||||||
|
console.log(`[migrate] Not yet in schema, will run: ${entry.tag}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
|
||||||
|
);
|
||||||
|
const hash = crypto.createHash("sha256").update(sql).digest("hex");
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
|
||||||
|
[hash, entry.when]
|
||||||
|
);
|
||||||
|
console.log(`[migrate] Baselined: ${entry.tag}`);
|
||||||
|
}
|
||||||
|
console.log("[migrate] Baseline complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeBogusEntries(client: Pool) {
|
||||||
|
// Get all recorded hashes
|
||||||
|
const { rows } = await client.query<{ id: number; hash: string }>(
|
||||||
|
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`
|
||||||
|
);
|
||||||
|
|
||||||
|
const journal = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
|
||||||
|
) as { entries: { idx: number; tag: string; when: number }[] };
|
||||||
|
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
|
||||||
|
);
|
||||||
|
const expectedHash = crypto.createHash("sha256").update(sql).digest("hex");
|
||||||
|
const recorded = rows.find((r) => r.hash === expectedHash);
|
||||||
|
if (!recorded) continue; // Not recorded yet — migrate() will run it
|
||||||
|
|
||||||
|
// It's recorded — verify it's actually applied in the schema
|
||||||
|
const applied = await isMigrationApplied(client, entry.tag);
|
||||||
|
if (!applied) {
|
||||||
|
console.log(`[migrate] Removing bogus migration record for: ${entry.tag}`);
|
||||||
|
await client.query(`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`, [recorded.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(client: Pool, schema: string, table: string): Promise<boolean> {
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.tables
|
||||||
|
WHERE table_schema = $1 AND table_name = $2
|
||||||
|
`, [schema, table]);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a specific migration's schema changes already exist in the DB.
|
||||||
|
*/
|
||||||
|
async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
|
||||||
|
if (tag === "0000_glossy_magneto") {
|
||||||
|
return tableExists(client, "public", "beenvoice_account");
|
||||||
|
}
|
||||||
|
if (tag === "0001_supreme_the_enforcers") {
|
||||||
|
// 0001 adds currency to beenvoice_client
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_client'
|
||||||
|
AND column_name = 'currency'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
if (tag === "0002_tax_deductible") {
|
||||||
|
// 0002 adds taxDeductible to beenvoice_expense
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_expense'
|
||||||
|
AND column_name = 'taxDeductible'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
// Unknown migration — assume not applied so it runs
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[migrate] Running migrations from", migrationsFolder);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await baselineIfNeeded(pool);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
console.log("[migrate] All migrations applied successfully");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[migrate] Migration failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
+104
-1
@@ -39,7 +39,9 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
clients: many(clients),
|
clients: many(clients),
|
||||||
businesses: many(businesses),
|
businesses: many(businesses),
|
||||||
invoices: many(invoices),
|
invoices: many(invoices),
|
||||||
sessions: many(sessions), // Added missing relation
|
sessions: many(sessions),
|
||||||
|
expenses: many(expenses),
|
||||||
|
invoiceTemplates: many(invoiceTemplates),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accounts = createTable(
|
export const accounts = createTable(
|
||||||
@@ -140,6 +142,7 @@ export const clients = createTable(
|
|||||||
postalCode: d.varchar({ length: 20 }),
|
postalCode: d.varchar({ length: 20 }),
|
||||||
country: d.varchar({ length: 100 }),
|
country: d.varchar({ length: 100 }),
|
||||||
defaultHourlyRate: d.real(),
|
defaultHourlyRate: d.real(),
|
||||||
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
createdById: d
|
createdById: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -238,6 +241,7 @@ export const invoices = createTable(
|
|||||||
totalAmount: d.real().notNull().default(0),
|
totalAmount: d.real().notNull().default(0),
|
||||||
taxRate: d.real().notNull().default(0.0),
|
taxRate: d.real().notNull().default(0.0),
|
||||||
notes: d.varchar({ length: 1000 }),
|
notes: d.varchar({ length: 1000 }),
|
||||||
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
createdById: d
|
createdById: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -309,3 +313,102 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
|
|||||||
references: [invoices.id],
|
references: [invoices.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const expenses = createTable(
|
||||||
|
"expense",
|
||||||
|
(d) => ({
|
||||||
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
||||||
|
clientId: d.varchar({ length: 255 }).references(() => clients.id),
|
||||||
|
invoiceId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.references(() => invoices.id, { onDelete: "set null" }),
|
||||||
|
date: d.timestamp().notNull(),
|
||||||
|
description: d.varchar({ length: 500 }).notNull(),
|
||||||
|
amount: d.real().notNull(),
|
||||||
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
|
category: d.varchar({ length: 100 }),
|
||||||
|
billable: d.boolean().default(false).notNull(),
|
||||||
|
reimbursable: d.boolean().default(false).notNull(),
|
||||||
|
taxDeductible: d.boolean().default(false).notNull(),
|
||||||
|
notes: d.varchar({ length: 500 }),
|
||||||
|
createdById: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: d
|
||||||
|
.timestamp()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||||
|
}),
|
||||||
|
(t) => [
|
||||||
|
index("expense_created_by_idx").on(t.createdById),
|
||||||
|
index("expense_client_id_idx").on(t.clientId),
|
||||||
|
index("expense_invoice_id_idx").on(t.invoiceId),
|
||||||
|
index("expense_date_idx").on(t.date),
|
||||||
|
index("expense_billable_idx").on(t.billable),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expensesRelations = relations(expenses, ({ one }) => ({
|
||||||
|
business: one(businesses, {
|
||||||
|
fields: [expenses.businessId],
|
||||||
|
references: [businesses.id],
|
||||||
|
}),
|
||||||
|
client: one(clients, {
|
||||||
|
fields: [expenses.clientId],
|
||||||
|
references: [clients.id],
|
||||||
|
}),
|
||||||
|
invoice: one(invoices, {
|
||||||
|
fields: [expenses.invoiceId],
|
||||||
|
references: [invoices.id],
|
||||||
|
}),
|
||||||
|
createdBy: one(users, {
|
||||||
|
fields: [expenses.createdById],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const invoiceTemplates = createTable(
|
||||||
|
"invoice_template",
|
||||||
|
(d) => ({
|
||||||
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
name: d.varchar({ length: 255 }).notNull(),
|
||||||
|
type: d.varchar({ length: 50 }).notNull().default("notes"), // "notes" | "terms"
|
||||||
|
content: d.text().notNull(),
|
||||||
|
isDefault: d.boolean().default(false).notNull(),
|
||||||
|
createdById: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: d
|
||||||
|
.timestamp()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||||
|
}),
|
||||||
|
(t) => [
|
||||||
|
index("invoice_template_created_by_idx").on(t.createdById),
|
||||||
|
index("invoice_template_type_idx").on(t.type),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const invoiceTemplatesRelations = relations(
|
||||||
|
invoiceTemplates,
|
||||||
|
({ one }) => ({
|
||||||
|
createdBy: one(users, {
|
||||||
|
fields: [invoiceTemplates.createdById],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,9 @@
|
|||||||
--color-input: hsl(var(--input));
|
--color-input: hsl(var(--input));
|
||||||
--color-ring: hsl(var(--ring));
|
--color-ring: hsl(var(--ring));
|
||||||
|
|
||||||
--font-sans: var(--font-sans), sans-serif;
|
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-heading: var(--font-heading), serif;
|
--font-heading: var(--font-heading), ui-serif, Georgia, serif;
|
||||||
--font-mono: var(--font-geist-mono), monospace;
|
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
|||||||
+4
-4
@@ -7,7 +7,6 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import { type AppRouter } from "~/server/api/root";
|
|
||||||
import { createQueryClient } from "./query-client";
|
import { createQueryClient } from "./query-client";
|
||||||
|
|
||||||
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
||||||
@@ -22,21 +21,22 @@ const getQueryClient = () => {
|
|||||||
return clientQueryClientSingleton;
|
return clientQueryClientSingleton;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const api = createTRPCReact<AppRouter>();
|
// Use inline import() type to avoid pulling server modules into the client bundle
|
||||||
|
export const api = createTRPCReact<import("~/server/api/root").AppRouter>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inference helper for inputs.
|
* Inference helper for inputs.
|
||||||
*
|
*
|
||||||
* @example type HelloInput = RouterInputs['example']['hello']
|
* @example type HelloInput = RouterInputs['example']['hello']
|
||||||
*/
|
*/
|
||||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
export type RouterInputs = inferRouterInputs<import("~/server/api/root").AppRouter>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inference helper for outputs.
|
* Inference helper for outputs.
|
||||||
*
|
*
|
||||||
* @example type HelloOutput = RouterOutputs['example']['hello']
|
* @example type HelloOutput = RouterOutputs['example']['hello']
|
||||||
*/
|
*/
|
||||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
export type RouterOutputs = inferRouterOutputs<import("~/server/api/root").AppRouter>;
|
||||||
|
|
||||||
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|||||||
@@ -108,9 +108,8 @@ fi
|
|||||||
SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION:-false}
|
SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION:-false}
|
||||||
|
|
||||||
if [ "$SKIP_DB_MIGRATION" != "true" ]; then
|
if [ "$SKIP_DB_MIGRATION" != "true" ]; then
|
||||||
echo "[start.sh] Applying database migrations (drizzle-kit push via bunx)"
|
echo "[start.sh] Applying database migrations"
|
||||||
# Use bunx so we don't need devDependencies inside the container
|
SKIP_ENV_VALIDATION=1 bun src/server/db/migrate.ts
|
||||||
SKIP_ENV_VALIDATION=1 bunx -y drizzle-kit@0.30.6 push
|
|
||||||
else
|
else
|
||||||
echo "[start.sh] Skipping DB migration due to SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION}"
|
echo "[start.sh] Skipping DB migration due to SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION}"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user